Merge main into gateway/integration

The scheduled sync has stopped on conflicts since 2026-10-19. Resolved by keeping both sides: main's restructured delete-rollback path in disk/local.rs with the integration-only native-publication record, main's per-instance region with the rustfs-s3-types Region, main's new lifecycle, rebalance and admin code ported to the persisted lifecycle types and rustfs-s3-types errors, both sides' new scanner tests, and the union of the access_consumer imports. The e2e smoke, full (darwin) and repl-nightly selections and the test-count baseline are regenerated from the merged tree.
This commit is contained in:
overtrue
2026-10-22 10:14:41 +09:00
417 changed files with 68454 additions and 7059 deletions
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
| Confirmation | Complete the gate below and end the turn. |
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
| 7 | [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.
@@ -1,4 +0,0 @@
interface:
display_name: "RustFS Release Bump"
short_description: "Prepare RustFS release branches like PR #2957."
default_prompt: "Use $rustfs-release-version-bump to prepare and verify an exact RustFS release-version bump."
@@ -36,6 +36,21 @@ changed lesson.
## Finding Standard
Each finding includes severity, `file:line`, attacker prerequisites, concrete
input/path, impact, smallest safe fix, and a regression check. Do not exaggerate
unauthenticated impact when the actual issue requires authenticated low privilege.
Every candidate names the lower-trust principal, the boundary it crosses, the
affected resource, and the observable result. A best-practice gap or an impact
confined to the caller's own principal is hardening, not a candidate.
Report each candidate under exactly one verdict:
- `confirmed`: a complete `file:line` source trace and a bounded observed
result. Only confirmed findings carry severity; each also states attacker
prerequisites, concrete input/path, impact, smallest safe fix, and a
regression check.
- `needs_validation`: the trace is blocked on one exact unresolved fact, such
as deployment or infrastructure behavior. State that fact; assign no severity
and do not substitute an assumption.
- `rejected`: a disproved candidate, with the caller, invariant, or test that
disproves it.
Do not exaggerate unauthenticated impact when the actual issue requires
authenticated low privilege.
@@ -1,4 +0,0 @@
interface:
display_name: "Security Advisory Lessons"
short_description: "Apply advisory lessons in reviews."
default_prompt: "Use $security-advisory-lessons for a dedicated RustFS security review grounded in past advisories."
-5
View File
@@ -1,5 +0,0 @@
# Bound individual tool outputs retained in context; retrieve relevant ranges
# from task-owned log files when more evidence is needed.
# https://learn.chatgpt.com/docs/config-file/config-reference
tool_output_token_limit = 4000
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-linux=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
sha256-darwin=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
sha256-linux=15fc5a29a558764622b02b1e7bc33d4b7aeb7de1a1a440dc110e207d56bebe49
sha256-darwin=15fc5a29a558764622b02b1e7bc33d4b7aeb7de1a1a440dc110e207d56bebe49
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=28ed2484cfcb24f88fa752c4223883df81e4bb973be27b2d491938b8915da67a
sha256-linux=53e20bfece60fcfbdf00665ac6334d3a2069b86b1a52337c3e505fc0a7136878
sha256-darwin=0afcfef2497fee81c74feafb9b3ccf7ba20e732c8232032a74dd7e7e3161ebee
sha256-linux=137098f8bc31a5ca38cac35a5b628346c1082b0277ec3982e2e0ffb69d0ea2e4
+1 -1
View File
@@ -1 +1 @@
sha256=26ab8733d1f92e634eade93f355d3511a152df5fc5022ace9de774fc8dbb4641
sha256=4eac269f9a59b2f9be4e7a87ea6aa38454f5ebe10ede35c9b8d4218137d2cd35
+1 -1
View File
@@ -1 +1 @@
sha256=266abc11b869460fc40b13fca7833d3fb2b353aa023ae0e786885d66cf4846e3
sha256=cd5e208dd7c2e595d5a7e1ebb52e894c27ff215e06a1141f88f01d156d0578d6
+47 -2
View File
@@ -76,6 +76,27 @@ 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"
# These eight-disk decommission fixtures exercise concurrent retries,
# free-version cleanup, and real capacity probes under production lock deadlines.
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
threads-required = "num-test-threads"
# This registration/backup lock probe uses an isolated eight-disk store.
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
# registration budget; retain the queued backup and deadlock assertions.
[[profile.default.overrides]]
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
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"
@@ -89,8 +110,11 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
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::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))'
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]]
@@ -332,6 +356,27 @@ 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"
# These eight-disk decommission fixtures exercise concurrent retries,
# free-version cleanup, and real capacity probes under production lock deadlines.
# Reserve the run's capacity so unrelated IO cannot exhaust those budgets.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & (test(/^store::init::tests::decommission_entry_(retries_source_changed_without_canceling_other_bucket|skips_cleanup_only_marker_when_free_version_is_present)$/) | test(=core::pools::tests::decommission_capacity_mutation_limits_prewrite_sampling_to_target))'
threads-required = "num-test-threads"
# This registration/backup lock probe uses an isolated eight-disk store.
# Reserve capacity so sibling disk-heavy tests cannot consume its 2s
# registration budget; retain the queued backup and deadlock assertions.
[[profile.ci.overrides]]
filter = 'package(rustfs) & test(=admin::handlers::table_catalog::tests::cold_strong_registration_completes_while_other_bucket_backup_waits)'
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"
@@ -346,7 +391,7 @@ 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::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))'
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]]
+18 -18
View File
@@ -9,40 +9,40 @@
# the PR that adds tests; lowering one is a reviewed statement that tests were
# intentionally removed. Regenerate with:
# scripts/check_test_count_ratchet.sh --update
e2e_test 908 18
rustfs 5712 63
e2e_test 934 19
rustfs 5806 63
rustfs-audit 70 0
rustfs-checksums 26 0
rustfs-common 25 0
rustfs-common 26 0
rustfs-concurrency 27 0
rustfs-config 43 0
rustfs-connect-symbol-catalog 6 0
rustfs-credentials 24 0
rustfs-crypto 67 0
rustfs-data-usage 54 0
rustfs-ecstore 6588 1
rustfs-data-usage 55 0
rustfs-ecstore 6933 1
rustfs-extension-schema 23 0
rustfs-filemeta 306 0
rustfs-heal 856 3
rustfs-filemeta 335 0
rustfs-heal 934 3
rustfs-heal-contracts 6 0
rustfs-iam 390 0
rustfs-iam 394 0
rustfs-io-core 49 0
rustfs-io-metrics 131 0
rustfs-io-metrics 142 0
rustfs-keystone 29 0
rustfs-kms 715 25
rustfs-license 2 0
rustfs-lifecycle 152 0
rustfs-lifecycle 153 0
rustfs-lock 135 0
rustfs-log-analyzer 89 0
rustfs-madmin 209 0
rustfs-madmin 210 0
rustfs-notify 136 0
rustfs-object-capacity 92 0
rustfs-object-data-cache 140 0
rustfs-obs 367 0
rustfs-policy 704 0
rustfs-obs 378 0
rustfs-policy 725 0
rustfs-protocols 736 1
rustfs-protos 64 0
rustfs-replication 207 0
rustfs-protos 65 0
rustfs-replication 209 0
rustfs-rio 200 0
rustfs-rio-v2 34 6
rustfs-s3-client 69 0
@@ -50,8 +50,8 @@ rustfs-s3-ops 8 0
rustfs-s3-types 99 0
rustfs-s3select-api 207 0
rustfs-s3select-query 189 2
rustfs-scanner 1018 20
rustfs-scanner-metrics 81 0
rustfs-scanner 1104 20
rustfs-scanner-metrics 84 0
rustfs-security-governance 20 0
rustfs-signer 31 0
rustfs-storage-api 34 0
@@ -59,5 +59,5 @@ rustfs-targets 603 26
rustfs-test-utils 1 0
rustfs-tls-runtime 43 0
rustfs-trusted-proxies 68 0
rustfs-utils 217 0
rustfs-utils 238 0
rustfs-zip 30 0
+5
View File
@@ -23,6 +23,11 @@ duplicate its rules here.
pass by fixing the cause, not by weakening the gate).
- If `shellcheck` is installed, actionlint also lints `run:` scripts; treat
those findings as part of the gate.
- Runner placement and billing rules live in `.github/workflows/README.md`.
A new job defaults to `ubuntu-latest` (free for this public repository);
a self-hosted `runs-on` needs one of the reasons listed there, stated in a
comment next to it, and a `rust-cache` key is never shared between the
hosted and self-hosted images.
## CI Alignment
+1 -1
View File
@@ -1,6 +1,6 @@
[
{ "workflow": ".github/workflows/audit.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
+74
View File
@@ -0,0 +1,74 @@
# Workflow runners: what is billed and where a job belongs
**Use this when:** adding a job, changing a `runs-on`, moving a lane between runner pools, or deciding whether a new check is worth its runner cost.
**Source of truth:** `runs-on` in each workflow file for current placement; `.github/actionlint.yaml` for the declared self-hosted labels; the organisation's Settings → Billing → Usage page for what is actually charged; `docs/testing/ci-gates.md` for which checks block a merge (that does not change when a runner changes).
## Billing facts
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
Every other label in this repository is capacity RustFS pays for by the hour whether or not a job is running well:
| Label | Paid by | What it is | Use it for |
|---|---|---|---|
| `ubuntu-latest` | nobody | GitHub-hosted, Ubuntu 24.04, 4 vCPU, 16 GB RAM, about 14 GB of free disk. Ships a C toolchain, `docker`, `gh`, `curl`, `wget`, `jq`, Python. Can `mount` tmpfs and loop devices; io_uring works | the default for every job |
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
| `sm-standard-4-arm` | RustFS | ARC pod, aarch64; reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds |
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
## Placement rule
A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason goes in a comment next to its `runs-on`:
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
3. **Hardware.** Native aarch64, Docker-in-Docker, or the lab machines.
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
## Cache rule
`Swatinem/rust-cache` builds its key from the OS, architecture, toolchain and lockfile. It does not include the runner label or image, so a cache written on an `sm-standard-4` pod and one written on `ubuntu-latest` would overwrite each other under the same `shared-key`.
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build.
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
## Where self-hosted minutes leak
These are the patterns that have cost real money here without validating anything more:
- An `if: always()` summary or alert job on a self-hosted label inside a `workflow_run`-triggered workflow. The upstream workflow completes many times a day, the real jobs skip, and the summary still starts a pod each time. Gate it on the upstream job having run, and keep it on `ubuntu-latest`.
- Per-push workflows on `main`. A merge triggers the full `ci.yml` matrix, `cache-warm.yml` and the `build.yml` development build; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
- `apt-get install` of tools the hosted image already ships. It is only needed on the minimal pod images.
- A throttled `CARGO_BUILD_JOBS` on a 14-CPU pod. The limit exists for link-storm reasons documented in `ci.yml`; measure before raising it, and measure before lowering it further.
- Tiny jobs on ARC pods. Each one pays roughly a minute of pod start-up before its first step; a compile-free job that takes ten seconds costs more to schedule than to run.
## Measuring a run
To see which pool each job of a run used and for how long:
```bash
gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
```
Only rows whose label starts with `sm-`, `dind-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
## Checklist for a new or moved job
- `runs-on` is a literal label. A new self-hosted label is declared in `.github/actionlint.yaml` in the same change (`.github/AGENTS.md`).
- The job declares `timeout-minutes` (`scripts/security/check_job_timeouts.sh`).
- `actionlint` reports nothing.
- A self-hosted `runs-on` carries a comment naming its reason from the placement rule.
- Every `./.github/actions/setup` call states `cache-save-if`, and a key read from one pool is not written from the other.
- Moving a job does not change its check name, so the `main` ruleset and `docs/testing/ci-gates.md` stay as they are; renaming it does, and then that document's change checklist applies.
+4 -4
View File
@@ -77,7 +77,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -89,7 +89,7 @@ jobs:
cargo-deny:
name: Cargo Deny
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
@@ -132,7 +132,7 @@ jobs:
workflow-pin-report:
name: Workflow Pin Report
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
@@ -169,7 +169,7 @@ jobs:
dependency-review:
name: Dependency Review
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.event_name == 'pull_request' && github.event.action != 'closed'
permissions:
+12 -42
View File
@@ -22,9 +22,12 @@
# 3. Trigger docker.yml to build and push images using the uploaded binaries
#
# Platform scope:
# - Pushes to main (development builds) build the Linux targets only — they
# feed the dev download channel and Docker dev images. Tags, the weekly
# schedule and manual dispatch build the full platform matrix.
# - Development builds come from the nightly schedule and manual dispatch and
# cover the full platform matrix; they feed the dev download channel
# (artifacts/rustfs/dev on R2, the *-main-latest packages). Tags build the
# release matrix. Main pushes do not build: at the usual merge rate most
# per-merge builds were cancelled by the next merge before publishing
# anything, and the dev channel does not need per-merge granularity.
#
# Manual Parameters:
# - publish: Publish binaries/releases for manual runs (default: false)
@@ -36,24 +39,8 @@ name: Build and Release
on:
push:
tags: [ "*.*.*" ]
branches: [ main ]
paths-ignore:
- "**.md"
- "**.txt"
- ".github/**"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "LICENSE*"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".gitignore"
- ".dockerignore"
- "flake.lock"
schedule:
- cron: "13 1 * * 0" # Weekly on Sunday 01:13 UTC
- cron: "13 1 * * *" # Nightly 01:13 UTC
workflow_dispatch:
inputs:
publish:
@@ -93,7 +80,7 @@ jobs:
# Build strategy check - determine build type based on trigger
build-check:
name: Build Strategy Check
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -144,12 +131,6 @@ jobs:
build_type="release"
echo "📦 Release build detected: $tag_name"
fi
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
# Main branch push - development build
should_build=true
build_type="development"
version="dev-${short_sha}"
echo "🛠️ Development build detected"
elif [[ "${{ github.event_name }}" == "schedule" ]] || \
[[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Scheduled or manual build
@@ -178,7 +159,7 @@ jobs:
prepare-platform-matrix:
name: Prepare Platform Matrix
needs: build-check
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
matrix: ${{ steps.select.outputs.matrix }}
@@ -200,17 +181,6 @@ jobs:
selected="all"
fi
# Per-merge development builds only feed the dev download channel
# and the Docker dev images, which consume the Linux binaries; at
# the usual merge cadence most per-merge builds are cancelled by the
# next merge anyway. macOS and Windows stay covered by tag builds,
# the weekly scheduled build and manual dispatch.
if [[ "${selected}" == "all" \
&& "${{ github.event_name }}" == "push" \
&& "${{ needs.build-check.outputs.build_type }}" == "development" ]]; then
selected="linux-x86_64-musl,linux-aarch64-musl,linux-x86_64-gnu,linux-aarch64-gnu"
echo "Development (main push) build: restricting to Linux targets"
fi
all='{"include":[
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
@@ -645,7 +615,7 @@ jobs:
LATEST_FILES="$LATEST_FILE"
fi
elif [[ "$BUILD_TYPE" == "development" ]]; then
# Development builds (only main branch triggers development builds)
# Development builds (nightly schedule and manual dispatch)
# Create main-latest version filename
# Convert from rustfs-linux-x86_64-dev-abc123 to rustfs-linux-x86_64-main-latest
MAIN_LATEST_FILE="${PACKAGE_BASENAME}-main-latest.zip"
@@ -860,7 +830,7 @@ jobs:
name: Build Summary
needs: [ build-check, build-rustfs ]
if: always() && needs.build-check.outputs.should_build == 'true'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Build completion summary
@@ -1049,7 +1019,7 @@ jobs:
name: Update Latest Version
needs: [ build-check, publish-release ]
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Update latest.json
+31 -22
View File
@@ -42,26 +42,23 @@
name: Cache Warm
on:
schedule:
# Every six hours; the 22:41 UTC run lands before ci.yml's nightly 00:11
# UTC matrix. A reader restores the newest entry under its key and
# rebuilds whatever its own diff changed, so a cache up to six hours old
# costs a PR the handful of crates merged since. Warming after every merge
# (fourteen completed runs a day, about two sm-standard-4 hours each)
# spent far more than that to avoid it.
- cron: "41 4,10,16,22 * * *"
push:
branches: [ main ]
# Mirrors ci.yml's push paths-ignore: if a commit cannot change what ci.yml
# compiles, it cannot change what ci.yml needs restored either.
paths-ignore:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
# A toolchain or lockfile change invalidates most of every key; those
# re-warm at once instead of waiting for the next slot.
paths:
- "Cargo.lock"
- "rust-toolchain.toml"
- ".github/actions/setup/**"
- ".github/workflows/cache-warm.yml"
workflow_dispatch:
inputs:
emit_timings:
@@ -75,7 +72,7 @@ on:
permissions:
contents: read
# Scoped by event. A push run and a dispatch run do not compete: GitHub keeps
# Scoped by event. Scheduled, push and dispatch runs do not compete: GitHub keeps
# one running plus one pending per group, so with a single shared group a
# manually dispatched run was displaced as pending by the next merge and
# cancelled — observed three times in a row, which made the --timings gate in
@@ -162,6 +159,17 @@ jobs:
cargo build --workspace --all-targets
cargo build -p rustfs --bins --features e2e-test-hooks
# ci.yml runs Clippy on every pull request. Clippy's check-mode artifacts
# are separate from the build above, so warm them with the exact
# invocation ci.yml uses; a pull request then re-checks only what its diff
# touches. A lint failure on main must not stop the warm-up: the cache is
# still saved (cache-on-failure), and the failure belongs to ci.yml.
- name: Warm Clippy check artifacts
run: |
if ! cargo clippy --all-targets -- -D warnings; then
echo "::warning::cargo clippy reports findings on this commit; check artifacts are still cached"
fi
# Runs before rust-cache's post step, so these are the sizes it is about
# to archive. Reported so the cache-all-crates decision stays evidence-led:
# registry/src is what that flag prunes, registry/cache is what the pruned
@@ -185,9 +193,10 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Readers: test-and-lint-rio-v2 (per-PR), build-rustfs-debug-binary-rio-v2
# (weekly schedule / manual dispatch only — dormant rio-v2 variant, see
# rustfs/backlog#1835 and docs/architecture/minio-file-format-compat.md).
# Readers: test-and-lint-rio-v2 (nightly schedule, merge group, dispatch),
# build-rustfs-debug-binary-rio-v2 (nightly schedule / manual dispatch only —
# dormant rio-v2 variant, see rustfs/backlog#1835 and
# docs/architecture/minio-file-format-compat.md).
# The second build below stays despite the reduced cadence: it warms the
# rio-v2,e2e-test-hooks feature resolution the scheduled build restores,
# which keeps that lane inside its 30-minute timeout.
+94 -107
View File
@@ -44,7 +44,7 @@ on:
merge_group:
types: [ checks_requested ]
schedule:
- cron: "11 0 * * 0" # Weekly on Sunday 00:11 UTC
- cron: "11 0 * * *" # Nightly 00:11 UTC, after the 22:41 UTC cache-warm run
workflow_dispatch:
permissions:
@@ -64,10 +64,15 @@ env:
jobs:
# Compile-free lanes, and lanes that only run a prebuilt binary, use
# GitHub-hosted runners: they are free for this public repository, and the
# self-hosted pool is reserved for the lanes that need its CPU count and
# disk (the workspace test build leaves a ~64GB target/, hosted runners
# have 14GB). Placement and billing rules: README.md in this directory.
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -76,7 +81,7 @@ jobs:
classify-changes:
name: Select CI scope
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
mode: ${{ steps.scope.outputs.mode }}
@@ -102,16 +107,12 @@ jobs:
typos:
name: Typos
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install typos download dependency
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends wget
- name: Typos check with custom config file
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
@@ -119,7 +120,7 @@ jobs:
quick-checks:
name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
@@ -170,10 +171,13 @@ jobs:
echo "started_at=$(date --utc --iso-8601=seconds)"
} > artifacts/test-and-lint/run-metadata.txt
# Keep full Clippy validation on main, merge groups, and scheduled/manual
# runs. PRs retain the complete default test suite and compile-free checks.
# Clippy gates every event, pull requests included. When it ran only after
# merge, a lint regression merged freely and then failed every main run
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
# writes the same invocation's check artifacts into ci-dev, so a pull
# request re-checks only the crates its diff touches. Main keeps the step
# because pull requests can merge without being up to date with main.
- 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
@@ -181,31 +185,27 @@ jobs:
- name: Run nextest tests
env:
# #5394 mitigation, now under a measured experiment (backlog#1601).
# #5394 mitigation, settled by the backlog#1601 experiment.
#
# 2 was chosen when three concurrent workspace test links were believed
# to saturate the runner's overlay I/O and wedge Cargo until the 75m
# timeout. cgroup v2 readings from the sampler show the pod actually
# has 14 CPUs and 28GB (peak use 2.1GB), so 2 throttles compilation to
# a seventh of what is available and memory was never the constraint —
# the label name "sm-standard-4" had led everyone, including the
# original mitigation, to assume 4 cores.
# has 14 CPUs and 28GB (peak use 2.1GB): the label name "sm-standard-4"
# had led everyone, including the original mitigation, to assume 4
# cores, and memory was never the constraint.
#
# Raised to 3 on main pushes and manual dispatches; PRs keep 2 so the
# merge path is untouched while the experiment runs.
#
# During baseline collection, main pushes cancelled superseded runs:
# only 4 of 20 Test and Lint jobs reached a terminal state. Running
# main validations now finish; dispatch remains an independent group
# for collecting additional samples on demand.
#
# Baseline over 17 samples at 2:
# median nextest/clippy step ratio 1.95, spread 1.85-2.06. The gate-2
# criterion is that ratio dropping at least 10% (below ~1.76) with no
# 75m timeout and no run showing three consecutive samples of
# rustc/collect2/rust-lld in D state. If it does not, the conclusion is
# "this limit is not the bottleneck" — fix it back at 2 and record the
# experiment, which is a result, not a failure.
# Measured on the same ci-dev cache, the test-profile compile took
# 28m21s at 2 on a pull request and 16-23 min at 3 across three
# successful main runs. The first pull-request run at 3 that met a
# busy node (run 39193827281) then hit the 75m timeout before any
# test started: the sampler shows cargo spinning for 70 minutes with
# the pod's io.pressure "full" at 86-88% and CPU pressure at zero,
# while node-level IO pressure was already 36% from co-tenants when
# the step began. The constraint is the pod's shared storage, not
# CPU or memory, and the limit that storage sustains on a contended
# node is 2. Main pushes and dispatches keep 3 (rarer, and three
# successes); pull requests stay at 2 until the pods get local disk
# for target/, which is the lever that would unlock the 14 cores.
#
# Must stay step-level: rust-cache hashes CARGO/CC/CFLAGS/CXX/CMAKE/RUST
# prefixed variables from process.env into the cache key, so promoting
@@ -325,10 +325,12 @@ jobs:
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:
name: Offline Enrollment E2E Root Boundary
# Both Connect boundaries rebuild only the rustfs crate on top of the restored
# ci-dev cache: the two e2e features are empty, so every dependency keeps its
# cached dev-profile artifact and each feature set recompiles rustfs once.
# The budget covers a ci-dev miss, where the first step builds the whole graph.
connect-feature-boundaries:
name: Connect Feature Boundaries
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
@@ -353,14 +355,58 @@ jobs:
- name: Protect Connect test home
run: chmod go-w "$(realpath "$HOME")"
- name: Run short credential behavior tests
env:
CARGO_BUILD_JOBS: "2"
run: |
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
registration_enforces_build_profile_credential_lifetime -- --exact
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
# The guard in rustfs/src/connect/registration.rs is cfg(not(debug_assertions)),
# and it must be tripped by a profile that turns debug assertions off for
# the whole graph. Handing `-C debug-assertions=off` to the rustfs lib alone
# through `cargo rustc` leaves its dependencies on the dev-profile cfg, so
# the lib also fails on items gated on one side only (E0599 on
# Filenames::Embedded) and "fails solely at the compile_error" can never
# hold. `cargo check --release` is metadata-only and needs no
# link-concurrency limit; it is cold because ci-dev holds no release
# artifacts.
- name: Reject short credentials in release builds
run: |
log="$(mktemp)"
set +e
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
--features connect-e2e-short-credentials >"$log" 2>&1
status=$?
set -e
cat "$log"
expected='error: connect-e2e-short-credentials is restricted to debug builds'
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
expected_count="$(grep -Fxc "$expected" "$log" || true)"
summary_count="$(grep -Fc "$summary" "$log" || true)"
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
echo "feature gate did not fail solely at the expected compile_error" >&2
rm -f "$log"
exit 1
fi
rm -f "$log"
- name: Check offline enrollment E2E root boundary
run: ./scripts/check_offline_enrollment_e2e.sh
test-and-lint-rio-v2:
name: Test and Lint (rio-v2)
# 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'
# Alternate feature configurations run on the nightly schedule, merge
# groups and manual dispatch. The default workspace remains the PR gate and
# the per-push main baseline: repeating four feature builds on every merge
# bought nothing the nightly run does not report within a day.
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -416,67 +462,10 @@ jobs:
retention-days: 3
if-no-files-found: error
connect-short-credential-boundary:
name: Connect Short Credential Boundary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Run short credential behavior tests
env:
CARGO_BUILD_JOBS: "2"
run: |
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
registration_enforces_build_profile_credential_lifetime -- --exact
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
- name: Reject short credentials in release builds
env:
CARGO_BUILD_JOBS: "2"
run: |
log="$(mktemp)"
set +e
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
--features connect-e2e-short-credentials >"$log" 2>&1
status=$?
set -e
cat "$log"
expected='error: connect-e2e-short-credentials is restricted to debug builds'
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
expected_count="$(grep -Fxc "$expected" "$log" || true)"
summary_count="$(grep -Fc "$summary" "$log" || true)"
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
echo "release feature gate did not fail solely at the expected compile_error" >&2
rm -f "$log"
exit 1
fi
rm -f "$log"
test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})"
if: needs.classify-changes.outputs.mode == 'full' && github.event_name != 'pull_request'
# Same cadence as test-and-lint-rio-v2 above.
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -632,7 +621,7 @@ jobs:
build-rustfs-debug-binary-rio-v2:
name: Build RustFS Debug Binary (rio-v2)
# Dormant rio-v2 variant (rustfs/backlog#1835): the feature ships in no
# default build, so this full-suite lane runs only on the weekly schedule
# default build, so this full-suite lane runs only on the nightly schedule
# and manual dispatch. Per-PR cfg-seam coverage stays with
# test-and-lint-rio-v2. Lifecycle and the promote-or-delete condition:
# docs/architecture/minio-file-format-compat.md ("rio-v2 variant lifecycle").
@@ -1191,7 +1180,7 @@ jobs:
s3-implemented-tests:
name: S3 Implemented Tests
needs: [ build-rustfs-debug-binary, e2e-tests ]
runs-on: sm-standard-4
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout repository
@@ -1260,12 +1249,12 @@ jobs:
name: S3 Lifecycle Behavior Tests
# Also gated on e2e-tests, matching s3-implemented-tests: when the e2e smoke
# suite is already red this lane cannot tell us anything new, and it holds a
# sm-standard-4 for up to 30 minutes doing so. Both lanes only download the
# runner for up to 30 minutes doing so. Both lanes only download the
# prebuilt debug binary (no cargo build), and s3-implemented-tests — which
# already waits on e2e-tests — finishes later anyway, so a green PR's total
# wall clock is unchanged.
needs: [ build-rustfs-debug-binary, e2e-tests ]
runs-on: sm-standard-4
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
@@ -1322,9 +1311,8 @@ jobs:
- typos
- quick-checks
- test-and-lint
- offline-enrollment-e2e
- connect-feature-boundaries
- test-and-lint-rio-v2
- connect-short-credential-boundary
- test-and-lint-protocols
- build-rustfs-debug-binary
- uring-integration
@@ -1334,7 +1322,7 @@ jobs:
- build-rustfs-debug-binary-rio-v2
- e2e-tests-rio-v2
- e2e-full
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -1350,12 +1338,11 @@ jobs:
name: Alert on scheduled failure
needs:
- classify-changes
- connect-short-credential-boundary
- connect-feature-boundaries
- required-checks
- typos
- quick-checks
- test-and-lint
- offline-enrollment-e2e
- test-and-lint-rio-v2
- test-and-lint-protocols
- build-rustfs-debug-binary
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
# Echoes one line; the run exists only so the concurrency group cancels the
# in-flight run of a closed PR.
permissions: {}
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -61,7 +61,7 @@ jobs:
checks: write
issues: write
pull-requests: write
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Report CLA result for merge queue
+3 -3
View File
@@ -83,7 +83,7 @@ jobs:
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch != 'main')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -501,7 +501,7 @@ jobs:
name: Scan Docker Images
needs: [ build-check, build-docker ]
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
@@ -556,7 +556,7 @@ jobs:
name: Docker Build Summary
needs: [ build-check, build-docker ]
if: always() && needs.build-check.outputs.should_build == 'true'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Docker build completion summary
+3 -3
View File
@@ -153,7 +153,7 @@ jobs:
- name: Build rustfs binary
run: |
python3 scripts/e2e_binary.py build --bins
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
- name: Verify distributed e2e membership
env:
@@ -170,9 +170,9 @@ jobs:
set -euo pipefail
rm -f target/nextest/e2e-distributed/junit.xml
if [ -n "${FILTER}" ]; then
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
else
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
fi
test -s target/nextest/e2e-distributed/junit.xml
+1 -1
View File
@@ -371,7 +371,7 @@ jobs:
name: Upstream HEAD classification canary
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
continue-on-error: true
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
+31 -24
View File
@@ -46,14 +46,11 @@ concurrency:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
jobs:
build:
name: Build upgrade candidate
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -66,19 +63,11 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-server
cache-shared-key: e2e-upgrade-server-hosted
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Download pinned previous release
run: |
mkdir -p target/debug
archive="target/debug/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
- name: Build current RustFS binary
run: python3 scripts/e2e_binary.py build
@@ -89,19 +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: Upgrade compatibility
name: Upgrade compatibility (${{ matrix.source.version }})
needs: build
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
source:
- version: 1.0.0-rc.5
asset: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
sha256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
- version: 1.0.1
asset: rustfs-linux-x86_64-gnu-v1.0.1.zip
sha256: 9742e183cb0dfb0d48fa909797af572fe47b7f827569036baa4156e0d3958ee5
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
@@ -111,7 +112,7 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-tests
cache-shared-key: e2e-upgrade-tests-hosted
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
@@ -132,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"
@@ -148,14 +151,18 @@ jobs:
run: |
export no_proxy="$NO_PROXY"
cases=(
direct-upgrade:direct_upgrade_from_rc2_preserves_object_contracts
mixed-version-upgrade:rolling_upgrade_from_rc2_preserves_mixed_version_contracts
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
odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration
multipart-layout-upgrade:direct_upgrade_from_rc5_preserves_multipart_layouts
multipart-layout-baseline:rc5_baseline_replicates_multipart_layouts
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%%:*}"
@@ -180,7 +187,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-g09-evidence-${{ github.run_number }}
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
@@ -189,7 +196,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-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
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
+2 -2
View File
@@ -31,7 +31,7 @@ permissions:
jobs:
build-helm-package:
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
if: |
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
@@ -166,7 +166,7 @@ jobs:
retention-days: 1
publish-helm-package:
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [ build-helm-package ]
if: needs.build-helm-package.result == 'success'
+1 -1
View File
@@ -32,7 +32,7 @@ permissions:
jobs:
build:
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: usthe/issues-translate-action@b41f55ddc81d7d54bd542a4f289fe28ec081898e # v2.7
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve nightly source
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
update-flake:
if: github.repository == 'rustfs/rustfs'
name: Update flake.lock
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -186,7 +186,7 @@ jobs:
cloud-source:
name: ${{ matrix.provider }} source (three-case minimum)
if: github.repository == 'rustfs/rustfs'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
+4 -2
View File
@@ -618,8 +618,10 @@ jobs:
summary:
name: Summary
needs: [ resolve, package ]
if: always()
runs-on: sm-standard-2
# Every completed Build and Release run triggers this workflow; when
# resolve declined it (not a release tag), there is nothing to summarise.
if: always() && needs.resolve.result != 'skipped'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Print summary
@@ -279,7 +279,9 @@ jobs:
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
STEPS="$STEPS,7,8"
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
STEPS="$STEPS,9"
# Step 10 asserts the surviving pools after the removal
# (rustfs#8726), so it only runs together with step 9.
STEPS="$STEPS,9,10"
fi
fi
ARGS=(--steps "$STEPS" --with-warp -y \
@@ -293,6 +295,10 @@ jobs:
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
ARGS+=(--proxy-endpoint "${RUSTFS_POOL_PROXY_ENDPOINT}")
fi
# Finalize the removal so step 10 can exercise the surviving
# pools; passing it without step 9 selected would run step 10
# against a topology where nothing was removed.
case ",${STEPS}," in *,9,*) ARGS+=(--finalize-decommission) ;; esac
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
@@ -44,7 +44,7 @@ permissions:
jobs:
forced-failure:
name: Forced failure
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fail on purpose
+1 -1
View File
@@ -27,7 +27,7 @@ on:
jobs:
stale:
if: github.repository == 'rustfs/rustfs'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
+1 -1
View File
@@ -15,7 +15,7 @@ concurrency:
jobs:
update:
if: github.repository == 'rustfs/rustfs'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
targets-live:
if: github.repository == 'rustfs/rustfs'
name: PostgreSQL, MySQL, AMQP, and NATS
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -60,7 +60,7 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: targets-live-lane
cache-shared-key: targets-live-lane-hosted
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
install-build-packaging-tools: 'false'
install-test-tools: 'false'
+130
View File
@@ -19,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)
}
+37
View File
@@ -3,6 +3,9 @@
This file contains repository-wide rules. Use the nearest subdirectory
`AGENTS.md` for path-specific invariants.
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
multi-pool, supports ILM tiering/lifecycle.
## Precedence
1. System/developer instructions.
@@ -38,6 +41,27 @@ rules. A skill cannot expand the user's requested scope or grant authorization.
pending. Before requesting missing approval, prepare the concrete result that
is already authorized; retain explicit merge and release gates.
- Keep individual tool outputs concise. Save large outputs to task-owned logs
and retrieve only the relevant ranges.
## Common Commands
```bash
cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
```
> **Docker build note**: `buildx build` without `--load` keeps the image in
> the buildx cache only — `docker run` will use a stale local image. The
> Makefile already includes `--load`; if you suspect a stale binary, add
> `--no-cache` to the `buildx build` invocation inside
> `.config/make/build-docker.mak`.
## Task-Specific Guidance
Read only the reference needed for the current task, once per unchanged context:
@@ -65,6 +89,19 @@ or agent scratch notes. Durable architecture belongs under `docs/architecture/`,
operations under `docs/operations/`, and testing references under
`docs/testing/`. `scripts/check_no_planning_docs.sh` enforces this boundary.
## Further References
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
policy: [docs/testing/README.md](docs/testing/README.md)
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
## Verification
Select checks from the final task-owned diff. Scoped `AGENTS.md` files may add a
+2
View File
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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.
@@ -86,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.
-46
View File
@@ -1,46 +0,0 @@
# RustFS — CLAUDE.md
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
multi-pool, supports ILM tiering/lifecycle.
Agent rules (change style, verification, PR conventions, scoped per-crate
guidance) live in [AGENTS.md](AGENTS.md) — follow it. This file only adds
what Claude Code needs on top: commands and pointers.
## Commands
```bash
cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
```
> **Docker build note**: `buildx build` without `--load` keeps the image in
> the buildx cache only — `docker run` will use a stale local image. The
> Makefile already includes `--load`; if you suspect a stale binary, add
> `--no-cache` to the `buildx build` invocation inside
> `.config/make/build-docker.mak`.
## Where to look (do not duplicate here)
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
policy: [docs/testing/README.md](docs/testing/README.md)
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
## Domain conventions worth knowing up front
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
"Cross-Cutting Storage Invariants" — read them before touching metadata or
tiering code.
Generated
+154 -161
View File
@@ -30,7 +30,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.3",
"inout 0.2.5",
]
[[package]]
@@ -319,9 +319,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.2"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e"
dependencies = [
"rustversion",
]
@@ -675,9 +675,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.50"
version = "0.4.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1"
checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
dependencies = [
"compression-codecs",
"compression-core",
@@ -841,9 +841,9 @@ dependencies = [
[[package]]
name = "atoi"
version = "3.1.0"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a8bbe9949e43a1edaa043038c68703b04774156afdfb62ba2cef5bf93d67be"
checksum = "d12c37257553c19e9aee6cba77814fdc9e07844abfc8b3a2c377cd920ee5a993"
dependencies = [
"num-traits",
]
@@ -873,9 +873,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-config"
version = "1.12.0"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4"
checksum = "939e256cfe0ed68e96d32f194339d88210d5c10685cff0787400158f852c6819"
dependencies = [
"aws-credential-types",
"aws-runtime",
@@ -904,9 +904,9 @@ dependencies = [
[[package]]
name = "aws-credential-types"
version = "1.3.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e"
checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -940,9 +940,9 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.10.0"
version = "1.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa"
checksum = "9a0cc2144962cc5e358467b16e3d82981b1d424a7c3d34602be0036103814a1a"
dependencies = [
"aws-credential-types",
"aws-sigv4",
@@ -966,9 +966,9 @@ dependencies = [
[[package]]
name = "aws-sdk-kms"
version = "1.123.0"
version = "1.124.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bbe8833a7ad3b970f2a6a856939c9cb171942dcdfd2ab65f2db3a8baaf73cc1"
checksum = "d08f45fc138774d0421ed7209a568e9da5356d8cb309ca3aa39b8a2481257d94"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -991,9 +991,9 @@ dependencies = [
[[package]]
name = "aws-sdk-s3"
version = "1.152.0"
version = "1.153.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "004d5acbd6850ab58789a43822e0a2ef23927a46b012c17f8fa51253103db9f8"
checksum = "6c5bee59f57aeef2dbbfbee57f50405cbc07a5cacbba40f350a317dfcdb10916"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1027,9 +1027,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sso"
version = "1.114.0"
version = "1.115.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12be2f9c8eef7f5fc919c96d538e629698469a02b4cb75408b26b1bd984ebe79"
checksum = "9ec003d17bc67708c440a5f7fd6f73c6b5e9842957a97bb476138944d2766c5d"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1052,9 +1052,9 @@ dependencies = [
[[package]]
name = "aws-sdk-ssooidc"
version = "1.116.0"
version = "1.117.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7645db8724ea3b82fdccfb67e1b0f637c9d8ab0e7ef29d84884d8d8d73f805d"
checksum = "8e071d2f824173edc63cd888d7fe4fef8b0012bafccf8c3ec2765f293b7fbdb8"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1077,9 +1077,9 @@ dependencies = [
[[package]]
name = "aws-sdk-sts"
version = "1.119.0"
version = "1.120.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03e490aa904849b38e770922faac779dd245f5cf8be81f19065acf18d276e4ad"
checksum = "61e925e0ab1617457f7d6e69be27b83143a5493a6f99bcd0107ae7a498a4052c"
dependencies = [
"arc-swap",
"aws-credential-types",
@@ -1103,9 +1103,9 @@ dependencies = [
[[package]]
name = "aws-sigv4"
version = "1.6.0"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b"
checksum = "0a3ccec0607ae3839cd76830df5ed62468ac1330acdb3dc4ec0502404113d2b6"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
@@ -1114,11 +1114,12 @@ dependencies = [
"aws-smithy-types",
"bytes",
"crypto-bigint 0.5.5",
"ecdsa 0.17.0",
"form_urlencoded",
"hex",
"hmac 0.13.0",
"http 1.5.0",
"p256 0.13.2",
"p256 0.14.0",
"percent-encoding",
"sha2 0.11.1",
"subtle",
@@ -1129,9 +1130,9 @@ dependencies = [
[[package]]
name = "aws-smithy-async"
version = "1.3.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316"
checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e"
dependencies = [
"futures-util",
"pin-project-lite",
@@ -1161,9 +1162,9 @@ dependencies = [
[[package]]
name = "aws-smithy-eventstream"
version = "0.61.4"
version = "0.61.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80c2051c2f1016fb8e6548dd07b8bc2ac9c3fe583721444b92f515e856d31609"
checksum = "f4aecd9c411046779355be31692bdd451ae35b3983ff40dd65258edffcc1a1ce"
dependencies = [
"aws-smithy-types",
"bytes",
@@ -1172,9 +1173,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http"
version = "0.64.1"
version = "0.64.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1"
checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
@@ -1188,15 +1189,14 @@ dependencies = [
"http-body-util",
"percent-encoding",
"pin-project-lite",
"pin-utils",
"tracing",
]
[[package]]
name = "aws-smithy-http-client"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51c89cc3f1f281d659a67a519a1b5c6d445b5ce09fa7e5aee40c2c2707e9509d"
checksum = "aeca69f65362f7375891008ea525fd440ad6db1836458ac188c87b126804114e"
dependencies = [
"aws-smithy-async",
"aws-smithy-protocol-test",
@@ -1224,9 +1224,9 @@ dependencies = [
[[package]]
name = "aws-smithy-json"
version = "0.63.1"
version = "0.65.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18"
checksum = "9f6835fb38bc6fc8c91e584b0366eb4ff595e5c485134dbf4f0bdeaa18856666"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -1263,9 +1263,9 @@ dependencies = [
[[package]]
name = "aws-smithy-query"
version = "0.62.1"
version = "0.64.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056"
checksum = "165f6265342a313ad6f7a1ac17a0cdba30c522039d3f41d8d6871ce8a2b34ecd"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -1276,9 +1276,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.16.0"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba"
checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -1293,16 +1293,15 @@ dependencies = [
"http-body",
"http-body-util",
"pin-project-lite",
"pin-utils",
"tokio",
"tracing",
]
[[package]]
name = "aws-smithy-runtime-api"
version = "1.19.0"
version = "1.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4"
checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -1328,9 +1327,9 @@ dependencies = [
[[package]]
name = "aws-smithy-schema"
version = "0.2.1"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829"
checksum = "1d7a3b01a2a1b453176457b260b0ed55d33de61d35d21b74022c51ec62a42895"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -1339,9 +1338,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.8.1"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1"
checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822"
dependencies = [
"base64-simd",
"bytes",
@@ -1353,7 +1352,6 @@ dependencies = [
"itoa",
"num-integer",
"pin-project-lite",
"pin-utils",
"ryu",
"serde",
"time",
@@ -1363,9 +1361,9 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
version = "0.62.1"
version = "0.64.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf"
checksum = "a6581618a2fd006159016d1c36881d94d61b4a017f4a8d631310875e18252413"
dependencies = [
"aws-smithy-runtime-api",
"aws-smithy-schema",
@@ -1375,9 +1373,9 @@ dependencies = [
[[package]]
name = "aws-types"
version = "1.6.0"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc"
checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee"
dependencies = [
"aws-credential-types",
"aws-smithy-async",
@@ -1978,9 +1976,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.8.0"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2100,7 +2098,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2",
"inout 0.2.3",
"inout 0.2.5",
"zeroize",
]
@@ -2216,9 +2214,9 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.45"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a"
checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
dependencies = [
"brotli 9.0.0",
"bzip2",
@@ -2227,15 +2225,15 @@ dependencies = [
"liblzma",
"lz4",
"memchr",
"zstd 0.14.2",
"zstd-safe 8.1.0",
"zstd 0.14.3",
"zstd-safe 8.2.0",
]
[[package]]
name = "compression-core"
version = "0.4.33"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
[[package]]
name = "concurrent-queue"
@@ -2464,9 +2462,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.5.2"
version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
dependencies = [
"cfg-if",
]
@@ -3800,9 +3798,9 @@ dependencies = [
[[package]]
name = "dial9"
version = "0.5.4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "734474ca4e814f893385c8fdea63688a9e6034ea19a5ec9846f8f515a69c9bed"
checksum = "634f30ebbf229703503cd7c24c106bb832c26c80e4a4de0dff9d9e81bbd10c70"
dependencies = [
"bon",
"dial9-core",
@@ -3817,9 +3815,9 @@ dependencies = [
[[package]]
name = "dial9-core"
version = "0.5.4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ff5074fab1f810a73d58336275eb163e795ab0ae923fcc8437ba58bf2994e33"
checksum = "944efbaf9995121a93b92a9dacdbb6225332914215af1f08758ce3b9ec3a6693"
dependencies = [
"ahash",
"arc-swap",
@@ -3851,9 +3849,9 @@ dependencies = [
[[package]]
name = "dial9-metrique"
version = "0.5.4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3dd61f8f3d44db27e7a5e970c216bf4c19db66ebb68b7f58db2549f010c3a02e"
checksum = "1aefb67b126274a6e0c31a82f624b347be1d531554d35766a2518b5eb04f1f40"
dependencies = [
"dial9-core",
"dial9-trace-format",
@@ -3865,9 +3863,9 @@ dependencies = [
[[package]]
name = "dial9-perf-self-profile"
version = "0.5.4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d320bef9be4ef9c20ca9800cbfc369c319304b5e11d0a7aa931f5482aa5c1bd8"
checksum = "3ad6d9ce2232b6ba154d8fa19416594f65ce283233953eb716d0184e0d5eb3e1"
dependencies = [
"blazesym",
"bon",
@@ -3884,9 +3882,9 @@ dependencies = [
[[package]]
name = "dial9-tokio-telemetry"
version = "0.5.4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a98e1e673f2dd9e223811832be7a4344ddfe48282a2e7efbf1289e47e4322ce"
checksum = "e30fba16e95037d93825bad06f087e99f60babce447fd5f344d039540cd39a39"
dependencies = [
"bon",
"bytes",
@@ -4097,7 +4095,7 @@ dependencies = [
"uuid",
"walkdir",
"zip",
"zstd 0.14.2",
"zstd 0.14.3",
]
[[package]]
@@ -4425,9 +4423,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.14"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "findshlibs"
@@ -5330,9 +5328,9 @@ dependencies = [
[[package]]
name = "hotpath"
version = "0.28.6"
version = "0.28.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2ee4f8d17ebe2400fa845f079acf9ede235be6472512876779af45f7890f934"
checksum = "b163ee2a34ff2fd85fa07c0f7bdc9573b0d49701c749d74c54b1e5b65eb8c368"
dependencies = [
"arc-swap",
"async-channel",
@@ -5365,15 +5363,15 @@ dependencies = [
[[package]]
name = "hotpath-drain"
version = "0.28.6"
version = "0.28.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd9e65572a429f44a48c360559a3f59028579b53e1df07c5f31dc147d2b94a50"
checksum = "15e84a734a1107eca0ced726ca5bfea94c53427f6dbb1ab2b87e9566bb4ea790"
[[package]]
name = "hotpath-macros"
version = "0.28.6"
version = "0.28.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90aafbaf5f6af01ee9db2888644b15995f5306e5c280aec8261feb3e5e214a"
checksum = "23148960ea4f4f2d93970cbc865dc8adfd7e065de68b6a3bdda776d900541a09"
dependencies = [
"proc-macro2",
"quote",
@@ -5707,9 +5705,9 @@ dependencies = [
[[package]]
name = "inout"
version = "0.2.3"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8c0d789294fb457b791f7797a04478704788f30b407e5d0b01deaa846572d9f"
checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [
"block-padding 0.4.2",
"hybrid-array",
@@ -6052,9 +6050,9 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
[[package]]
name = "lapin"
version = "4.12.2"
version = "4.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a585eb3060f00f09a1d231e3030afe59f2c2d63f794787ab1dd87d2f72df6a3"
checksum = "3212643cc0031d982f81a62360998106268587237d81d6e8501a0a323e13b656"
dependencies = [
"amq-protocol",
"async-rs",
@@ -6165,9 +6163,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.190"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libflate"
@@ -6205,9 +6203,9 @@ dependencies = [
[[package]]
name = "liblzma"
version = "0.4.8"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fe0a34ca854fd4f20c07f696fc8675aec78f87d88d29f5e10257a7490a1b2e1"
checksum = "aa980ea6744074a5b5e0ac6b164aca94fc970121869b3e78ae12a2b0097dbea7"
dependencies = [
"liblzma-sys",
]
@@ -6380,9 +6378,9 @@ dependencies = [
[[package]]
name = "lzma-rust2"
version = "0.21.0"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b"
checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb"
dependencies = [
"sha2 0.11.1",
]
@@ -6697,16 +6695,17 @@ dependencies = [
[[package]]
name = "ml-kem"
version = "0.3.2"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66"
checksum = "a9ba01ebcf90f6aff8e9b9f55bb69b19e6250e4ade5787d0040eaa27dd226238"
dependencies = [
"hybrid-array",
"kem",
"module-lattice",
"pkcs8 0.11.0",
"rand_core 0.10.1",
"sha3 0.11.0",
"sha3",
"shake",
]
[[package]]
@@ -6815,7 +6814,7 @@ dependencies = [
"tracing",
"twox-hash",
"url",
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
@@ -6853,15 +6852,16 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "neli"
version = "0.7.4"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87"
checksum = "6b152f4f18819e638ea350a740b16de75099302b5ec3a58f4f8766ef2fcf695d"
dependencies = [
"bitflags 2.13.2",
"byteorder",
"derive_builder",
"getset",
"libc",
"linux-raw-sys",
"log",
"neli-proc-macros",
"parking_lot",
@@ -7112,9 +7112,9 @@ dependencies = [
[[package]]
name = "num_threads"
version = "0.1.7"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
dependencies = [
"libc",
]
@@ -7951,12 +7951,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pin-utils"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13bee6c73da26345c729282832b60b0363cf3dd9f4bfd81d8551b7a1c889a113"
[[package]]
name = "piper"
version = "0.2.5"
@@ -8184,9 +8178,9 @@ checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppmd-rust"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "196a7c80b9a7652aba7cc070827516c2abe4ccdf53d128e1944003cf5726cff1"
checksum = "494b4379484fc92217cc7d9e3a532375b1d01a61889ec8333025dc3b0377af56"
[[package]]
name = "ppv-lite86"
@@ -8524,7 +8518,7 @@ dependencies = [
"tracing",
"url",
"uuid",
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
@@ -9389,7 +9383,7 @@ dependencies = [
"ghash",
"hex-literal",
"hmac 0.13.0",
"inout 0.2.3",
"inout 0.2.5",
"keccak",
"log",
"md5",
@@ -9415,7 +9409,7 @@ dependencies = [
"sec1 0.8.1",
"sha1 0.11.0",
"sha2 0.11.1",
"sha3 0.12.0",
"sha3",
"signature 3.0.0",
"spki 0.8.1",
"ssh-encoding",
@@ -9538,7 +9532,7 @@ dependencies = [
"apache-avro",
"async-trait",
"async_zip",
"atoi 3.1.0",
"atoi 3.1.1",
"atomic_enum",
"aws-config",
"aws-sdk-s3",
@@ -9686,7 +9680,7 @@ dependencies = [
"xxhash-rust",
"zeroize",
"zip",
"zstd 0.14.2",
"zstd 0.14.3",
]
[[package]]
@@ -10054,7 +10048,6 @@ dependencies = [
"rustfs-common",
"rustfs-concurrency",
"rustfs-config",
"rustfs-data-usage",
"rustfs-ecstore",
"rustfs-filemeta",
"rustfs-heal-contracts",
@@ -10190,7 +10183,7 @@ dependencies = [
"tracing",
"twox-hash",
"uuid",
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
@@ -10213,7 +10206,7 @@ dependencies = [
"tokio-rustls",
"tracing",
"uuid",
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
@@ -10226,7 +10219,6 @@ dependencies = [
"http 1.5.0",
"http-body",
"http-body-util",
"hyper",
"moka",
"reqwest",
"rustfs-credentials",
@@ -10361,7 +10353,7 @@ dependencies = [
"thiserror 2.0.21",
"walkdir",
"zip",
"zstd 0.14.2",
"zstd 0.14.3",
]
[[package]]
@@ -10385,18 +10377,18 @@ dependencies = [
[[package]]
name = "rustfs-mimalloc"
version = "0.5.6"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95bb29aa7aee255a84d956b4f5309a3f98993ae6e10836ae5e4e8519e960780f"
checksum = "f40353bb1b86c737dd68a403ae2fb4ca0c9917f8f54b1dad3a14f4d6be61ecc3"
dependencies = [
"rustfs-mimalloc-sys",
]
[[package]]
name = "rustfs-mimalloc-sys"
version = "0.5.6"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e6571957abe93757f0c6dd99182589054ace7da4102af4296ffcc8297eb3524"
checksum = "291d2eabf67ff4fcc9e630e8644d0f503a41d016a8adb2d41853538b2bda71e9"
dependencies = [
"cc",
]
@@ -10532,7 +10524,7 @@ dependencies = [
"tracing-opentelemetry",
"tracing-subscriber",
"url",
"zstd 0.14.2",
"zstd 0.14.3",
]
[[package]]
@@ -11159,7 +11151,7 @@ dependencies = [
"transform-stream",
"url",
"windows",
"zstd 0.14.2",
"zstd 0.14.3",
]
[[package]]
@@ -11328,9 +11320,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -11379,7 +11371,7 @@ dependencies = [
"arc-swap",
"arrayvec",
"async-trait",
"atoi 3.1.0",
"atoi 3.1.1",
"base64-simd",
"bytes",
"bytestring",
@@ -11519,9 +11511,9 @@ checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71"
[[package]]
name = "schannel"
version = "0.1.29"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
@@ -11897,16 +11889,6 @@ dependencies = [
"digest 0.11.3",
]
[[package]]
name = "sha3"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
dependencies = [
"digest 0.11.3",
"keccak",
]
[[package]]
name = "sha3"
version = "0.12.0"
@@ -11931,6 +11913,17 @@ dependencies = [
"serde_json",
]
[[package]]
name = "shake"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
dependencies = [
"digest 0.11.3",
"keccak",
"sponge-cursor",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
@@ -12789,9 +12782,9 @@ dependencies = [
[[package]]
name = "tokio"
version = "1.53.3"
version = "1.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
dependencies = [
"bytes",
"libc",
@@ -13265,9 +13258,9 @@ checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "typed-path"
version = "0.12.3"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
checksum = "54ec2755506471f54baf5e4bb5499d8e510cc1caf271270e778a736bd2d7336c"
[[package]]
name = "typeid"
@@ -13333,9 +13326,9 @@ checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
@@ -13620,9 +13613,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
@@ -13633,14 +13626,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
@@ -14247,9 +14240,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.0.3"
version = "9.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d98561b612a84ae525331a7a094429ef8a5772514ed507ff6db5f54badc971"
checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e"
dependencies = [
"aes 0.9.3",
"bzip2",
@@ -14270,7 +14263,7 @@ dependencies = [
"typed-path",
"zeroize",
"zopfli",
"zstd 0.14.2",
"zstd 0.14.3",
]
[[package]]
@@ -14308,11 +14301,11 @@ dependencies = [
[[package]]
name = "zstd"
version = "0.14.2"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93"
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
dependencies = [
"zstd-safe 8.1.0",
"zstd-safe 8.2.0",
]
[[package]]
@@ -14326,18 +14319,18 @@ dependencies = [
[[package]]
name = "zstd-safe"
version = "8.1.0"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac"
checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b"
dependencies = [
"zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.1.1+zstd.1.5.7"
version = "2.1.2+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078"
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
dependencies = [
"cc",
"pkg-config",
+20 -20
View File
@@ -152,7 +152,7 @@ rustfs-gateway-types = { git = "https://github.com/rustfs/gateway.git", rev = "2
async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" }
mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.50" }
async-compression = { version = "0.4.51" }
async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false }
axum = "0.8.9"
@@ -162,7 +162,7 @@ futures-lite = "2.6.1"
futures-util = "0.3.34"
pollster = "1.0.1"
pulsar = { default-features = false, version = "=6.9.0" }
lapin = { default-features = false, version = "4.12.2" }
lapin = { default-features = false, version = "4.12.3" }
hyper = { version = "1.12.0" }
h2 = { version = "0.4.20" }
hyper-rustls = { default-features = false, version = "0.27.10" }
@@ -175,7 +175,7 @@ minlz = "1.2.3"
reqwest = "0.13.5"
rustfs-kafka-async = { version = "1.4.0" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.53.3" }
tokio = { version = "1.54.0" }
tokio-rustls = { default-features = false, version = "0.26.6" }
tokio-stream = { version = "0.1.19" }
tokio-test = "0.4.6"
@@ -249,22 +249,22 @@ tokio-postgres-rustls = "0.14.0"
# Utilities and Tools
anyhow = "1.0.104"
arc-swap = "1.9.2"
arc-swap = "1.9.3"
rustfs-tokio-tar = { version = "0.7.1" }
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
tar-codec = "0.0.15"
tar-framing = "0.0.15"
atoi = "3.1.0"
atoi = "3.1.1"
atomic_enum = "0.3.0"
aws-config = { version = "1.12.0" }
aws-credential-types = { version = "1.3.0" }
aws-sdk-kms = { default-features = false, version = "1.123.0" }
aws-sdk-s3 = { default-features = false, version = "1.152.0" }
aws-sdk-sts = { default-features = false, version = "1.119.0" }
aws-smithy-async = { version = "1.3.0" }
aws-smithy-http-client = { default-features = false, version = "1.5.0" }
aws-smithy-runtime-api = { version = "1.19.0" }
aws-smithy-types = { version = "1.8.1" }
aws-config = { version = "1.13.0" }
aws-credential-types = { version = "1.3.1" }
aws-sdk-kms = { default-features = false, version = "1.124.0" }
aws-sdk-s3 = { default-features = false, version = "1.153.0" }
aws-sdk-sts = { default-features = false, version = "1.120.0" }
aws-smithy-async = { version = "1.3.1" }
aws-smithy-http-client = { default-features = false, version = "1.5.1" }
aws-smithy-runtime-api = { version = "1.19.1" }
aws-smithy-types = { version = "1.9.0" }
base64-simd = "0.8.0"
brotli = "9.0.0"
clap = { version = "4.6.7" }
@@ -293,7 +293,7 @@ highway = { version = "1.3.0" }
hostname = "0.4.2"
ipnetwork = { version = "0.21.1" }
lazy_static = "1.5.1"
libc = "0.2.190"
libc = "0.2.191"
libsystemd = "0.7.2"
local-ip-address = "0.6.13"
log = "0.4"
@@ -365,13 +365,13 @@ winapi-util = "0.1.11"
windows = { version = "0.62.2" }
windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.19" }
zip = "9.0.3"
zstd = "0.14.2"
zip = "9.0.5"
zstd = "0.14.3"
# Observability and Metrics
metrics = "0.24.6"
metrics-util = "0.20"
dial9 = { version = "0.5.4", default-features = false }
dial9 = { version = "0.5.5", default-features = false }
opentelemetry = { version = "0.33.1" }
opentelemetry-appender-tracing = { version = "0.33.1" }
opentelemetry-otlp = { version = "0.33.1" }
@@ -393,9 +393,9 @@ russh-sftp = "3.0.1"
dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.6" }
rustfs-mimalloc = { version = "0.7.0" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "0.28.6", default-features = false }
hotpath = { version = "0.28.7", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.49" }
+2 -2
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM alpine:3.24.1 AS build
FROM alpine:3.24.2 AS build
ARG TARGETARCH
ARG RELEASE=latest
@@ -70,7 +70,7 @@ RUN set -eux; \
rm -rf rustfs.zip /build/.tmp || true
FROM alpine:3.24.1
FROM alpine:3.24.2
ARG RELEASE=latest
ARG BUILD_DATE
+3 -3
View File
@@ -141,7 +141,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.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
View File
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.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 相关命令
```
+19
View File
@@ -97,6 +97,25 @@ pub fn try_get_global_local_node_name() -> Option<String> {
.filter(|name| !name.is_empty())
}
#[cfg(test)]
mod node_identity_tests {
use super::*;
#[tokio::test]
async fn local_node_name_nonblocking_read_recovers_after_writer_release() {
let previous = get_global_local_node_name().await;
let mut writer = GLOBAL_LOCAL_NODE_NAME.write().await;
*writer = "published-node".to_string();
let unavailable = try_get_global_local_node_name();
drop(writer);
let published = try_get_global_local_node_name();
set_global_local_node_name(&previous).await;
assert!(unavailable.is_none(), "a held writer must make the nonblocking getter unavailable");
assert_eq!(published.as_deref(), Some("published-node"));
}
}
/// Set the global RustFS address used for gRPC connections.
///
/// # Arguments
+9 -1
View File
@@ -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
+25 -3
View File
@@ -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
+14
View File
@@ -60,6 +60,20 @@ pub const DEFAULT_HEALTH_LOCK_ONLINE_TIMEOUT_MS: u64 = 1000;
pub const ENV_STARTUP_READINESS_MAX_WAIT_SECS: &str = "RUSTFS_STARTUP_READINESS_MAX_WAIT_SECS";
pub const DEFAULT_STARTUP_READINESS_MAX_WAIT_SECS: u64 = 120;
/// Wait for peer re-admission before a distributed node publishes startup
/// readiness.
///
/// After this node reaches local readiness it asks the peers in each erasure
/// set it serves, over the internode RPC channel, whether they count its
/// drives as online. Startup readiness is published once a strict majority of
/// each set's peers do, or when the startup budget
/// ([`ENV_STARTUP_READINESS_MAX_WAIT_SECS`]) elapses, or when unreachable or
/// older peers make that majority impossible. The gate always fails open; it
/// never affects steady-state `/health/ready` or request handling. Disable it
/// to restore the local-view-only startup readiness.
pub const ENV_STARTUP_PEER_READMISSION_ENABLE: &str = "RUSTFS_STARTUP_PEER_READMISSION_ENABLE";
pub const DEFAULT_STARTUP_PEER_READMISSION_ENABLE: bool = true;
/// Enable minimal health payload mode for GET `/health*` responses.
/// When enabled, only `status` and `ready` fields are returned.
pub const ENV_HEALTH_MINIMAL_RESPONSE_ENABLE: &str = "RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE";
+1 -1
View File
@@ -242,7 +242,7 @@ const _: () = assert!(!DEFAULT_INTERNODE_OFFLINE_BYPASS);
/// read-after-write window can erode the read quorum and surface as a spurious
/// `InsufficientReadQuorum` (issue #2761). One bounded retry absorbs that transient failure.
/// **Only** idempotent reads use this; write/lock RPCs (`WriteAll`/`RenameData`/`Delete*`/`Lock`
/// /`UnLock`) must never auto-retry, to preserve quorum and idempotency semantics (see `CLAUDE.md`).
/// /`UnLock`) must never auto-retry, to preserve quorum and idempotency semantics (see `AGENTS.md`).
pub const ENV_INTERNODE_IDEMPOTENT_READ_RETRIES: &str = "RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES";
pub const DEFAULT_INTERNODE_IDEMPOTENT_READ_RETRIES: usize = 1;
+54
View File
@@ -59,6 +59,15 @@ pub const SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "max_concurrent_set_scans";
/// Scanner config key capping concurrent scanner disk bucket walks per set.
pub const SCANNER_MAX_CONCURRENT_DISK_SCANS: &str = "max_concurrent_disk_scans";
/// Scanner config key setting how many bucket walkers each local disk runs.
pub const SCANNER_WALKERS_PER_DISK: &str = "walkers_per_disk";
/// Per-local-disk scanner I/O ceiling in operations per second.
pub const SCANNER_DISK_IO_BUDGET_IOPS: &str = "disk_io_budget_iops";
/// Per-local-disk scanner I/O ceiling in bytes per second.
pub const SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC: &str = "disk_io_budget_bytes_per_sec";
/// Scanner config key controlling how often object loops yield.
pub const SCANNER_YIELD_EVERY_N_OBJECTS: &str = "yield_every_n_objects";
@@ -86,6 +95,9 @@ pub const SCANNER_KEYS: &[&str] = &[
SCANNER_CACHE_SAVE_TIMEOUT,
SCANNER_MAX_CONCURRENT_SET_SCANS,
SCANNER_MAX_CONCURRENT_DISK_SCANS,
SCANNER_WALKERS_PER_DISK,
SCANNER_DISK_IO_BUDGET_IOPS,
SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC,
SCANNER_YIELD_EVERY_N_OBJECTS,
SCANNER_ALERT_EXCESS_VERSIONS,
SCANNER_ALERT_EXCESS_VERSION_SIZE,
@@ -214,6 +226,18 @@ pub const ENV_SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "RUSTFS_SCANNER_MAX_CONCU
/// - Example: `export RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS=1`
pub const ENV_SCANNER_MAX_CONCURRENT_DISK_SCANS: &str = "RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS";
/// Environment variable that sets how many bucket walkers each local disk runs.
/// The walkers of one disk share its scanner admission, so a peer still sees one
/// scan session per physical disk. Remote disks keep one walker. The product of
/// local disks and this factor is what `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS`
/// caps; the disk count stays the floor. Valid range is `1` through `16`.
/// - Example: `export RUSTFS_SCANNER_WALKERS_PER_DISK=4`
pub const ENV_SCANNER_WALKERS_PER_DISK: &str = "RUSTFS_SCANNER_WALKERS_PER_DISK";
pub const ENV_SCANNER_DISK_IO_BUDGET_IOPS: &str = "RUSTFS_SCANNER_DISK_IO_BUDGET_IOPS";
pub const ENV_SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC: &str = "RUSTFS_SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC";
/// Environment variable that controls how often scanner object loops yield to the async runtime.
/// A value of `0` disables this extra object-count yield.
/// - Example: `export RUSTFS_SCANNER_YIELD_EVERY_N_OBJECTS=32`
@@ -228,6 +252,36 @@ pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 4;
/// Default disk scan concurrency budget.
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
/// Default bucket walkers per local disk.
///
/// One walker per disk keeps today's I/O profile, which is what rotational
/// media needs. Flash is meant to default higher once a local disk can report
/// its media type; `rustfs_utils::os::Info::rotational` is never populated
/// today, so every deployment starts from one walker.
pub const DEFAULT_SCANNER_WALKERS_PER_DISK: usize = 1;
/// Upper bound for `walkers_per_disk`; a larger factor only multiplies
/// contention on one spindle or NVMe queue without more disks to spread it.
pub const MAX_SCANNER_WALKERS_PER_DISK: usize = 16;
/// Default spelling of both per-disk scanner I/O budget keys: resolve the
/// rate from the detected media. `off` removes the cap; a number is explicit.
pub const DEFAULT_SCANNER_DISK_IO_BUDGET: &str = "auto";
/// Rotational media budget, also used when media cannot be detected: about
/// half of a 7200 rpm spindle's random reads stay available to foreground
/// requests, and the byte ceiling sits well above the steady ~2 MB/s a paced
/// single-disk scan was measured at once admission landed (rustfs/rustfs#8377).
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_ROTATIONAL_IOPS: u64 = 100;
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_ROTATIONAL_BYTES_PER_SEC: u64 = 16 * 1024 * 1024;
/// Flash media budget: a few percent of a SATA SSD's random-read capacity,
/// enough to keep metadata walks far from saturating the device.
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_FLASH_IOPS: u64 = 4000;
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_FLASH_BYTES_PER_SEC: u64 = 256 * 1024 * 1024;
/// Default object interval for cooperative scanner yields.
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
+75
View File
@@ -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 {
+2
View File
@@ -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"] }
+5 -4
View File
@@ -22,7 +22,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **functional** | top-level `*_test.rs` | S3 data plane: `list_objects_*`, `copy_object_*`, `delete_objects_versioning`, `head_object_*`, `checksum_upload`, `compression`, `content_encoding`, `special_chars`, `leading_slash_key`, `create_bucket_region`, `quota`, `data_usage`, `snowball_auto_extract`, `mc_mirror_small_bucket`, `archive_download_integrity`, `version_id_regression`, `delete_marker_migration_semantics` |
| **object_lock** | [`src/object_lock/`](src/object_lock) | Retention / legal-hold / WORM semantics |
| **kms** | [`src/kms/`](src/kms) | SSE-S3 / SSE-KMS / SSE-C, local + Vault backends, multipart encryption. Own guide: [`src/kms/README.md`](src/kms/README.md) |
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `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` |
@@ -200,10 +200,11 @@ python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run
python3 scripts/e2e_binary.py run --binary "$RUSTFS_E2E_STARTUP_CAS_BINARY" --features e2e-test-hooks -- cargo nextest run --profile e2e-full -p e2e_test
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-nightly -p e2e_test
# Distributed 4-node 4-disk lane uses the default server.
# Distributed 4-node 4-disk lane uses the fault-test hooks server; the
# scanner_heal_topology_ fault cases fail closed without it.
# Upgrade cases require RUSTFS_UPGRADE_SOURCE_BINARY and fail closed without it.
python3 scripts/e2e_binary.py build
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test
python3 scripts/e2e_binary.py build --bins --features e2e-test-hooks
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-distributed -p e2e_test
# Replication nightly uses the default server; awscurl is required for STS.
python3 scripts/e2e_binary.py build
+57
View File
@@ -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,
@@ -1830,6 +1846,47 @@ impl RustFSTestClusterEnvironment {
Ok(new_idx)
}
/// Remove retired single-node pools from a stopped multi-pool cluster.
///
/// A decommissioned pool must leave the server command line before the
/// next start; the server refuses to start while it is still listed. The
/// remaining pools keep their node order and are renumbered from zero.
/// Data directories are left on disk for the owning harness to clean up.
pub fn remove_single_node_pools(&mut self, retired: &[usize]) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
if self.nodes.iter().any(|node| node.process.is_some()) {
return Err("stop the cluster before removing a pool".into());
}
let pools = self.topology.normalized_pools();
if pools.iter().any(|nodes| nodes.len() != 1) {
return Err("remove_single_node_pools requires one node per pool".into());
}
if retired.iter().any(|pool| *pool >= pools.len()) || retired.len() >= pools.len() {
return Err(format!("cannot remove pools {retired:?} from {} pools", pools.len()).into());
}
let keep: Vec<usize> = (0..self.nodes.len())
.filter(|node_idx| !retired.contains(&self.nodes[*node_idx].pool_idx))
.collect();
fn retain_indices<T>(values: &mut Vec<T>, keep: &[usize]) {
let old = std::mem::take(values);
*values = old
.into_iter()
.enumerate()
.filter(|(idx, _)| keep.contains(idx))
.map(|(_, value)| value)
.collect();
}
retain_indices(&mut self.nodes, &keep);
retain_indices(&mut self.node_extra_env, &keep);
retain_indices(&mut self.node_capture_log_paths, &keep);
retain_indices(&mut self.volume_proxy_addresses, &keep);
for (pool_idx, node) in self.nodes.iter_mut().enumerate() {
node.pool_idx = pool_idx;
}
self.topology.node_count = self.nodes.len();
self.topology.pools = (0..self.nodes.len()).map(|node_idx| vec![node_idx]).collect();
Ok(())
}
/// Gracefully stop one cluster node and wait for its process to exit.
///
/// This is intentionally separate from [`Self::stop_node`]: the latter is
@@ -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(())
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//! Regression test for rustfs/backlog#629(b): region-aware `CreateBucket`.
//! Region-aware bucket creation and location regressions: backlog#629(b), rustfs#8729.
//!
//! `minio-go`'s `MakeBucket(bucket, "us-east-1")` was reported to fail SigV4
//! validation. The SigV4 signature is verified by `s3s` over the raw request
@@ -23,7 +23,13 @@
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
build_test_s3_config, build_test_sts_client, init_logging, local_http_client, signed_s3_request_with_headers,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::config::Region;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketLocationConstraint, CreateBucketConfiguration};
use std::error::Error;
@@ -33,7 +39,7 @@ mod tests {
async fn test_create_bucket_with_us_east_1_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let client = env.create_s3_client();
client
@@ -48,6 +54,13 @@ mod tests {
.await
.expect("region-aware CreateBucket must pass SigV4 validation and succeed");
let location = client.get_bucket_location().bucket("region-aware-bucket").send().await?;
assert_eq!(
location.location_constraint().map(|value| value.as_str()),
Some(""),
"us-east-1 must serialize an empty LocationConstraint element"
);
// The bucket exists and is listed.
let listed = client.list_buckets().send().await?;
let names: Vec<&str> = listed.buckets().iter().filter_map(|b| b.name()).collect();
@@ -63,7 +76,7 @@ mod tests {
async fn test_create_bucket_without_location_constraint() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let client = env.create_s3_client();
client
@@ -76,4 +89,423 @@ mod tests {
env.stop_server();
Ok(())
}
#[tokio::test]
async fn test_create_bucket_rejects_unserved_location_constraints() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "us-east-1"]).await?;
let client = env.create_s3_client();
for (index, region) in ["eu-1", "not-a-real-region-42", "us-west-2", "EU"].iter().enumerate() {
let bucket = format!("rejected-region-{index}");
let error = client
.create_bucket()
.bucket(&bucket)
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from(*region))
.build(),
)
.send()
.await
.expect_err("a region not served by this instance must be rejected");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidLocationConstraint")
);
let missing = client
.head_bucket()
.bucket(&bucket)
.send()
.await
.expect_err("rejected bucket must not exist");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
}
let missing = client
.get_bucket_location()
.bucket("missing-region-bucket")
.send()
.await
.expect_err("missing bucket must remain an error");
assert_eq!(missing.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchBucket"));
Ok(())
}
#[tokio::test]
async fn test_create_bucket_uses_configured_region() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
for region in ["eu-1", "eu-west-1"] {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", region]).await?;
let client = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new(region))
.build(),
);
let mut configurations = vec![None, Some(CreateBucketConfiguration::builder().build())];
for constraint in ["", region].into_iter().chain((region == "eu-west-1").then_some("EU")) {
configurations.push(Some(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from(constraint))
.build(),
));
}
// Omitted and empty constraints retain the S3-compatible server's site default.
for (index, configuration) in configurations.into_iter().enumerate() {
let bucket = format!("configured-region-{index}");
client
.create_bucket()
.bucket(&bucket)
.set_create_bucket_configuration(configuration)
.send()
.await?;
let location = client.get_bucket_location().bucket(&bucket).send().await?;
assert_eq!(location.location_constraint().map(|value| value.as_str()), Some(region));
}
// A correctly signed request still cannot choose another bucket region.
let error = client
.create_bucket()
.bucket("wrong-default-region")
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from("us-east-1"))
.build(),
)
.send()
.await
.expect_err("the default region must not bypass a custom site region");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidLocationConstraint")
);
let missing = client
.head_bucket()
.bucket("wrong-default-region")
.send()
.await
.expect_err("rejected bucket must not exist");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
}
Ok(())
}
async fn location_policy_client(
env: &RustFSTestEnvironment,
user: &str,
statements: serde_json::Value,
) -> Result<Client, Box<dyn Error + Send + Sync>> {
let secret = "location-policy-test-secret";
admin_create_user(env, user, secret).await?;
let policy = serde_json::json!({"Version":"2012-10-17", "Statement":statements});
admin_add_canned_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
user,
&policy.to_string(),
)
.await?;
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, user, user).await?;
Ok(Client::from_conf(
build_test_s3_config(&env.url, user, secret, None, "location-policy")
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
))
}
#[tokio::test]
async fn test_create_bucket_location_constraint_policy_allow() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
let root = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
);
let configuration = |region: &str| {
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from(region))
.build()
};
for (user, condition, accepts_region, accepts_alias, accepts_missing) in [
(
"location-allow",
serde_json::json!({"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}),
true,
false,
false,
),
(
"location-alias",
serde_json::json!({"StringEquals":{"s3:LocationConstraint":"EU"}}),
false,
true,
false,
),
(
"location-null",
serde_json::json!({"Null":{"s3:LocationConstraint":"true"}}),
false,
false,
true,
),
] {
let client = location_policy_client(
&env,
user,
serde_json::json!([{
"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*", "Condition":condition,
}]),
)
.await?;
for (index, (config, allowed)) in [
(Some(configuration("eu-west-1")), accepts_region),
(Some(configuration("EU")), accepts_alias),
(None, accepts_missing),
(Some(CreateBucketConfiguration::builder().build()), accepts_missing),
(Some(configuration("")), accepts_missing),
]
.into_iter()
.enumerate()
{
let bucket = format!("{user}-{index}");
let result = client
.create_bucket()
.bucket(&bucket)
.set_create_bucket_configuration(config)
.send()
.await;
if allowed {
result.unwrap_or_else(|error| panic!("{bucket} should be allowed: {error:?}"));
root.head_bucket().bucket(&bucket).send().await?;
} else {
let denied = result.expect_err("the requested XML constraint must satisfy the conditional Allow");
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403), "{bucket}");
assert_eq!(
denied.as_service_error().and_then(ProvideErrorMetadata::code),
Some("AccessDenied"),
"{bucket}"
);
let absent = root
.head_bucket()
.bucket(&bucket)
.send()
.await
.expect_err("denied bucket must not be created");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
}
}
}
let object_lock_client = location_policy_client(
&env,
"location-lock",
serde_json::json!([
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}},
{"Effect":"Allow", "Action":["s3:PutBucketObjectLockConfiguration", "s3:PutBucketVersioning"],
"Resource":"arn:aws:s3:::location-*", "Condition":{"Null":{"s3:LocationConstraint":"true"}}}
]),
)
.await?;
object_lock_client
.create_bucket()
.bucket("location-with-lock")
.create_bucket_configuration(configuration("eu-west-1"))
.object_lock_enabled_for_bucket(true)
.send()
.await
.expect("only CreateBucket receives the XML condition; dependent actions see it absent");
let object_lock = root
.get_object_lock_configuration()
.bucket("location-with-lock")
.send()
.await?;
assert_eq!(
object_lock
.object_lock_configuration()
.and_then(|configuration| configuration.object_lock_enabled())
.map(|value| value.as_str()),
Some("Enabled")
);
let versioning = root.get_bucket_versioning().bucket("location-with-lock").send().await?;
assert_eq!(versioning.status().map(|value| value.as_str()), Some("Enabled"));
let user = "location-session";
location_policy_client(
&env,
user,
serde_json::json!([{
"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"
}]),
)
.await?;
let session_policy = serde_json::json!({"Version":"2012-10-17", "Statement":[
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"},
{"Effect":"Deny", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}}
]});
let assumed = build_test_sts_client(&env.url, user, "location-policy-test-secret", None, "location-policy-sts")
.assume_role()
.role_arn("arn:aws:iam::123456789012:role/location-policy")
.role_session_name("location-policy-session")
.policy(session_policy.to_string())
.send()
.await?;
let credentials = assumed.credentials().expect("AssumeRole returns temporary credentials");
let session = Client::from_conf(
build_test_s3_config(
&env.url,
credentials.access_key_id(),
credentials.secret_access_key(),
Some(credentials.session_token()),
"location-policy-session",
)
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
);
let denied = session
.create_bucket()
.bucket("location-session-denied")
.create_bucket_configuration(configuration("eu-west-1"))
.send()
.await
.expect_err("the STS session's conditional Deny must be enforced");
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
let absent = root
.head_bucket()
.bucket("location-session-denied")
.send()
.await
.expect_err("session denial must not create a bucket");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
session.create_bucket().bucket("location-session-allowed").send().await?;
Ok(())
}
#[tokio::test]
async fn test_create_bucket_location_constraint_policy_deny() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec!["--region", "eu-west-1"]).await?;
let root = Client::from_conf(
env.create_s3_client()
.config()
.to_builder()
.region(Region::new("eu-west-1"))
.build(),
);
let user = "location-deny";
let client = location_policy_client(
&env,
user,
serde_json::json!([
{"Effect":"Allow", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*"},
{"Effect":"Deny", "Action":"s3:CreateBucket", "Resource":"arn:aws:s3:::location-*",
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}},
]),
)
.await?;
let denied = client
.create_bucket()
.bucket("location-deny-matching")
.create_bucket_configuration(
CreateBucketConfiguration::builder()
.location_constraint(BucketLocationConstraint::from("eu-west-1"))
.build(),
)
.send()
.await
.expect_err("a matching explicit Deny must override the unconditional Allow");
assert_eq!(denied.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
assert_eq!(denied.raw_response().map(|response| response.status().as_u16()), Some(403));
let absent = root
.head_bucket()
.bucket("location-deny-matching")
.send()
.await
.expect_err("denied bucket must not exist");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
client.create_bucket().bucket("location-deny-missing").send().await?;
let xml = r#"<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><LocationConstraint>eu-west-1</LocationConstraint></CreateBucketConfiguration>"#;
let mut headers = http::HeaderMap::new();
headers.insert("locationconstraint", http::HeaderValue::from_static("us-east-1"));
for (index, body) in [xml.to_owned(), xml.replace("eu-west-1", "eu-west-&#49;")]
.into_iter()
.enumerate()
{
let bucket = format!("location-deny-spoofed-{index}");
let spoofed = signed_s3_request_with_headers(
http::Method::PUT,
&format!("{}/{bucket}?LocationConstraint=us-east-1", env.url),
Some(body),
Some("application/xml"),
user,
"location-policy-test-secret",
&headers,
)
.await?;
assert_eq!(spoofed.status(), reqwest::StatusCode::FORBIDDEN);
assert!(spoofed.text().await?.contains("<Code>AccessDenied</Code>"));
let absent = root
.head_bucket()
.bucket(&bucket)
.send()
.await
.expect_err("spoofed conditions must not create a bucket");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
}
let bucket = "location-anonymous";
root.create_bucket().bucket(bucket).send().await?;
for (effect, match_status, missing_status) in [
("Allow", reqwest::StatusCode::CONFLICT, reqwest::StatusCode::FORBIDDEN),
("Deny", reqwest::StatusCode::FORBIDDEN, reqwest::StatusCode::CONFLICT),
] {
let mut statements = vec![];
if effect == "Deny" {
statements.push(serde_json::json!({"Effect":"Allow", "Principal":"*", "Action":"s3:CreateBucket", "Resource":format!("arn:aws:s3:::{bucket}")}));
}
statements.push(serde_json::json!({"Effect":effect, "Principal":"*", "Action":"s3:CreateBucket", "Resource":format!("arn:aws:s3:::{bucket}"),
"Condition":{"StringEquals":{"s3:LocationConstraint":"eu-west-1"}}}));
root.put_bucket_policy()
.bucket(bucket)
.policy(serde_json::json!({"Version":"2012-10-17", "Statement":statements}).to_string())
.send()
.await?;
let unsigned = local_http_client();
for (body, expected) in [(xml, match_status), ("", missing_status)] {
let response = unsigned
.put(format!("{}/{bucket}", env.url))
.header("content-type", "application/xml")
.body(body)
.send()
.await?;
assert_eq!(response.status(), expected, "anonymous {effect}, XML present={}", !body.is_empty());
let response = response.text().await?;
assert!(response.contains(if expected == reqwest::StatusCode::FORBIDDEN {
"<Code>AccessDenied</Code>"
} else {
"<Code>BucketAlreadyExists</Code>"
}));
}
}
Ok(())
}
}
+129 -15
View File
@@ -39,6 +39,7 @@ use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::{get_object::GetObjectError, put_object::PutObjectError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use futures::future::try_join_all;
use http::{Method, StatusCode};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
@@ -77,6 +78,7 @@ pub(crate) struct DistCluster {
pub cluster: RustFSTestClusterEnvironment,
pool_storage_roots: Option<Vec<PathBuf>>,
owned_pool_dirs: Vec<PathBuf>,
capture_log_dir: Option<PathBuf>,
}
impl DistCluster {
@@ -128,11 +130,12 @@ impl DistCluster {
for &(key, value) in extra_env {
cluster.set_env(key, value);
}
configure_node_logs(&mut cluster)?;
configure_node_logs(&mut cluster, None)?;
Ok(Self {
cluster,
pool_storage_roots,
owned_pool_dirs,
capture_log_dir: None,
})
}
@@ -170,6 +173,14 @@ impl DistCluster {
Ok(())
}
/// Append every node's stdout/stderr to `directory/node-<idx>.log`, including
/// nodes added by later pool expansion. Call before the first start so startup
/// events such as the resolved durability mode are captured.
pub fn capture_logs_under(&mut self, directory: &Path) -> TestResult {
self.capture_log_dir = Some(directory.to_path_buf());
configure_node_logs(&mut self.cluster, Some(directory))
}
pub fn client_with_credentials(&self, node_idx: usize, access_key: &str, secret_key: &str) -> TestResult<Client> {
if node_idx >= self.cluster.nodes.len() {
return Err("node_idx is invalid".into());
@@ -183,6 +194,16 @@ impl DistCluster {
)))
}
/// Stop the fleet, drop retired pools from the command line, and restart
/// the remaining pools, which are renumbered from zero.
pub async fn remove_retired_pools_and_restart(&mut self, retired: &[usize]) -> TestResult {
self.stop_all_gracefully().await?;
self.cluster.remove_single_node_pools(retired)?;
self.cluster.start().await?;
wait_for_ready(&self.cluster).await?;
Ok(())
}
pub async fn append_pool_and_restart(&mut self) -> TestResult<usize> {
self.stop_all_gracefully().await?;
let node_idx = self.cluster.append_single_node_pool().await?;
@@ -192,7 +213,7 @@ impl DistCluster {
.ok_or("pool expansion requires configured isolated pool filesystems")?;
self.owned_pool_dirs
.push(relocate_pool_storage(&mut self.cluster, node_idx, roots)?);
configure_node_logs(&mut self.cluster)?;
configure_node_logs(&mut self.cluster, self.capture_log_dir.as_deref())?;
self.cluster.start().await?;
wait_for_ready(&self.cluster).await?;
Ok(node_idx)
@@ -326,7 +347,19 @@ fn relocate_pool_storage(cluster: &mut RustFSTestClusterEnvironment, node_idx: u
Ok(pool_run_root)
}
fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
fn configure_node_logs(cluster: &mut RustFSTestClusterEnvironment, capture_dir: Option<&Path>) -> TestResult {
if let Some(directory) = capture_dir {
std::fs::create_dir_all(directory)?;
for node_idx in 0..cluster.nodes.len() {
// Overrides a path assigned from RUSTFS_E2E_LOG_DIR so every node of
// the run, including the seed node, logs into the same directory.
let path = directory.join(format!("node-{node_idx}.log"));
if cluster.node_capture_log_paths[node_idx].as_deref() != Some(path.to_string_lossy().as_ref()) {
cluster.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
}
}
return Ok(());
}
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
return Ok(());
};
@@ -656,13 +689,50 @@ pub(crate) async fn wait_for_replicated_bytes(
.await
}
/// Quota stats for `bucket` from every node at once. Each node answers from
/// its own cached copy of the persisted usage snapshot, and only the scanner
/// leader's save invalidates that copy. Leadership goes to whichever node
/// starts its scanner first, so a probe pinned to one node can wait out the
/// whole cache TTL on a node that never saves.
pub(crate) async fn quota_stats_on_every_node(
cluster: &RustFSTestClusterEnvironment,
bucket: &str,
) -> TestResult<Vec<(StatusCode, String)>> {
let path = format!("/rustfs/admin/v3/quota-stats/{bucket}");
try_join_all(
cluster
.nodes
.iter()
.map(|node| admin_request(&node.url, Method::GET, &path, None, &cluster.access_key, &cluster.secret_key)),
)
.await
}
/// `current_usage` from a quota-stats body. `None` means the node has no
/// scanner usage for the bucket yet: a quota admitted by the reservation
/// ledger reports `null` rather than a guess.
pub(crate) fn quota_stats_current_usage(body: &str) -> TestResult<Option<u64>> {
let stats: serde_json::Value =
serde_json::from_str(body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
match stats.get("current_usage") {
Some(serde_json::Value::Null) => Ok(None),
Some(usage) => Ok(Some(
usage
.as_u64()
.ok_or_else(|| format!("quota stats current_usage is not a byte count: {stats}"))?,
)),
None => Err(format!("quota stats omitted current_usage: {stats}").into()),
}
}
pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, bucket: &str, quota_bytes: u64) -> TestResult {
wait_until(
Duration::from_secs(30),
|| async {
let (status, _) =
cluster_admin(cluster, Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
Ok(status.is_success() || status == StatusCode::NOT_FOUND)
let stats = quota_stats_on_every_node(cluster, bucket).await?;
Ok(stats
.iter()
.any(|(status, _)| status.is_success() || *status == StatusCode::NOT_FOUND))
},
"quota stats ready",
)
@@ -686,23 +756,44 @@ pub(crate) async fn set_bucket_quota(cluster: &RustFSTestClusterEnvironment, buc
.await
}
/// The existing decommission cases wait up to 180 s for movement to finish; a
/// start refused because a movement gate is busy is retried within the same budget.
const DECOMMISSION_START_BUSY_BUDGET: Duration = Duration::from_secs(180);
const DECOMMISSION_START_PROOF_BUDGET: Duration = Duration::from_secs(45);
/// Start decommission and fail closed unless the admin API acknowledges it.
pub(crate) async fn start_decommission(cluster: &RustFSTestClusterEnvironment, pool_id: usize) -> TestResult {
let path = format!("/rustfs/admin/v3/pools/decommission?pool={pool_id}&by-id=true");
let deadline = Instant::now() + Duration::from_secs(45);
post_decommission_start(cluster, &path, DECOMMISSION_START_BUSY_BUDGET).await?;
Ok(())
}
/// POST a decommission start and return the response body. A start that meets
/// a busy movement gate answers a retryable 503 and is retried within
/// `busy_budget`; the init stage's missing fleet capability proof (500) is
/// retried for 45 s. Every other status is fatal.
pub(crate) async fn post_decommission_start(
cluster: &RustFSTestClusterEnvironment,
path: &str,
busy_budget: Duration,
) -> TestResult<String> {
let started = Instant::now();
let mut delay = Duration::from_millis(250);
loop {
let (status, response) = cluster_admin(cluster, Method::POST, &path, None).await?;
let (status, response) = cluster_admin(cluster, Method::POST, path, None).await?;
if status.is_success() {
return Ok(());
return Ok(response);
}
if status == StatusCode::INTERNAL_SERVER_ERROR
let elapsed = started.elapsed();
let busy = status == StatusCode::SERVICE_UNAVAILABLE && elapsed < busy_budget;
let awaiting_proof = status == StatusCode::INTERNAL_SERVER_ERROR
&& response.contains("requires a live fleet capability proof")
&& Instant::now() < deadline
{
sleep(Duration::from_secs(1)).await;
continue;
&& elapsed < DECOMMISSION_START_PROOF_BUDGET;
if !busy && !awaiting_proof {
return Err(format!("POST {path} did not start decommission: {status} {response}").into());
}
return Err(format!("POST {path} did not start decommission: {status} {response}").into());
sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(2));
}
}
@@ -1259,6 +1350,29 @@ async fn append_single_node_pool_extends_ellipses_volumes() {
assert!(volumes.contains("/drive{0...1}"), "expanded layout must keep drive ellipses: {volumes}");
}
#[tokio::test]
async fn remove_single_node_pools_drops_retired_pools_and_renumbers() {
let mut env =
RustFSTestClusterEnvironment::with_topology(ClusterTopology::per_node_pools(2, vec![vec![0], vec![1], vec![2], vec![3]]))
.await
.expect("four-pool topology");
let kept: Vec<String> = env.nodes[2..].iter().map(|node| node.address.clone()).collect();
env.remove_single_node_pools(&[0, 1]).expect("remove two retired pools");
assert_eq!(env.nodes.len(), 2);
assert_eq!(env.nodes.iter().map(|node| node.address.clone()).collect::<Vec<_>>(), kept);
assert_eq!(env.nodes.iter().map(|node| node.pool_idx).collect::<Vec<_>>(), vec![0, 1]);
let volumes = env.rustfs_volumes_arg();
assert_eq!(volumes.split(' ').count(), 2, "expected two pool arguments, got: {volumes}");
assert!(
volumes.contains(&kept[0]) && volumes.contains(&kept[1]),
"retired pools must leave the command line: {volumes}"
);
env.remove_single_node_pools(&[0, 1])
.expect_err("the last remaining pool cannot be removed");
}
#[tokio::test]
async fn append_single_node_pool_rejects_striped_single_pool() {
let mut env = RustFSTestClusterEnvironment::new(4).await.expect("four-node single pool");
+2
View File
@@ -32,6 +32,8 @@ mod replication_delete_marker_test;
mod replication_quota_test;
mod s3_basic_test;
mod s3_during_data_movement_test;
mod scanner_heal_topology_fixture;
mod scanner_heal_topology_test;
mod site_replication_test;
mod upgrade_test;
mod versioning_test;
@@ -13,13 +13,14 @@
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, put_bucket_replication, put_object, retrying_put, set_bucket_quota,
set_remote_target, unique_bucket, wait_for_ready, wait_for_replicated_bytes, wait_until,
DistCluster, DistLayout, TestResult, cluster_admin_ok, enable_versioning, put_bucket_replication, put_object,
quota_stats_current_usage, quota_stats_on_every_node, retrying_put, set_bucket_quota, set_remote_target, unique_bucket,
wait_for_ready, wait_for_replicated_bytes, wait_until,
};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, init_logging};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, admin_request, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use http::{Method, StatusCode};
use std::time::Duration;
async fn wait_for_replication_status(
@@ -109,12 +110,165 @@ async fn four_node_bucket_replication_converges_to_peer_cluster() -> TestResult
Ok(())
}
#[tokio::test]
async fn four_node_quota_changes_refresh_peers_before_response() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("quota-refresh");
dist.create_bucket(&bucket).await?;
let native_path = format!("/rustfs/admin/v3/quota/{bucket}");
let compat_set = format!("/rustfs/admin/v3/set-bucket-quota?bucket={bucket}");
let compat_get = format!("/rustfs/admin/v3/get-bucket-quota?bucket={bucket}");
// Warm every node's no-quota cache. The compatibility GET does not
// depend on a scanner usage snapshot for this newly created bucket.
for node in &dist.cluster.nodes {
let (status, body) = admin_request(
&node.url,
Method::GET,
&compat_get,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
}
wait_until(
Duration::from_secs(30),
|| async {
let (status, body) = admin_request(
&dist.cluster.nodes[0].url,
Method::PUT,
&native_path,
Some(serde_json::json!({"quota": 8192, "quota_type": "HARD"}).to_string()),
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
if status == StatusCode::SERVICE_UNAVAILABLE {
return Ok(false);
}
assert_eq!(status, StatusCode::OK, "{body}");
Ok(true)
},
"quota fleet capability ready",
)
.await?;
for (method, path, payload, expected) in [
(Method::GET, native_path.as_str(), None, Some(8192_u64)),
(
Method::PUT,
native_path.as_str(),
Some(serde_json::json!({"quota": 4096, "quota_type": "HARD"})),
Some(4096),
),
(Method::DELETE, native_path.as_str(), None, None),
(
Method::PUT,
compat_set.as_str(),
Some(serde_json::json!({"quota": 16384, "quotatype": "hard", "reservation_protocol": 2})),
Some(16384),
),
(
Method::PUT,
compat_set.as_str(),
Some(serde_json::json!({"quota": 0, "quotatype": "hard"})),
None,
),
] {
if method != Method::GET {
cluster_admin_ok(&dist.cluster, method, path, payload.map(|value| value.to_string())).await?;
}
// No polling: a successful mutation waits for healthy peers to reload.
for (index, node) in dist.cluster.nodes.iter().enumerate() {
let (status, body) = admin_request(
&node.url,
Method::GET,
&compat_get,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body)?["quota"],
expected.unwrap_or(0),
"node {index} kept stale quota metadata",
);
if expected.is_some() {
let (status, body) = admin_request(
&node.url,
Method::GET,
&native_path,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "node {index}: {body}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"].as_u64(), expected);
}
}
}
cluster_admin_ok(
&dist.cluster,
Method::PUT,
&native_path,
Some(serde_json::json!({"quota": 8192, "quota_type": "HARD"}).to_string()),
)
.await?;
dist.cluster.stop_node(3)?;
// An unreachable peer cannot turn an already committed clear into a failure.
cluster_admin_ok(&dist.cluster, Method::DELETE, &native_path, None).await?;
for node in dist.cluster.nodes.iter().take(3) {
let (status, body) = admin_request(
&node.url,
Method::GET,
&compat_get,
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(serde_json::from_str::<serde_json::Value>(&body)?["quota"], 0);
}
Ok(())
}
#[tokio::test]
async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult {
init_logging();
let dist = DistCluster::start_with_env(DistLayout::FourByFour, FAST_DATA_USAGE_SCANNER_ENV).await?;
let bucket = unique_bucket("quota");
dist.create_bucket(&bucket).await?;
// A bucket without a quota reports usage only once a snapshot covers it.
// The scanner leader sees its own save at once; every other node holds a
// snapshot cached before the bucket existed and must reload it early
// rather than answer 503 for the rest of the 30 s cache TTL.
wait_until(
Duration::from_secs(30),
|| async {
let stats = quota_stats_on_every_node(&dist.cluster, &bucket).await?;
Ok(stats.iter().any(|(status, _)| status.is_success()))
},
"first node reports quota stats for the new bucket",
)
.await?;
wait_until(
Duration::from_secs(15),
|| async {
let stats = quota_stats_on_every_node(&dist.cluster, &bucket).await?;
Ok(stats.iter().all(|(status, _)| status.is_success()))
},
"every node reports quota stats for the new bucket",
)
.await?;
set_bucket_quota(&dist.cluster, &bucket, 8 * 1024).await?;
let client = dist.client(1)?;
@@ -122,23 +276,12 @@ async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult
wait_until(
Duration::from_secs(30),
|| async {
let (status, body) = super::harness::cluster_admin(
&dist.cluster,
Method::GET,
&format!("/rustfs/admin/v3/quota-stats/{bucket}"),
None,
)
.await?;
if !status.is_success() {
return Ok(false);
for (status, body) in quota_stats_on_every_node(&dist.cluster, &bucket).await? {
if status.is_success() && quota_stats_current_usage(&body)?.is_some_and(|usage| usage >= 1024) {
return Ok(true);
}
}
let stats: serde_json::Value =
serde_json::from_str(&body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
let usage = stats
.get("current_usage")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| format!("quota stats omitted current_usage: {stats}"))?;
Ok(usage >= 1024)
Ok(false)
},
"quota stats observe small object",
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,751 @@
// Copyright 2026 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.
//! `scanner_heal_topology_` protection matrix (backlog#2873, S00).
//!
//! Every case seeds the deterministic inventory from the fixture into one
//! pool, then drives expansion, rebalance or decommission while re-verifying
//! the recorded versions through every node and the physical pool placement
//! from `xl.meta`. Fault cases hold the mover at a proven window through the
//! `e2e-test-hooks` barriers before injecting a kill or a commit failure. The
//! gate mapping is documented in `docs/testing/scanner-heal-topology-gates.md`.
use super::harness::unique_bucket;
use super::harness::{
DECOMMISSION_POOL_ID, TestResult, decommission_status_json, rebalance_complete, rebalance_status_json, start_decommission,
start_rebalance, wait_for_decommission_active, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
wait_for_rebalance_active, wait_for_rebalance_complete,
};
use super::scanner_heal_topology_fixture::{
LiveWorkload, MOVEMENT_ACTIVE_TIMEOUT, MOVEMENT_COMPLETE_TIMEOUT, MovementOperation, SEED_POOL,
STAGE_AFTER_RESPONSIBILITY_PERSISTED, STAGE_BEFORE_SOURCE_CLEANUP, STAGE_BEFORE_TARGET_COMMIT, TopologyManifest, TopologyRun,
assert_bucket_durability_strict, assert_every_node_serves, assert_probe_objects, assert_process_durability_strict,
assert_seed_pool_census_unchanged, assert_single_pool_placement, cancel_decommission, capture_seed_pool_census,
clear_decommission, expect_decommission_rejected, expected_live_state, incarnation_sidecar_census, pool_count, pools_holding,
put_probe_objects, rebalance_stopped, require_test_hooks, run_live_workload, seed_manifest, start_decommission_targets,
stop_rebalance, verify_from_every_node, verify_live_state, verify_manifest, wait_for_decommission_flag,
};
use crate::common::{admin_request, init_logging};
use http::Method;
use serde_json::{Value, json};
use std::collections::BTreeSet;
use std::time::Duration;
use tokio::time::{Instant, sleep};
const ALL_POOLS: [usize; 4] = [0, 1, 2, 3];
const TARGET_POOLS: [usize; 3] = [1, 2, 3];
const LIVE_WRITERS: usize = 6;
const LIVE_ROUNDS: usize = 6;
/// Locally the decommission start reaches its durable save in about a second
/// (POST to response ~0.8 s); 60 s separates a stalled start path from slow I/O.
const PRE_ACK_REACH_TIMEOUT: Duration = Duration::from_secs(60);
/// Cancel and stop are acknowledged before every in-flight entry finishes its
/// cleanup; terminal state and placement are polled within this budget.
const TERMINAL_TRANSITION_TIMEOUT: Duration = Duration::from_secs(90);
fn pools(ids: &[usize]) -> BTreeSet<usize> {
ids.iter().copied().collect()
}
fn seed_pool_sidecars(
census: &std::collections::BTreeMap<String, crate::chaos::VersionShardCensus>,
) -> Vec<(&String, &crate::chaos::VersionShardCensus)> {
census
.iter()
.filter(|(location, _)| location.starts_with(&format!("{SEED_POOL}/")))
.collect()
}
/// Expansion and restart must leave the seed pool's sidecar census unchanged.
fn assert_seed_sidecars_unchanged(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult {
for bucket in manifest.buckets() {
let now = incarnation_sidecar_census(&run.dist, bucket)?;
let before = &manifest.incarnation_sidecars[bucket];
if seed_pool_sidecars(&now) != seed_pool_sidecars(before) {
return Err(format!(
"{label}: bucket incarnation sidecar of {bucket} changed on the seed pool: before {before:?} now {now:?}"
)
.into());
}
}
Ok(())
}
/// After the seed pool is retired every bucket must keep its incarnation
/// sidecar on at least one target pool.
fn assert_sidecars_survive_on_targets(
run: &TopologyRun,
manifest: &TopologyManifest,
targets: &BTreeSet<usize>,
label: &str,
) -> TestResult<Value> {
let mut summary = serde_json::Map::new();
for bucket in manifest.buckets() {
let now = incarnation_sidecar_census(&run.dist, bucket)?;
let on_targets: Vec<&String> = now
.keys()
.filter(|location| {
location
.split('/')
.next()
.and_then(|pool| pool.parse::<usize>().ok())
.is_some_and(|pool| targets.contains(&pool))
})
.collect();
if on_targets.is_empty() {
return Err(
format!("{label}: bucket {bucket} has no incarnation sidecar on target pools {targets:?}: {now:?}").into(),
);
}
summary.insert(bucket.to_string(), json!(on_targets));
}
Ok(Value::Object(summary))
}
fn manifest_summary(manifest: &TopologyManifest) -> Value {
let mut shapes = std::collections::BTreeMap::new();
for entry in &manifest.entries {
*shapes.entry(entry.shape).or_insert(0usize) += 1;
}
json!({
"buckets": manifest.buckets(),
"entries": manifest.entries.len(),
"keys": manifest.entries.iter().map(|entry| format!("{}/{}", entry.bucket, entry.key)).collect::<BTreeSet<_>>().len(),
"shapes": shapes,
"bytes": manifest.entries.iter().map(|entry| entry.length).sum::<u64>(),
})
}
fn timeouts() -> Value {
json!({
"movement_active_secs": MOVEMENT_ACTIVE_TIMEOUT.as_secs(),
"movement_complete_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
"barrier_reach_secs": MOVEMENT_COMPLETE_TIMEOUT.as_secs(),
"terminal_transition_secs": TERMINAL_TRANSITION_TIMEOUT.as_secs(),
})
}
/// Cancel and stop acknowledge before every in-flight entry has finished its
/// cleanup, so placement is polled until it settles within the terminal budget.
async fn wait_for_single_pool_placement(
run: &TopologyRun,
manifest: &TopologyManifest,
allowed: &BTreeSet<usize>,
label: &str,
) -> TestResult<std::collections::BTreeMap<String, usize>> {
let deadline = Instant::now() + TERMINAL_TRANSITION_TIMEOUT;
loop {
match assert_single_pool_placement(&run.dist, manifest, allowed, label) {
Ok(placement) => return Ok(placement),
Err(error) if Instant::now() < deadline => {
tracing::info!("{label}: placement not settled yet: {error}");
sleep(Duration::from_secs(1)).await;
}
Err(error) => return Err(error),
}
}
}
/// A stopped run may leave a committed target beside its retained source.
/// Every version must still be held by at least one pool; returns the
/// versions held by more than one.
fn assert_held_by_some_pool(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult<Vec<String>> {
let mut duplicated = Vec::new();
for entry in &manifest.entries {
let held = pools_holding(&run.dist, &entry.bucket, &entry.key, entry.version_id.as_deref())?;
if held.is_empty() {
return Err(format!(
"{label}: {}/{} version {:?} is held by no pool",
entry.bucket, entry.key, entry.version_id
)
.into());
}
if held.len() > 1 {
duplicated.push(format!("{}/{}@{} {held:?}", entry.bucket, entry.key, entry.listed_version_id()));
}
}
Ok(duplicated)
}
async fn expand_and_verify(run: &mut TopologyRun, manifest: &TopologyManifest) -> TestResult {
run.dist.expand_to_four_pools().await?;
assert_eq!(pool_count(&run.dist.cluster).await?, 4, "expected four pools after expansion");
verify_from_every_node(&run.dist, manifest, "after expansion to four pools").await?;
assert_single_pool_placement(&run.dist, manifest, &pools(&[SEED_POOL]), "before data movement")?;
Ok(())
}
/// G-EXP: 1→2→4 pools with scanner and heal enabled; old versions intact on
/// every node, seed-pool shards and set placement untouched, new writes land
/// in exactly one pool, and the pool map survives a full restart.
#[tokio::test]
async fn scanner_heal_topology_expand_one_to_four_pools_preserves_versions_and_placement() -> TestResult {
init_logging();
let mut run = TopologyRun::start("expand").await?;
let manifest = seed_manifest(&run).await?;
let bucket_durability = assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
let seed_census = capture_seed_pool_census(&run.dist, &manifest)?;
let scratch = unique_bucket("sht-scratch");
run.dist.create_bucket(&scratch).await?;
let mut steps = Vec::new();
for expected_pools in 2..=4 {
let new_node = run.dist.append_pool_and_restart().await?;
if new_node + 1 != expected_pools {
return Err(format!("appended node index {new_node} does not match pool count {expected_pools}").into());
}
let label = format!("after expanding to {expected_pools} pools");
assert_eq!(pool_count(&run.dist.cluster).await?, expected_pools, "{label}: pool list");
verify_from_every_node(&run.dist, &manifest, &label).await?;
assert_seed_pool_census_unchanged(&run.dist, &manifest, &seed_census, &label)?;
assert_seed_sidecars_unchanged(&run, &manifest, &label)?;
steps.push(json!({ "pools": expected_pools, "verified_nodes": run.dist.cluster.nodes.len() }));
}
let probes = put_probe_objects(&run.dist, &scratch, "post-expand", 8).await?;
let probe_placement = assert_probe_objects(&run.dist, &scratch, &probes, &pools(&ALL_POOLS), "post-expand probes").await?;
run.dist.restart_current_binary_gracefully().await?;
assert_eq!(pool_count(&run.dist.cluster).await?, 4, "pool map after restart");
verify_from_every_node(&run.dist, &manifest, "after full restart").await?;
assert_seed_pool_census_unchanged(&run.dist, &manifest, &seed_census, "after full restart")?;
assert_seed_sidecars_unchanged(&run, &manifest, "after full restart")?;
assert_probe_objects(&run.dist, &scratch, &probes, &pools(&ALL_POOLS), "post-expand probes after restart").await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
"g-exp-expand",
&json!({
"gate": "G-EXP",
"manifest": manifest_summary(&manifest),
"bucket_durability": bucket_durability,
"process_durability_strict_nodes": strict_nodes,
"expansion_steps": steps,
"probe_placement": probe_placement,
"timeouts": timeouts(),
}),
)?;
Ok(())
}
/// Wait for the first rebalance run to report either `stopped` (the expected
/// outcome) or `completed` (the inventory moved before the stop arrived).
async fn wait_for_rebalance_stopped_or_completed(run: &TopologyRun, rebalance_id: &str) -> TestResult<(&'static str, Value)> {
let deadline = Instant::now() + TERMINAL_TRANSITION_TIMEOUT;
loop {
let status = rebalance_status_json(&run.dist.cluster).await?;
if rebalance_stopped(&status, rebalance_id)? {
return Ok(("stopped", status));
}
if rebalance_complete(&status, rebalance_id).unwrap_or(false) {
return Ok(("completed_before_stop", status));
}
if Instant::now() >= deadline {
return Err(format!(
"rebalance {rebalance_id} reached neither stopped nor completed within {TERMINAL_TRANSITION_TIMEOUT:?}: {status}"
)
.into());
}
sleep(Duration::from_millis(500)).await;
}
}
/// G-REB: rebalance with concurrent PUT/GET/DELETE and the scanner running;
/// stop mid-run, verify no content error and no lost acknowledged version,
/// restart the cluster, then rebalance to completion.
#[tokio::test]
async fn scanner_heal_topology_rebalance_survives_live_writes_stop_and_restart() -> TestResult {
init_logging();
let mut run = TopologyRun::start("rebalance").await?;
let manifest = seed_manifest(&run).await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
let scratch = unique_bucket("sht-scratch");
run.dist.create_bucket(&scratch).await?;
expand_and_verify(&mut run, &manifest).await?;
let baseline: Vec<(String, String)> = manifest
.plain_entries()
.filter(|entry| matches!(entry.shape, "inline" | "external" | "special"))
.map(|entry| (entry.key.clone(), entry.sha256.clone()))
.collect();
let first_id = start_rebalance(&run.dist.cluster).await?;
wait_for_rebalance_active(&run.dist.cluster, &first_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
let during_rebalance = put_probe_objects(&run.dist, &scratch, "during-rebalance", 4).await?;
let workload = tokio::spawn(run_live_workload(LiveWorkload {
clients: run.dist.clients()?,
bucket: scratch.clone(),
prefix: "live".to_string(),
writers: LIVE_WRITERS,
rounds: LIVE_ROUNDS,
baseline_bucket: manifest.plain_bucket.clone(),
baseline: baseline.clone(),
pause: Duration::from_millis(100),
}));
sleep(Duration::from_secs(2)).await;
stop_rebalance(&run.dist.cluster).await?;
let (first_outcome, first_status) = wait_for_rebalance_stopped_or_completed(&run, &first_id).await?;
let ops = workload.await??;
let live_state = expected_live_state(&ops);
for node_idx in 0..run.dist.cluster.nodes.len() {
let client = run.dist.client(node_idx)?;
verify_manifest(&client, &manifest, &format!("after rebalance {first_outcome} via node {node_idx}")).await?;
verify_live_state(
&client,
&scratch,
&live_state,
&format!("after rebalance {first_outcome} via node {node_idx}"),
)
.await?;
}
let retained_after_stop = assert_held_by_some_pool(&run, &manifest, &format!("after rebalance {first_outcome}"))?;
let during_placement = assert_probe_objects(
&run.dist,
&scratch,
&during_rebalance,
&pools(&TARGET_POOLS),
"writes issued while the seed pool was rebalancing must avoid it",
)
.await?;
run.dist.restart_current_binary_gracefully().await?;
assert_every_node_serves(&run.dist, &scratch, "after-stop-restart").await?;
verify_from_every_node(&run.dist, &manifest, "after restart following the stop").await?;
verify_live_state(&run.dist.client(0)?, &scratch, &live_state, "after restart following the stop").await?;
let second_id = start_rebalance(&run.dist.cluster).await?;
wait_for_rebalance_active(&run.dist.cluster, &second_id, MOVEMENT_ACTIVE_TIMEOUT).await?;
wait_for_rebalance_complete(&run.dist.cluster, &second_id, MOVEMENT_COMPLETE_TIMEOUT).await?;
verify_from_every_node(&run.dist, &manifest, "after the second rebalance completed").await?;
verify_live_state(&run.dist.client(3)?, &scratch, &live_state, "after the second rebalance completed").await?;
let final_placement =
assert_single_pool_placement(&run.dist, &manifest, &pools(&ALL_POOLS), "after the second rebalance completed")?;
let moved = final_placement.values().filter(|pool| **pool != SEED_POOL).count();
if moved == 0 {
return Err("rebalance completed without moving any manifest version off the seed pool".into());
}
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
"g-reb-rebalance",
&json!({
"gate": "G-REB",
"manifest": manifest_summary(&manifest),
"first_run": { "id": first_id, "outcome": first_outcome, "status": first_status, "versions_held_by_two_pools_after_stop": retained_after_stop },
"second_run": { "id": second_id, "versions_off_seed_pool": moved },
"live_ops": ops,
"probe_placement_during_rebalance": during_placement,
"process_durability_strict_nodes": strict_nodes,
"timeouts": timeouts(),
}),
)?;
Ok(())
}
/// G-DEC: cancel an active decommission and keep every version; queue two
/// pools, retire them in order, reject retiring the last active pools, and
/// keep listings, history, markers and locks correct across a restart.
#[tokio::test]
async fn scanner_heal_topology_decommission_queue_cancel_resume_and_last_pool_guard() -> TestResult {
init_logging();
let mut run = TopologyRun::start("decommission").await?;
let manifest = seed_manifest(&run).await?;
assert_bucket_durability_strict(&run.dist.cluster, &manifest.buckets()).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
let scratch = unique_bucket("sht-scratch");
run.dist.create_bucket(&scratch).await?;
expand_and_verify(&mut run, &manifest).await?;
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
wait_for_decommission_running_with_progress(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_ACTIVE_TIMEOUT).await?;
cancel_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
let canceled =
wait_for_decommission_flag(&run.dist.cluster, DECOMMISSION_POOL_ID, "canceled", TERMINAL_TRANSITION_TIMEOUT).await?;
let after_cancel = wait_for_single_pool_placement(&run, &manifest, &pools(&ALL_POOLS), "after cancel").await?;
verify_from_every_node(&run.dist, &manifest, "after cancel").await?;
clear_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
start_decommission_targets(&run.dist.cluster, "0,1").await?;
let queued = wait_for_decommission_flag(&run.dist.cluster, 1, "queued", Duration::from_secs(5)).await?;
wait_for_decommission_active(&run.dist.cluster, 0, MOVEMENT_ACTIVE_TIMEOUT).await?;
let during = put_probe_objects(&run.dist, &scratch, "during-decommission", 4).await?;
wait_for_decommission_complete(&run.dist.cluster, 0, MOVEMENT_COMPLETE_TIMEOUT).await?;
wait_for_decommission_complete(&run.dist.cluster, 1, MOVEMENT_COMPLETE_TIMEOUT).await?;
let (rejected_status, rejected_body) = expect_decommission_rejected(&run.dist.cluster, "2,3").await?;
verify_from_every_node(&run.dist, &manifest, "after retiring pools 0 and 1").await?;
let final_placement = assert_single_pool_placement(&run.dist, &manifest, &pools(&[2, 3]), "after retiring pools 0 and 1")?;
assert_probe_objects(&run.dist, &scratch, &during, &pools(&[2, 3]), "writes issued during decommission").await?;
let sidecars = assert_sidecars_survive_on_targets(&run, &manifest, &pools(&[2, 3]), "after retiring pools 0 and 1")?;
// A decommissioned pool must leave the command line; the server refuses to
// start while it is still listed. Pools 2 and 3 are renumbered to 0 and 1.
run.dist.remove_retired_pools_and_restart(&[0, 1]).await?;
let pools_after_restart = pool_count(&run.dist.cluster).await?;
if pools_after_restart != 2 {
return Err(format!("expected two pools after removing the retired pools, found {pools_after_restart}").into());
}
verify_from_every_node(&run.dist, &manifest, "after restart without the retired pools").await?;
assert_single_pool_placement(&run.dist, &manifest, &pools(&[0, 1]), "after restart without the retired pools")?;
let status_after_restart = decommission_status_json(&run.dist.cluster).await?;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths())?;
run.write_evidence(
"g-dec-decommission",
&json!({
"gate": "G-DEC",
"manifest": manifest_summary(&manifest),
"cancel": canceled,
"placement_after_cancel_pools": after_cancel.values().collect::<BTreeSet<_>>(),
"queued_pool_1": queued,
"final_placement_pools": final_placement.values().collect::<BTreeSet<_>>(),
"sidecars_on_targets": sidecars,
"last_pool_rejection": { "status": rejected_status.as_u16(), "body": rejected_body },
"status_after_restart": status_after_restart,
"process_durability_strict_nodes": strict_nodes,
"timeouts": timeouts(),
}),
)?;
Ok(())
}
/// Outcome of a fault case: the worker result and the data verdict are
/// reported separately so a fail-closed worker and a lost version never hide
/// each other.
async fn settle_decommission(run: &TopologyRun, manifest: &TopologyManifest, label: &str) -> TestResult<Value> {
let completion = wait_for_decommission_complete(&run.dist.cluster, DECOMMISSION_POOL_ID, MOVEMENT_COMPLETE_TIMEOUT).await;
let allowed = if completion.is_ok() {
pools(&TARGET_POOLS)
} else {
pools(&ALL_POOLS)
};
let integrity = async {
verify_from_every_node(&run.dist, manifest, label).await?;
wait_for_single_pool_placement(run, manifest, &allowed, label).await
}
.await;
let status = decommission_status_json(&run.dist.cluster).await.unwrap_or(Value::Null);
settle(label, completion, integrity, status)
}
async fn settle_rebalance(run: &TopologyRun, manifest: &TopologyManifest, rebalance_id: &str, label: &str) -> TestResult<Value> {
let completion = wait_for_rebalance_complete(&run.dist.cluster, rebalance_id, MOVEMENT_COMPLETE_TIMEOUT).await;
let integrity = async {
verify_from_every_node(&run.dist, manifest, label).await?;
wait_for_single_pool_placement(run, manifest, &pools(&ALL_POOLS), label).await
}
.await;
let status = rebalance_status_json(&run.dist.cluster).await.unwrap_or(Value::Null);
settle(label, completion, integrity, status)
}
fn settle(
label: &str,
completion: TestResult,
integrity: TestResult<std::collections::BTreeMap<String, usize>>,
status: Value,
) -> TestResult<Value> {
match (completion, integrity) {
(Ok(()), Ok(placement)) => {
Ok(json!({ "worker": "completed", "placement_pools": placement.values().collect::<BTreeSet<_>>(), "status": status }))
}
(Err(worker), Ok(_)) => {
Err(format!("{label}: data stayed intact but the worker did not complete: {worker}; status {status}").into())
}
(Ok(()), Err(data)) => Err(format!("{label}: worker completed but data diverged: {data}").into()),
(Err(worker), Err(data)) => {
Err(format!("{label}: worker did not complete: {worker}; and data diverged: {data}; status {status}").into())
}
}
}
fn record(run: &TopologyRun, case: &str, outcome: &TestResult<Value>, mut evidence: Value) -> TestResult {
evidence["outcome"] = match outcome {
Ok(detail) => detail.clone(),
Err(error) => json!({ "error": error.to_string() }),
};
run.write_evidence(case, &evidence)?;
Ok(())
}
/// G-DEC fault: SIGKILL the decommission leader while an entry is held after
/// its target commit and before source cleanup; the resumed worker must
/// finish without losing or duplicating the version.
#[tokio::test]
async fn scanner_heal_topology_decommission_kill_before_source_cleanup_resumes_without_loss() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("decom-kill").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
let key = manifest.plain_key("external", 3)?;
run.movement_barrier
.arm_movement("decommission", &manifest.plain_bucket, &key, STAGE_BEFORE_SOURCE_CLEANUP)?;
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
let reached = run
.movement_barrier
.wait_reached_during(
&run.dist.cluster,
MovementOperation::Decommission(DECOMMISSION_POOL_ID),
MOVEMENT_COMPLETE_TIMEOUT,
)
.await?;
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
if !window.contains(&SEED_POOL) || window.len() < 2 {
return Err(format!(
"barrier window did not show a committed target beside the retained source for {key}: pools {window:?}"
)
.into());
}
let killed_pid = run.kill_node(SEED_POOL)?;
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the killed leader resumed decommission";
let outcome = settle_decommission(&run, &manifest, label).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-dec-kill-before-source-cleanup",
&outcome,
json!({
"gate": "G-DEC",
"window": STAGE_BEFORE_SOURCE_CLEANUP,
"nonce": run.movement_barrier.nonce(),
"object": key,
"reached": reached,
"pools_holding_at_window": window,
"killed_pid": killed_pid,
"restarted_pid": restarted_pid,
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
/// G-DEC fault: inject a target commit failure after the multipart copy is
/// staged; the source must stay authoritative and a later attempt completes.
#[tokio::test]
async fn scanner_heal_topology_decommission_target_commit_failure_retains_source() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("decom-target-fail").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
let key = manifest.plain_key("multipart", 0)?;
run.movement_barrier
.arm_movement("decommission", &manifest.plain_bucket, &key, STAGE_BEFORE_TARGET_COMMIT)?;
start_decommission(&run.dist.cluster, DECOMMISSION_POOL_ID).await?;
let reached = run
.movement_barrier
.wait_reached_during(
&run.dist.cluster,
MovementOperation::Decommission(DECOMMISSION_POOL_ID),
MOVEMENT_COMPLETE_TIMEOUT,
)
.await?;
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
if window != pools(&[SEED_POOL]) {
return Err(format!("the target must not be published before its commit; {key} is held by {window:?}").into());
}
run.movement_barrier.disarm()?;
run.movement_barrier.release_movement_with_io_error()?;
let label = "after the failed target commit was retried";
let outcome = settle_decommission(&run, &manifest, label).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-dec-target-commit-failure",
&outcome,
json!({
"gate": "G-DEC",
"window": STAGE_BEFORE_TARGET_COMMIT,
"nonce": run.movement_barrier.nonce(),
"object": key,
"reached": reached,
"injection": "io_error",
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
/// G-DEC fault: kill the leader after the decommission start transaction is
/// durable in `pool.bin` but before the admin request is acknowledged and
/// before any worker starts; the persisted responsibility must resume on restart.
#[tokio::test]
async fn scanner_heal_topology_decommission_kill_after_responsibility_persisted_before_ack_resumes() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("decom-ack").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
run.movement_barrier
.arm_movement("decommission_start", ".rustfs.sys", "pool.bin", STAGE_AFTER_RESPONSIBILITY_PERSISTED)?;
let leader_url = run.dist.cluster.nodes[SEED_POOL].url.clone();
let access_key = run.dist.cluster.access_key.clone();
let secret_key = run.dist.cluster.secret_key.clone();
let start_request = tokio::spawn(async move {
// Mirror start_decommission: the init stage answers 500 with the fleet
// proof marker until the fleet has proven its capability.
let path = format!("/rustfs/admin/v3/pools/decommission?pool={DECOMMISSION_POOL_ID}&by-id=true");
// A busy movement gate answers a retryable 503; keep retrying within
// the window budget so a refused start is never read as a reached window.
let deadline = Instant::now() + PRE_ACK_REACH_TIMEOUT;
loop {
match admin_request(&leader_url, Method::POST, &path, None, &access_key, &secret_key).await {
Ok((status, body))
if (status.as_u16() == 503
|| (status.as_u16() == 500 && body.contains("requires a live fleet capability proof")))
&& Instant::now() < deadline =>
{
sleep(Duration::from_secs(1)).await;
}
Ok((status, body)) => return Ok(format!("{status} {body}")),
Err(error) => return Err(error.to_string()),
}
}
});
// The start request normally reaches the durable save within about a
// second. If it returns first the window was never entered; if it stays in
// flight past the budget, the start path itself stalled before the save.
let requested_at = Instant::now();
let mut start_request = start_request;
let reached = tokio::select! {
reached = run.movement_barrier.wait_reached(PRE_ACK_REACH_TIMEOUT) => match reached {
Ok(reached) => reached,
Err(error) => {
return Err(format!(
"the decommission start stayed in flight for {PRE_ACK_REACH_TIMEOUT:?} without reaching the durable start save: {error}"
)
.into());
}
},
finished = &mut start_request => {
return Err(format!("the decommission start returned before the pre-ACK window was reached: {:?}", finished?).into());
}
};
let reach_elapsed = requested_at.elapsed();
let killed_pid = run.kill_node(SEED_POOL)?;
let start_outcome = start_request.await?;
if let Ok(response) = &start_outcome {
return Err(format!(
"the decommission start was acknowledged ({response}) although the leader was killed in the pre-ACK window"
)
.into());
}
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the persisted decommission resumed";
let resumed = wait_for_decommission_active(&run.dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(60)).await;
let outcome = match resumed {
Ok(()) => settle_decommission(&run, &manifest, label).await,
Err(error) => Err(format!("{label}: the persisted decommission did not become active after the restart: {error}").into()),
};
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-dec-kill-before-ack",
&outcome,
json!({
"gate": "G-DEC",
"window": STAGE_AFTER_RESPONSIBILITY_PERSISTED,
"nonce": run.movement_barrier.nonce(),
"reached": reached,
"reach_elapsed_ms": reach_elapsed.as_millis() as u64,
"start_request": { "outcome": "uncertain", "error": start_outcome.err() },
"killed_pid": killed_pid,
"restarted_pid": restarted_pid,
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
/// G-REB fault: SIGKILL the rebalancing node while an entry is held after its
/// target commit and before source cleanup; the resumed run must complete
/// without losing or duplicating the version.
#[tokio::test]
async fn scanner_heal_topology_rebalance_kill_before_source_cleanup_resumes_without_loss() -> TestResult {
init_logging();
require_test_hooks();
let mut run = TopologyRun::start("rebal-kill").await?;
let manifest = seed_manifest(&run).await?;
verify_from_every_node(&run.dist, &manifest, "seeded").await?;
expand_and_verify(&mut run, &manifest).await?;
let key = manifest.plain_key("empty", 0)?;
run.movement_barrier
.arm_movement("rebalance", &manifest.plain_bucket, &key, STAGE_BEFORE_SOURCE_CLEANUP)?;
let rebalance_id = start_rebalance(&run.dist.cluster).await?;
let reached = run
.movement_barrier
.wait_reached_during(&run.dist.cluster, MovementOperation::Rebalance(&rebalance_id), MOVEMENT_COMPLETE_TIMEOUT)
.await?;
let window = pools_holding(&run.dist, &manifest.plain_bucket, &key, None)?;
if !window.contains(&SEED_POOL) || window.len() < 2 {
return Err(format!(
"barrier window did not show a committed target beside the retained source for {key}: pools {window:?}"
)
.into());
}
let killed_pid = run.kill_node(SEED_POOL)?;
run.movement_barrier.disarm()?;
let restarted_pid = run.restart_killed_node(SEED_POOL).await?;
let label = "after the killed node resumed rebalance";
let outcome = settle_rebalance(&run, &manifest, &rebalance_id, label).await;
let strict_nodes = assert_process_durability_strict(&run.node_log_paths());
record(
&run,
"g-reb-kill-before-source-cleanup",
&outcome,
json!({
"gate": "G-REB",
"window": STAGE_BEFORE_SOURCE_CLEANUP,
"nonce": run.movement_barrier.nonce(),
"object": key,
"reached": reached,
"pools_holding_at_window": window,
"rebalance_id": rebalance_id,
"killed_pid": killed_pid,
"restarted_pid": restarted_pid,
"process_durability_strict_nodes": strict_nodes.as_ref().ok(),
"timeouts": timeouts(),
}),
)?;
outcome?;
strict_nodes?;
Ok(())
}
@@ -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,
+7
View File
@@ -144,6 +144,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;
@@ -191,6 +195,9 @@ mod conditional_delete_objects_test;
#[cfg(test)]
mod delete_authorization_test;
#[cfg(test)]
mod table_location_authorization_test;
// Regression test for signed DELETE Object?versionId requests without Content-Length.
#[cfg(test)]
mod delete_object_no_content_length_test;
+376
View File
@@ -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.
//! Harbor / Docker Distribution multipart staging to CopyObject regressions.
//! Multipart completion validation, staging preservation, and CopyObject readiness.
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
@@ -39,6 +39,132 @@ fn list_contains_key(output: &aws_sdk_s3::operation::list_objects_v2::ListObject
.any(|object| object.key().is_some_and(|candidate| candidate == key))
}
#[tokio::test]
async fn multipart_complete_invalid_etag_returns_400_and_preserves_retry() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "multipart-invalid-etag";
let key = "object";
env.create_test_bucket(bucket).await?;
let original = b"original object";
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(original))
.send()
.await?;
let upload = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload id")?;
let payload = b"completed multipart payload";
let mut uploaded_etag = None;
// The first case reproduces #8771's Create -> Complete sequence without UploadPart.
for (upload_first, part_number, etag) in [
(false, 1, Some("unknown-etag")),
(true, 1, Some("unknown-etag")),
(true, 1, Some("\"unknown-etag\"")),
(true, 1, Some("00000000000000000000000000000000")),
(true, 1, None),
(true, 2, Some("unknown-etag")),
] {
if upload_first && uploaded_etag.is_none() {
let part = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(payload))
.send()
.await?;
uploaded_etag = Some(part.e_tag().ok_or("missing part ETag")?.to_owned());
}
let error = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(part_number)
.set_e_tag(etag.map(str::to_owned))
.build(),
)
.build(),
)
.send()
.await
.expect_err("missing or mismatched parts must not complete the upload");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400), "{error:?}");
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidPart"),
"{error:?}"
);
let staged = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.send()
.await?;
assert_eq!(staged.parts().len(), usize::from(uploaded_etag.is_some()));
if let Some(etag) = uploaded_etag.as_deref() {
assert_eq!(staged.parts()[0].part_number(), Some(1));
assert_eq!(staged.parts()[0].e_tag(), Some(etag));
}
let current = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(current.as_ref(), original, "rejected completion must preserve the original object");
}
let etag = uploaded_etag.ok_or("part was not uploaded")?;
// Accept the unquoted value, then replay with the quoted UploadPart response.
for etag in [etag.trim_matches('"'), etag.as_str()] {
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(CompletedPart::builder().part_number(1).e_tag(etag).build())
.build(),
)
.send()
.await?;
}
let current = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(current.as_ref(), payload, "a valid retry must publish the exact uploaded bytes");
client.delete_object().bucket(bucket).key(key).send().await?;
env.delete_test_bucket(bucket).await?;
env.stop_server();
Ok(())
}
async fn upload_one_part_mpu(
client: &aws_sdk_s3::Client,
bucket: &str,
@@ -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>,
@@ -541,6 +555,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>,
@@ -940,6 +961,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn peer_drive_admission(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::PeerDriveAdmissionRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::PeerDriveAdmissionResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn get_metacache_listing(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::GetMetacacheListingRequest>,
@@ -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,
@@ -10100,10 +10085,9 @@ async fn build_scanner_compensation_pair(
/// P1-20: objects that already exist when a rule with
/// ExistingObjectReplication=Enabled arrives are compensated by the scanner's
/// existing-object resync pass, whatever wrote them — plain PUT, CopyObject,
/// or Snowball auto-extract. The pinned exception is a null-version object
/// (written before the bucket became versioned): the scanner heal gate skips
/// nil-version objects entirely (`scanner_folder.rs` heal_replication), so it
/// must NEVER be compensated.
/// or Snowball auto-extract. That includes a null-version object written
/// before the bucket became versioned (rustfs/rustfs#8747): it is compensated
/// like any other existing object and lands as the target's null version.
#[tokio::test]
async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestResult {
init_logging();
@@ -10113,14 +10097,54 @@ async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestR
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
// Null-version cell: PUT before versioning; the object keeps the nil
// version id forever.
// Null-version cell: PUT before versioning; the object keeps the null
// version forever.
let null_key = "pre-versioning-null.txt";
let null_payload = "null version payload";
source_client
.put_object()
.bucket(source_bucket)
.key(null_key)
.body(ByteStream::from_static(b"null version payload"))
.body(ByteStream::from_static(null_payload.as_bytes()))
.send()
.await?;
// Multipart null-version cell: a one-part upload already carries a
// multipart ETag, so its replica travels the target's multipart path,
// whose completion must keep the null version too.
let multipart_null_key = "pre-versioning-null-multipart.txt";
let multipart_null_payload = "multipart null version payload";
let upload = source_client
.create_multipart_upload()
.bucket(source_bucket)
.key(multipart_null_key)
.send()
.await?;
let upload_id = upload.upload_id().ok_or("multipart upload id missing")?;
let part = source_client
.upload_part()
.bucket(source_bucket)
.key(multipart_null_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(multipart_null_payload.as_bytes()))
.send()
.await?;
source_client
.complete_multipart_upload()
.bucket(source_bucket)
.key(multipart_null_key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.set_e_tag(part.e_tag().map(str::to_string))
.build(),
)
.build(),
)
.send()
.await?;
@@ -10208,9 +10232,22 @@ async fn test_scanner_compensates_existing_objects_across_write_paths() -> TestR
wait_for_replicated_object(&target_client, target_bucket, copy_key, plain_payload).await?;
wait_for_replicated_object(&target_client, target_bucket, member_key, std::str::from_utf8(member_payload)?).await?;
// Null-version contract: with every sibling compensated (scanner proven
// live), the nil-version object must stay absent across further cycles.
assert_replication_key_absent(&target_client, target_bucket, null_key, Duration::from_secs(6)).await?;
// The null versions converge too, and keep their identity whichever path
// carried them: each replica is the target's null version, not a freshly
// minted one.
for (key, payload) in [(null_key, null_payload), (multipart_null_key, multipart_null_payload)] {
wait_for_replicated_object(&target_client, target_bucket, key, payload).await?;
let null_replica = target_client
.get_object()
.bucket(target_bucket)
.key(key)
.version_id("null")
.send()
.await?;
assert_eq!(null_replica.version_id(), Some("null"), "{key} must replicate as the null version");
let null_replica_body = null_replica.body.collect().await?.into_bytes();
assert_eq!(null_replica_body.as_ref(), payload.as_bytes());
}
Ok(())
}
@@ -303,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);
@@ -324,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?;
@@ -352,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"
@@ -421,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(())
}
@@ -245,11 +245,11 @@ async fn test_concurrent_object_operations() -> Result<(), Box<dyn Error + Send
Ok(())
}
/// Internal/private endpoints must be rejected as remote tier backends (SSRF)
/// Loopback and metadata endpoints must be rejected as remote tier backends (SSRF)
/// before RustFS opens a connection to them.
///
/// This issues a real admin AddTier call (`PUT /rustfs/admin/v3/tier`) for each
/// internal/private endpoint and requires the exact validation response. A
/// restricted endpoint and requires the exact validation response. A
/// loopback listener additionally proves the literal loopback case is rejected
/// without an outbound connection; a generic connectivity failure is not
/// sufficient evidence of SSRF protection.
@@ -267,6 +267,9 @@ async fn test_tiering_url_validation() -> Result<(), Box<dyn Error + Send + Sync
("http://localhost:8080", false),
("http://169.254.169.254", false), // cloud instance metadata endpoint
("http://[::1]:8080", false),
("http://[fd00:ec2::254]", false),
("http://[fd00:ec2::23]", false),
("http://[fd20:ce::254]", false),
];
for (endpoint, checks_connection_attempt) in internal_endpoints {
@@ -0,0 +1,417 @@
// Copyright 2026 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.
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
init_logging, signed_request,
};
use aws_sdk_s3::primitives::ByteStream;
use http::{Method, StatusCode};
use serde_json::{Value, json};
use uuid::Uuid;
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
async fn set_policy(env: &RustFSTestEnvironment, user: &str, policy: Value) -> TestResult {
admin_add_canned_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
user,
&policy.to_string(),
)
.await?;
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, user, user).await
}
async fn catalog_request(
env: &RustFSTestEnvironment,
credentials: &(String, String),
method: Method,
path: &str,
body: Option<Value>,
expected: StatusCode,
) -> TestResult<Value> {
let response = signed_request(
method.clone(),
&format!("{}{path}", env.url),
&credentials.0,
&credentials.1,
body.map(|value| serde_json::to_vec(&value)).transpose()?,
Some("application/json"),
)
.await?;
let status = response.status();
let text = response.text().await?;
assert_eq!(status, expected, "{method} {path}: {text}");
if text.is_empty() || !status.is_success() {
Ok(Value::Null)
} else {
Ok(serde_json::from_str(&text)?)
}
}
async fn get_object(
env: &RustFSTestEnvironment,
credentials: &(String, String),
bucket: &str,
key: &str,
expected: StatusCode,
) -> TestResult {
let response = signed_request(
Method::GET,
&format!("{}/{bucket}/{key}", env.url),
&credentials.0,
&credentials.1,
None,
None,
)
.await?;
assert_eq!(response.status(), expected, "GET {bucket}/{key}");
if expected == StatusCode::OK {
assert_eq!(response.bytes().await?.as_ref(), b"original object");
}
Ok(())
}
fn create_table(name: &str) -> Value {
json!({
"name": name,
"schema": {
"type": "struct",
"fields": [{"id": 1, "name": "id", "required": false, "type": "long"}]
},
"stage-create": false
})
}
fn staged_commit(metadata: &Value, location: &str) -> Value {
json!({
"requirements": [{"type": "assert-create"}],
"updates": [
{"action": "assign-uuid", "uuid": metadata["table-uuid"]},
{"action": "upgrade-format-version", "format-version": metadata["format-version"]},
{"action": "add-schema", "schema": metadata["schemas"][0]},
{"action": "set-current-schema", "schema-id": -1},
{"action": "add-spec", "spec": metadata["partition-specs"][0]},
{"action": "set-default-spec", "spec-id": -1},
{"action": "add-sort-order", "sort-order": metadata["sort-orders"][0]},
{"action": "set-default-sort-order", "sort-order-id": -1},
{"action": "set-location", "location": location}
]
})
}
async fn location_claim_preserves_other_users_objects_after_drop_and_restart(backing: &str) -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
let server_env = [("RUSTFS_TABLE_CATALOG_BACKING", backing)];
env.start_rustfs_server_without_cleanup_with_env(&[("RUSTFS_TABLE_CATALOG_BACKING", "object")])
.await?;
env.wait_for_server_ready().await?;
let root = (env.access_key.clone(), env.secret_key.clone());
let creator = (format!("creator-{}", Uuid::new_v4().simple()), Uuid::new_v4().simple().to_string());
let reader = (format!("reader-{}", Uuid::new_v4().simple()), Uuid::new_v4().simple().to_string());
let bucket = "table-location-authorization";
let root_s3 = env.create_s3_client();
root_s3.create_bucket().bucket(bucket).send().await?;
for key in ["shared/known.txt", "shared2/known.txt", "authorized/known.txt"] {
root_s3
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b"original object"))
.send()
.await?;
}
let catalog = format!("/iceberg/v1/{bucket}/namespaces/lab/tables");
catalog_request(&env, &root, Method::PUT, &format!("/iceberg/v1/buckets/{bucket}"), None, StatusCode::OK).await?;
catalog_request(
&env,
&root,
Method::POST,
&format!("/iceberg/v1/{bucket}/namespaces"),
Some(json!({"namespace": ["lab"]})),
StatusCode::OK,
)
.await?;
if backing == "durable-strong" {
catalog_request(
&env,
&root,
Method::POST,
&format!("/iceberg/v1/{bucket}/catalog/warehouse-index/backfill"),
None,
StatusCode::NO_CONTENT,
)
.await?;
let preflight = catalog_request(
&env,
&root,
Method::GET,
&format!("/iceberg/v1/{bucket}/catalog/migration"),
None,
StatusCode::OK,
)
.await?;
assert_eq!(preflight["blockers"], json!([]), "{preflight}");
let migration = catalog_request(
&env,
&root,
Method::POST,
&format!("/iceberg/v1/{bucket}/catalog/migration"),
None,
StatusCode::OK,
)
.await?;
assert_eq!(migration["ready_to_enable_durable_strong"], json!(true), "{migration}");
env.restart_server_preserving_data(vec![], &server_env).await?;
env.wait_for_server_ready().await?;
}
admin_create_user(&env, &creator.0, &creator.1).await?;
admin_create_user(&env, &reader.0, &reader.1).await?;
let create_statement = json!({
"Effect": "Allow",
"Action": "admin:CreateTable",
"Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab")
});
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [create_statement.clone()]}),
)
.await?;
set_policy(
&env,
&reader.0,
json!({"Version": "2012-10-17", "Statement": [{
"Effect": "Allow", "Action": "s3:GetObject", "Resource": format!("arn:aws:s3:::{bucket}/*")
}]}),
)
.await?;
for key in ["shared/known.txt", "shared2/known.txt"] {
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
get_object(&env, &creator, bucket, key, StatusCode::FORBIDDEN).await?;
}
for (method, path) in [
(Method::PUT, format!("/{bucket}/shared/new.txt")),
(Method::GET, format!("/{bucket}?list-type=2")),
] {
let response = signed_request(method, &format!("{}{path}", env.url), &creator.0, &creator.1, None, None).await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
// Both direct and staged requests must reject a caller-selected foreign prefix.
for staged in [false, true] {
for location in [format!("s3://{bucket}/shared"), format!("s3://{bucket}/shared/")] {
let mut request = create_table("claim");
request["location"] = json!(location);
request["stage-create"] = json!(staged);
catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::FORBIDDEN).await?;
}
}
catalog_request(&env, &root, Method::GET, &format!("{catalog}/claim"), None, StatusCode::NOT_FOUND).await?;
// Server-assigned locations still need only CreateTable, and retain protection after drop.
let created = catalog_request(&env, &creator, Method::POST, &catalog, Some(create_table("managed")), StatusCode::OK).await?;
let location = created["metadata"]["location"].as_str().ok_or("missing table location")?;
let managed_key = format!(
"{}/known.txt",
location
.strip_prefix(&format!("s3://{bucket}/"))
.ok_or("table location has wrong bucket")?
);
root_s3
.put_object()
.bucket(bucket)
.key(&managed_key)
.body(ByteStream::from_static(b"original object"))
.send()
.await?;
get_object(&env, &reader, bucket, &managed_key, StatusCode::FORBIDDEN).await?;
catalog_request(&env, &root, Method::DELETE, &format!("{catalog}/managed"), None, StatusCode::NO_CONTENT).await?;
env.restart_server_preserving_data(vec![], &server_env).await?;
env.wait_for_server_ready().await?;
for table in ["claim", "managed"] {
catalog_request(&env, &root, Method::GET, &format!("{catalog}/{table}"), None, StatusCode::NOT_FOUND).await?;
}
for key in ["shared/known.txt", "shared2/known.txt", "authorized/known.txt"] {
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
}
get_object(&env, &reader, bucket, &managed_key, StatusCode::FORBIDDEN).await?;
get_object(&env, &root, bucket, &managed_key, StatusCode::OK).await?;
// Explicit adoption is scoped to a physical prefix and does not grant S3 access.
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [
create_statement.clone(),
{"Effect": "Allow", "Action": "admin:ClaimTableLocation", "Resource": format!("arn:aws:s3:::{bucket}/authorized")}
]}),
)
.await?;
let mut request = create_table("authorized");
request["location"] = json!(format!("s3://{bucket}/shared"));
catalog_request(&env, &creator, Method::POST, &catalog, Some(request.clone()), StatusCode::FORBIDDEN).await?;
request["location"] = json!(format!("s3://{bucket}/authorized"));
let adopted = catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::OK).await?;
get_object(&env, &creator, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
get_object(&env, &reader, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
// Existing updates retain their permissions; relocation and re-registration
// require a prefix grant even when the caller can access every S3 object.
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [
create_statement,
{"Effect": "Allow", "Action": "admin:RegisterTable", "Resource": [
format!("arn:aws:s3:::{bucket}/namespaces/lab"),
format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/*")
]},
{"Effect": "Allow", "Action": "admin:CommitTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/*")},
{"Effect": "Allow", "Action": "admin:SetTableMetadataLocation", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/authorized")},
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": format!("arn:aws:s3:::{bucket}/*")}
]}),
)
.await?;
let requirements = json!([{"type": "assert-table-uuid", "uuid": adopted["metadata"]["table-uuid"]}]);
let committed = catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized"),
Some(json!({
"requirements": requirements,
"updates": [{"action": "set-properties", "updates": {"test.location-authorization": "retained"}}]
})),
StatusCode::OK,
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized"),
Some(json!({
"requirements": requirements,
"updates": [{"action": "set-location", "location": format!("s3://{bucket}/shared")}]
})),
StatusCode::FORBIDDEN,
)
.await?;
let current = catalog_request(&env, &root, Method::GET, &format!("{catalog}/authorized"), None, StatusCode::OK).await?;
assert_eq!(current["metadata"]["location"], json!(format!("s3://{bucket}/authorized")));
assert_eq!(current["metadata-location"], committed["metadata-location"]);
catalog_request(
&env,
&root,
Method::DELETE,
&format!("{catalog}/authorized"),
None,
StatusCode::NO_CONTENT,
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("/iceberg/v1/{bucket}/namespaces/lab/register"),
Some(json!({"name": "authorized", "metadata-location": committed["metadata-location"]})),
StatusCode::FORBIDDEN,
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized/catalog/import"),
Some(json!({"metadata-location": committed["metadata-location"]})),
StatusCode::FORBIDDEN,
)
.await?;
if backing == "object" {
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/authorized/catalog/external/sync"),
Some(json!({
"catalog": "polaris",
"external-namespace": "lab",
"external-table": "authorized",
"metadata-location": committed["metadata-location"]
})),
StatusCode::FORBIDDEN,
)
.await?;
}
// Staged metadata is client-controlled and must be authorized at publication.
let mut request = create_table("staged");
request["stage-create"] = json!(true);
let staged = catalog_request(&env, &creator, Method::POST, &catalog, Some(request), StatusCode::OK).await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/staged"),
Some(staged_commit(&staged["metadata"], &format!("s3://{bucket}/shared"))),
StatusCode::FORBIDDEN,
)
.await?;
catalog_request(&env, &root, Method::GET, &format!("{catalog}/staged"), None, StatusCode::NOT_FOUND).await?;
set_policy(
&env,
&creator.0,
json!({"Version": "2012-10-17", "Statement": [
{"Effect": "Allow", "Action": "admin:CreateTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab")},
{"Effect": "Allow", "Action": "admin:CommitTable", "Resource": format!("arn:aws:s3:::{bucket}/namespaces/lab/tables/staged")},
{"Effect": "Allow", "Action": "admin:ClaimTableLocation", "Resource": format!("arn:aws:s3:::{bucket}/stage-owned")},
{"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject"], "Resource": format!("arn:aws:s3:::{bucket}/*")}
]}),
)
.await?;
catalog_request(
&env,
&creator,
Method::POST,
&format!("{catalog}/staged"),
Some(staged_commit(&staged["metadata"], &format!("s3://{bucket}/stage-owned"))),
StatusCode::OK,
)
.await?;
catalog_request(&env, &root, Method::DELETE, &format!("{catalog}/staged"), None, StatusCode::NO_CONTENT).await?;
env.restart_server_preserving_data(vec![], &server_env).await?;
env.wait_for_server_ready().await?;
for table in ["authorized", "staged"] {
catalog_request(&env, &root, Method::GET, &format!("{catalog}/{table}"), None, StatusCode::NOT_FOUND).await?;
}
get_object(&env, &reader, bucket, "authorized/known.txt", StatusCode::FORBIDDEN).await?;
get_object(&env, &root, bucket, "authorized/known.txt", StatusCode::OK).await?;
for key in ["shared/known.txt", "shared2/known.txt"] {
get_object(&env, &reader, bucket, key, StatusCode::OK).await?;
}
Ok(())
}
#[tokio::test]
async fn object_catalog_location_claim_preserves_objects_after_drop_and_restart() -> TestResult {
location_claim_preserves_other_users_objects_after_drop_and_restart("object").await
}
#[tokio::test]
async fn durable_strong_catalog_location_claim_preserves_objects_after_drop_and_restart() -> TestResult {
location_claim_preserves_other_users_objects_after_drop_and_restart("durable-strong").await
}
+588 -84
View File
@@ -25,11 +25,11 @@ use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, DefaultRetention,
ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, ObjectAttributes, ObjectLockConfiguration,
ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, PublicAccessBlockConfiguration, ServerSideEncryption,
ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule, StorageClass, Tag, Tagging,
VersioningConfiguration,
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, DefaultRetention, Delete,
ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, ObjectAttributes, ObjectIdentifier,
ObjectLockConfiguration, ObjectLockEnabled, ObjectLockRetentionMode, ObjectLockRule, PublicAccessBlockConfiguration,
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
StorageClass, Tag, Tagging, VersioningConfiguration,
};
use http::{Method, StatusCode};
use serde_json::Value;
@@ -46,6 +46,7 @@ type BoxError = Box<dyn std::error::Error + Send + Sync>;
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
const G09_EVIDENCE_DIR_ENV: &str = "RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR";
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
const STABLE_1_0_1_COMMIT: &str = "6de965ae3c965a78ff819fbcd7acd4aa44177d92";
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
const PLAIN_BUCKET: &str = "upgrade-plain-data";
@@ -653,6 +654,26 @@ async fn exercise_mixed_cluster(
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
);
// Every online participant must understand the delete decision protocol.
// An upgrade phase can serve reads/writes while intentionally refusing a
// modern DELETE; rejecting it must leave the old writer's seed intact.
let error = upgrade_probe_client(current_client)
.delete_object()
.bucket(MIXED_BUCKET)
.key(PREVIOUS_RELEASE_SEED_KEY)
.send()
.await
.expect_err("a modern DELETE must fail closed while an old participant is online");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503), "{error:?}");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("ServiceUnavailable"));
for (node, client) in clients.iter().enumerate() {
assert_eq!(
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
UPGRADE_READINESS_BODY,
"{phase}: rejected DELETE must preserve the seed through node {node}"
);
}
Ok(())
}
@@ -756,6 +777,18 @@ async fn wait_for_upgrade_writes(
Ok(())
}
fn previous_baseline_writers(clients: &[Client], restarted_node: usize) -> &[Client] {
// Node 0 has persisted the seed. With restart order [1, 2, 3, 0], only
// this prefix has also cleared rc.5's startup write fence. Every RRS
// probe still needs all drives, including those of not-yet-restarted peers.
let ready_end = if restarted_node == 0 {
clients.len()
} else {
restarted_node + 1
};
&clients[..ready_end]
}
async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnvironment, previous_binary: &Path) -> TestResult {
let clients: Vec<_> = cluster.create_all_clients()?.iter().map(upgrade_probe_client).collect();
// rc.5 can latch a non-elected node's write fence while its first startup
@@ -766,8 +799,9 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
// A node that just came back is still replaying its startup traffic against
// every peer. Killing the next node during that burst gives the fresh node
// two failures in a row and it drops that peer's drive to Offline, where
// only the slow recovery probe can bring it back. Let every node re-admit
// every drive before the next kill and before any current binary starts.
// only the slow recovery probe can bring it back. Let every writable node
// re-admit every drive before the next kill. Nodes still awaiting their
// restart may retain the startup write fence and cannot coordinate a PUT.
for node in [1, 2, 3, 0] {
cluster.stop_node(node)?;
cluster.start_node_from_binary(node, previous_binary).await?;
@@ -779,7 +813,13 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
LISTING_CONVERGENCE_TIMEOUT,
)
.await?;
wait_for_drive_readmission(&clients, MIXED_BUCKET, &context, DRIVE_READMISSION_TIMEOUT).await?;
wait_for_drive_readmission(
previous_baseline_writers(&clients, node),
MIXED_BUCKET,
&context,
DRIVE_READMISSION_TIMEOUT,
)
.await?;
}
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
@@ -851,57 +891,193 @@ async fn parity_zero_write_needs_every_drive() -> TestResult {
mod upgrade_write_readiness_tests {
use super::*;
use crate::fake_s3_target::FaultAction;
use aws_sdk_s3::config::interceptors::BeforeTransmitInterceptorContextRef;
use aws_sdk_s3::config::{ConfigBag, Intercept, RuntimeComponents};
#[derive(Debug)]
struct RequireAllDrivesProbe;
impl Intercept for RequireAllDrivesProbe {
fn name(&self) -> &'static str {
"require-all-drives-probe"
}
fn read_before_transmit(
&self,
context: &BeforeTransmitInterceptorContextRef<'_>,
_runtime_components: &RuntimeComponents,
_cfg: &mut ConfigBag,
) -> Result<(), BoxError> {
assert_eq!(context.request().method(), "PUT");
assert_eq!(
context.request().headers().get("x-amz-storage-class"),
Some("REDUCED_REDUNDANCY"),
"every admitted coordinator must use the all-drive storage class"
);
Ok(())
}
}
#[tokio::test]
async fn waits_for_each_writer_after_metadata_is_ready() -> TestResult {
let target = FakeS3Target::start().await?;
let bucket = "distributed-upgrade-history";
target.create_bucket(bucket);
let client = fake_source_client(&target);
client.head_bucket().bucket(bucket).send().await?;
async fn previous_baseline_probes_only_writable_coordinators_until_all_restart() -> TestResult {
let mut targets = Vec::new();
let mut clients = Vec::new();
for _ in 0..MIXED_NODE_COUNT {
let target = FakeS3Target::start().await?;
target.create_bucket(MIXED_BUCKET);
clients.push(Client::from_conf(
fake_source_client(&target)
.config()
.to_builder()
.interceptor(RequireAllDrivesProbe)
.build(),
));
targets.push(target);
}
for target in &targets[2..] {
target.inject(FakeTargetOperation::PutObject, FaultAction::Status(StatusCode::INTERNAL_SERVER_ERROR), 10);
}
// Reproduce the original all-coordinator gate: node 0 and restarted
// node 1 can write, but unrestarted node 2 still returns a latched 500.
let error = wait_for_drive_readmission(&clients, MIXED_BUCKET, "premature", Duration::from_secs(5))
.await
.expect_err("an unrestarted coordinator must retain its startup write failure");
assert!(error.to_string().contains("node 2 drive re-admission failed"), "{error}");
let failed = targets[2].requests();
assert_eq!(failed.len(), 1, "the startup 500 must fail without retrying");
assert_eq!(failed[0].fault, Some(FaultAction::Status(StatusCode::INTERNAL_SERVER_ERROR)));
for target in &targets {
target.take_requests();
}
for (restarted_node, expected_writers) in [(1, 2), (2, 3), (3, 4), (0, 4)] {
// A restart clears only this process's startup write fence.
targets[restarted_node].clear_faults();
let phase = format!("previous-restart-{restarted_node}");
wait_for_drive_readmission(
previous_baseline_writers(&clients, restarted_node),
MIXED_BUCKET,
&phase,
Duration::from_secs(5),
)
.await?;
for (node, target) in targets.iter().enumerate() {
let key = format!(".upgrade-readiness/{phase}/all-drives/node-{node}");
let admitted = node < expected_writers;
assert_eq!(
target.count_requests(FakeTargetOperation::PutObject, &key),
usize::from(admitted),
"{phase}: only writable node {node} may coordinate an all-drive probe"
);
assert_eq!(target.has_object(MIXED_BUCKET, &key), admitted);
assert_eq!(target.take_requests().len(), usize::from(admitted));
}
}
// Once every node is admitted, its permanent errors must still fail
// the full gate immediately rather than being skipped or retried.
let phase = "previous-ready-error";
let key = format!(".upgrade-readiness/{phase}/all-drives/node-3");
targets[3].inject_for_key(
FakeTargetOperation::PutObject,
&key,
FaultAction::Status(StatusCode::INTERNAL_SERVER_ERROR),
1,
);
let error =
wait_for_drive_readmission(previous_baseline_writers(&clients, 0), MIXED_BUCKET, phase, Duration::from_secs(5))
.await
.expect_err("a recovered coordinator's 500 must still fail the final gate");
assert!(error.to_string().contains("node 3 drive re-admission failed"), "{error}");
assert_eq!(targets[3].count_requests(FakeTargetOperation::PutObject, &key), 1);
assert!(!targets[3].has_object(MIXED_BUCKET, &key));
for target in targets {
target.shutdown().await;
}
Ok(())
}
#[tokio::test(start_paused = true)]
async fn waits_for_each_writer_after_metadata_is_ready() -> TestResult {
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
use aws_smithy_types::body::SdkBody;
let bucket = "distributed-upgrade-history";
let phase = "one-previous-node";
let first_key = format!(".upgrade-readiness/{phase}/node-0");
let second_key = format!(".upgrade-readiness/{phase}/node-1");
target.inject_for_key(
FakeTargetOperation::PutObject,
&first_key,
FaultAction::Status(StatusCode::SERVICE_UNAVAILABLE),
2,
);
target.inject_for_key(
FakeTargetOperation::PutObject,
&second_key,
FaultAction::Status(StatusCode::SERVICE_UNAVAILABLE),
1,
let endpoint = "http://upgrade-readiness.example.invalid";
let event = |method: Method, key: &str, status: StatusCode| {
let (body, query) = if method == Method::PUT {
(SdkBody::from(UPGRADE_READINESS_BODY), "?x-id=PutObject")
} else {
(SdkBody::empty(), "")
};
ReplayEvent::new(
http::Request::builder()
.method(method)
.uri(format!("{endpoint}/{bucket}/{key}{query}"))
.body(body)
.unwrap(),
http::Response::builder()
.status(status)
.header("content-type", "application/xml")
.body(if status == StatusCode::SERVICE_UNAVAILABLE {
SdkBody::from("<Error><Code>ServiceUnavailable</Code></Error>")
} else {
SdkBody::empty()
})
.unwrap(),
)
};
// Keep transport scheduling outside the probe's time budget. The
// replay checks every writer's key and body without real network I/O.
let transport = StaticReplayClient::new(vec![
event(Method::HEAD, "", StatusCode::OK),
event(Method::PUT, &first_key, StatusCode::SERVICE_UNAVAILABLE),
event(Method::PUT, &first_key, StatusCode::SERVICE_UNAVAILABLE),
event(Method::PUT, &first_key, StatusCode::OK),
event(Method::PUT, &second_key, StatusCode::SERVICE_UNAVAILABLE),
event(Method::PUT, &second_key, StatusCode::OK),
]);
let client = Client::from_conf(
aws_sdk_s3::Config::builder()
.behavior_version_latest()
.credentials_provider(Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "readiness-test"))
.region(Region::new("us-east-1"))
.endpoint_url(endpoint)
.force_path_style(true)
.retry_config(aws_sdk_s3::config::retry::RetryConfig::disabled())
.request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
.http_client(transport.clone())
.build(),
);
client.head_bucket().bucket(bucket).send().await?;
// Metadata readiness does not prove that a data write can succeed.
let premature = client
.put_object()
.bucket(bucket)
.key(&first_key)
.body(ByteStream::from_static(b"upgrade write readiness"))
.body(ByteStream::from_static(UPGRADE_READINESS_BODY))
.send()
.await
.expect_err("metadata readiness does not prove write readiness");
assert_eq!(premature.raw_response().map(|response| response.status().as_u16()), Some(503));
wait_for_upgrade_write_readiness(&[client.clone(), client.clone()], bucket, phase, Duration::from_secs(5)).await?;
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &first_key), 3);
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &second_key), 2);
assert!(target.has_object(bucket, &first_key));
assert!(target.has_object(bucket, &second_key));
assert!(
client
.list_objects_v2()
.bucket(bucket)
.prefix(format!("{phase}/"))
.send()
.await?
.contents()
.is_empty()
let started = Instant::now();
wait_for_upgrade_write_readiness(&[client.clone(), client], bucket, phase, Duration::from_secs(5)).await?;
assert_eq!(started.elapsed(), Duration::from_secs(1), "each unavailable writer must be polled again");
transport.assert_requests_match(&[]);
assert_eq!(
transport
.actual_requests()
.map(|request| request.method().to_string())
.collect::<Vec<_>>(),
["HEAD", "PUT", "PUT", "PUT", "PUT", "PUT"],
);
target.shutdown().await;
Ok(())
}
@@ -1198,7 +1374,7 @@ async fn rc5_rollback_requires_restoring_odm_configuration() -> TestResult {
#[tokio::test]
#[ignore = "requires a pinned previous RustFS release binary"]
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
async fn direct_upgrade_from_previous_release_preserves_object_contracts() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let mut env = RustFSTestEnvironment::new().await?;
@@ -1348,7 +1524,7 @@ async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
#[tokio::test]
#[ignore = "requires a pinned previous RustFS release binary"]
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
async fn rolling_upgrade_from_previous_release_preserves_mixed_version_contracts() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let evidence_context = g09_evidence_context(&previous_binary).await?;
@@ -1365,11 +1541,11 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
cluster.start_node_from_binary(0, &current_binary).await?;
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
for node_idx in [1, 2] {
for (node_idx, phase) in [(1, "two-current-nodes"), (2, "one-previous-node")] {
cluster.stop_node(node_idx)?;
cluster.start_node_from_binary(node_idx, &current_binary).await?;
exercise_mixed_cluster(&cluster, phase, 0, 3).await?;
}
exercise_mixed_cluster(&cluster, "one-previous-node", 0, 3).await?;
cluster.stop_node(3)?;
cluster.start_node_from_binary(3, &current_binary).await?;
@@ -1382,7 +1558,7 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
UPGRADE_READINESS_BODY,
"current node {node_idx} must retain the previous-release seed"
);
for phase in ["one-current-node", "one-previous-node"] {
for phase in ["one-current-node", "two-current-nodes", "one-previous-node"] {
wait_for_phase_listing(
client,
phase,
@@ -1394,14 +1570,14 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
}
if let Some(context) = evidence_context.as_ref() {
let phases = ["one-current-node", "one-previous-node"];
let phases = ["one-current-node", "two-current-nodes", "one-previous-node"];
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
write_g09_evidence(
context,
"mixed_version_reader_evidence",
"mixed-version-reader",
&["old-writer-new-reader", "new-writer-old-reader"],
"upgrade_compatibility_test::rolling_upgrade_from_rc2_preserves_mixed_version_contracts",
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
@@ -1419,7 +1595,7 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
"mixed_version_writer_evidence",
"mixed-version-writer",
&["old-reader-new-writer", "new-reader-old-writer"],
"upgrade_compatibility_test::rolling_upgrade_from_rc2_preserves_mixed_version_contracts",
"upgrade_compatibility_test::rolling_upgrade_from_previous_release_preserves_mixed_version_contracts",
serde_json::json!({
"bucket": MIXED_BUCKET,
"phases": phases,
@@ -1427,12 +1603,337 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
"assertions": [
"current writer publishes objects readable by previous-release node",
"previous-release writer publishes objects readable by current node",
"multipart writers continue under one-current-node and one-previous-node layouts"
"multipart writers continue at every individual node upgrade",
"modern DELETE fails closed and preserves every byte while old peers remain"
],
}),
)?;
}
let clients = cluster.create_all_clients()?;
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "all-current", LISTING_CONVERGENCE_TIMEOUT).await?;
clients[0]
.delete_object()
.bucket(MIXED_BUCKET)
.key(PREVIOUS_RELEASE_SEED_KEY)
.send()
.await?;
for (node, client) in clients.iter().enumerate() {
let error = client
.get_object()
.bucket(MIXED_BUCKET)
.key(PREVIOUS_RELEASE_SEED_KEY)
.send()
.await
.expect_err("the homogeneous cluster must apply the previously blocked DELETE");
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("NoSuchKey"),
"node {node}"
);
}
Ok(())
}
struct LegacyDeleteObject {
bucket: String,
key: String,
version: Option<String>,
etag: String,
body: Vec<u8>,
data_dirs: Vec<PathBuf>,
}
fn legacy_payload_directories(object_dir: &Path) -> Result<Vec<PathBuf>, BoxError> {
let entries = match std::fs::read_dir(object_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error.into()),
};
let mut directories = Vec::new();
for entry in entries {
let entry = entry?;
if entry.file_type()?.is_dir() && uuid::Uuid::parse_str(&entry.file_name().to_string_lossy()).is_ok() {
for child in std::fs::read_dir(entry.path())? {
let child = child?;
if child.file_type()?.is_file() && child.file_name().to_string_lossy().starts_with("part.") {
directories.push(entry.path());
break;
}
}
}
}
Ok(directories)
}
async fn seed_legacy_delete_object(
env: &RustFSTestEnvironment,
client: &Client,
bucket: &str,
key: &str,
seed: u64,
) -> Result<LegacyDeleteObject, BoxError> {
let object_dir = Path::new(&env.temp_dir).join(bucket).join(key);
let before = legacy_payload_directories(&object_dir)?;
// Incompressible external shards ensure the regression exercises payload
// reclamation, rather than only the inline xl.meta path.
let body = layout_noise(1024 * 1024 + 17, seed);
let response = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.send()
.await?;
let data_dirs: Vec<_> = legacy_payload_directories(&object_dir)?
.into_iter()
.filter(|path| !before.contains(path))
.collect();
assert_eq!(data_dirs.len(), 1, "the old binary must create one external payload for {bucket}/{key}");
// A suspended PUT can omit the version header while reads identify the
// stored generation as "null". Preserve the old reader's exact identity.
let head = client
.head_object()
.bucket(bucket)
.key(key)
.set_version_id(response.version_id().map(str::to_owned))
.send()
.await?;
assert_eq!(head.e_tag(), response.e_tag(), "old writer and reader agree on the stored bytes");
if let Some(version) = response.version_id() {
assert_eq!(head.version_id(), Some(version), "old reader resolves the written version");
}
Ok(LegacyDeleteObject {
bucket: bucket.to_owned(),
key: key.to_owned(),
version: head.version_id().map(str::to_owned),
etag: response.e_tag().ok_or("the old writer omitted its ETag")?.to_owned(),
body,
data_dirs,
})
}
async fn assert_legacy_delete_object(client: &Client, object: &LegacyDeleteObject) -> TestResult {
let response = client
.get_object()
.bucket(&object.bucket)
.key(&object.key)
.set_version_id(object.version.clone())
.send()
.await
.map_err(|error| format!("GET {}/{} version {:?}: {error:?}", object.bucket, object.key, object.version))?;
assert_eq!(
response.e_tag(),
Some(object.etag.as_str()),
"{}/{}: original ETag",
object.bucket,
object.key
);
assert_eq!(
response.version_id(),
object.version.as_deref(),
"{}/{}: original version",
object.bucket,
object.key
);
assert_eq!(response.body.collect().await?.into_bytes().as_ref(), object.body);
Ok(())
}
fn preserve_legacy_upgrade_volume(env: &RustFSTestEnvironment, label: &str) -> TestResult {
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else { return Ok(()) };
let root = Path::new(&env.temp_dir);
let destination = Path::new(&log_dir).join(label);
for entry in walkdir::WalkDir::new(root).follow_links(false) {
let entry = entry?;
let target = destination.join(entry.path().strip_prefix(root)?);
if entry.file_type().is_dir() {
std::fs::create_dir_all(target)?;
} else if entry.file_type().is_file() {
std::fs::copy(entry.path(), target)?;
} else {
return Err("the stopped upgrade fixture contains a non-regular entry".into());
}
}
Ok(())
}
#[tokio::test]
#[ignore = "requires the pinned stable 1.0.1 release binary"]
async fn stable_upgrade_deletes_foreign_reservations_without_losing_live_objects() -> TestResult {
use sha2::Digest;
init_logging();
let previous_binary = source_binary()?;
assert_eq!(binary_source_revision(&previous_binary).await?, STABLE_1_0_1_COMMIT);
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_from_binary(&previous_binary, vec![], &[]).await?;
let old_client = env.create_s3_client();
let plain_bucket = "legacy-delete-plain";
let versioned_bucket = "legacy-delete-versioned";
let suspended_bucket = "legacy-delete-suspended";
for bucket in [plain_bucket, versioned_bucket, suspended_bucket] {
env.create_test_bucket(bucket).await?;
}
enable_versioning(&old_client, versioned_bucket).await?;
enable_versioning(&old_client, suspended_bucket).await?;
old_client
.put_bucket_versioning()
.bucket(suspended_bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await?;
let mut deleted = Vec::new();
for (index, (bucket, key)) in [
(plain_bucket, "single"),
(plain_bucket, "batch-one"),
(plain_bucket, "batch-two"),
(versioned_bucket, "versioned"),
(suspended_bucket, "suspended"),
]
.into_iter()
.enumerate()
{
deleted.push(seed_legacy_delete_object(&env, &old_client, bucket, key, index as u64 + 1).await?);
}
let successor = seed_legacy_delete_object(&env, &old_client, versioned_bucket, "versioned", 20).await?;
let child_key = format!("single/{}/child", uuid::Uuid::new_v4());
let child = seed_legacy_delete_object(&env, &old_client, plain_bucket, &child_key, 21).await?;
for object in deleted.iter().chain([&successor, &child]) {
assert_legacy_delete_object(&old_client, object).await?;
}
env.stop_server();
// The stable release produced the complete volume and every xl.meta/part
// byte above. Only the empty legacy marker is fault-injected after stop;
// this deterministically models an interrupted reservation, not a claimed
// reproduction of the old binary's SIGKILL timing window.
let mut evidence = Vec::new();
for object in &deleted {
let metadata_path = Path::new(&env.temp_dir)
.join(&object.bucket)
.join(&object.key)
.join("xl.meta");
let metadata = std::fs::read(&metadata_path)?;
for directory in &object.data_dirs {
let marker = directory.join(format!("reserve-delete-data.{}", uuid::Uuid::new_v4()));
std::fs::File::create(marker)?.sync_all()?;
}
assert_eq!(
std::fs::read(&metadata_path)?,
metadata,
"fault injection must preserve the old xl.meta bytes"
);
evidence.push(serde_json::json!({
"bucket": object.bucket,
"key": object.key,
"version": object.version,
"etag": object.etag,
"xl_meta_sha256": hex_simd::encode_to_string(sha2::Sha256::digest(&metadata), hex_simd::AsciiCase::Lower),
}));
}
if let Some(directory) = std::env::var_os("RUSTFS_E2E_LOG_DIR") {
std::fs::create_dir_all(&directory)?;
std::fs::write(
Path::new(&directory).join("stable-delete-source.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"writer_revision": STABLE_1_0_1_COMMIT,
"candidate_revision": current_source_revision()?,
"fault_injection": "empty foreign reservation marker after stopping the genuine old writer",
"objects": evidence,
}))?,
)?;
}
preserve_legacy_upgrade_volume(&env, "stable-source-volume")?;
env.restart_server_preserving_data(vec![], &[]).await?;
let client = env.create_s3_client();
for object in deleted.iter().chain([&successor, &child]) {
if let Err(error) = assert_legacy_delete_object(&client, object).await {
env.stop_server();
preserve_legacy_upgrade_volume(&env, "failed-upgrade-volume")?;
return Err(error);
}
}
client.delete_object().bucket(plain_bucket).key("single").send().await?;
let response = client
.delete_objects()
.bucket(plain_bucket)
.delete(
Delete::builder()
.objects(ObjectIdentifier::builder().key("batch-one").build()?)
.objects(ObjectIdentifier::builder().key("batch-two").build()?)
.build()?,
)
.send()
.await?;
assert!(response.errors().is_empty(), "batch deletion must converge: {:?}", response.errors());
assert_eq!(response.deleted().len(), 2);
for object in &deleted[3..] {
client
.delete_object()
.bucket(&object.bucket)
.key(&object.key)
.set_version_id(object.version.clone())
.send()
.await?;
}
// Repeat recovery across process restarts, then retry the same requests.
// The old committed payload must disappear while the successor and the
// UUID-shaped prefix child remain intact through every pass.
for pass in 0..2 {
env.restart_server_preserving_data(vec![], &[]).await?;
let client = env.create_s3_client();
for object in &deleted {
let error = client
.get_object()
.bucket(&object.bucket)
.key(&object.key)
.set_version_id(object.version.clone())
.send()
.await
.expect_err("an acknowledged deletion must not resurrect after restart");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(404));
for directory in &object.data_dirs {
assert!(
!directory.exists(),
"pass {pass}: deleted payload and foreign reservation must be physically reclaimed"
);
}
client
.delete_object()
.bucket(&object.bucket)
.key(&object.key)
.set_version_id(object.version.clone())
.send()
.await?;
}
for object in [&successor, &child] {
assert_legacy_delete_object(&client, object).await?;
}
assert_eq!(
read_object(&client, versioned_bucket, "versioned", None).await?.1,
successor.body,
"deleting the old version must preserve the current successor"
);
let versions = client.list_object_versions().bucket(versioned_bucket).send().await?;
assert_eq!(versions.versions().len(), 1);
assert_eq!(versions.versions()[0].version_id(), successor.version.as_deref());
assert!(versions.delete_markers().is_empty());
let listed = client.list_objects_v2().bucket(plain_bucket).send().await?;
assert_eq!(listed.contents().len(), 1);
assert_eq!(listed.contents()[0].key(), Some(child_key.as_str()));
}
client.delete_object().bucket(plain_bucket).key(&child_key).send().await?;
client.delete_bucket().bucket(plain_bucket).send().await?;
Ok(())
}
@@ -2147,14 +2648,14 @@ async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestRes
}
// ---------------------------------------------------------------------------
// rc.5 multipart layouts under the current build (backlog#2147 follow-up to
// Previous-release multipart layouts under the current build (backlog#2147 follow-up to
// rustfs#7305)
// ---------------------------------------------------------------------------
//
// rustfs#7305 changed `ObjectInfo::is_multipart` to consult the stored part
// list before the ETag shape. Every earlier check of that change used
// synthetic metadata; this scenario writes the layouts with the published
// rc.5 binary and then reads, describes, and replicates them with the
// previous-release binary and then reads, describes, and replicates them with the
// current build on the same data directory.
const LAYOUT_PLAIN_BUCKET: &str = "upgrade-layout-plain";
@@ -2175,10 +2676,10 @@ struct LayoutCase {
/// `false` for layouts whose replication is a known pre-existing failure;
/// their outcome is logged, not asserted.
assert_replication: bool,
/// Recorded from the rc.5 writer.
rc5_etag: String,
/// Whether rc.5 reported `ObjectParts` for the object.
rc5_reported_parts: Option<usize>,
/// Recorded from the previous-release writer.
source_etag: String,
/// Whether the previous release reported `ObjectParts` for the object.
source_reported_parts: Option<usize>,
}
impl LayoutCase {
@@ -2283,7 +2784,7 @@ async fn layout_attributes(
Ok(request.send().await?)
}
/// Write `case` with the rc.5 client; single PUT when `part_sizes` is empty.
/// Write `case` with the source client; single PUT when `part_sizes` is empty.
async fn layout_write(client: &Client, case: &LayoutCase) -> Result<(), BoxError> {
let content_type = "text/plain";
if case.part_sizes.is_empty() {
@@ -2370,8 +2871,8 @@ fn layout_cases() -> Vec<LayoutCase> {
body,
ssec,
assert_replication: true,
rc5_etag: String::new(),
rc5_reported_parts: None,
source_etag: String::new(),
source_reported_parts: None,
};
vec![
case(LAYOUT_PLAIN_BUCKET, "plain/single.bin", vec![], layout_noise(1024 * 1024 + 17, 1), false),
@@ -2414,7 +2915,7 @@ fn layout_cases() -> Vec<LayoutCase> {
// compressible first part is stored well below 5 MiB, so the sender
// declares each part's plaintext length and the target validates the
// 5 MiB minimum against it (rustfs/backlog#2363). rc.5 as the sender
// still fails this layout (see `rc5_baseline_replicates_multipart_layouts`).
// still fails this layout (see `previous_release_baseline_replicates_multipart_layouts`).
case(
LAYOUT_PLAIN_BUCKET,
"plain/ssec-compressed-multipart-2.txt",
@@ -2469,8 +2970,8 @@ async fn assert_layout_full_body(client: &Client, case: &LayoutCase, context: &s
let head = layout_head(client, case).await?;
assert_eq!(
head.e_tag().map(|etag| etag.trim_matches('"')),
Some(case.rc5_etag.as_str()),
"{context}: {label}: the ETag written by rc.5 must be reported unchanged"
Some(case.source_etag.as_str()),
"{context}: {label}: the ETag written by the previous release must be reported unchanged"
);
assert_eq!(
head.content_length(),
@@ -2480,7 +2981,10 @@ async fn assert_layout_full_body(client: &Client, case: &LayoutCase, context: &s
let full = layout_get(client, case, None, None).await?.body.collect().await?.into_bytes();
assert_eq!(full.len(), case.body.len(), "{context}: {label}: full GET length");
assert!(full == case.body, "{context}: {label}: full GET body must equal the rc.5 upload");
assert!(
full == case.body,
"{context}: {label}: full GET body must equal the previous-release upload"
);
Ok(())
}
@@ -2522,7 +3026,7 @@ async fn assert_layout_attributes(client: &Client, case: &LayoutCase, context: &
let attributes = layout_attributes(client, case).await?;
assert_eq!(
attributes.e_tag().map(|etag| etag.trim_matches('"')),
Some(case.rc5_etag.as_str()),
Some(case.source_etag.as_str()),
"{context}: {label}: attributes ETag"
);
assert_eq!(
@@ -2721,13 +3225,13 @@ async fn replicate_layouts(
Ok((target, transports))
}
/// rc.5 writes single-PUT, multipart, compressed, SSE-C and SSE-S3 layouts;
/// The previous release writes single-PUT, multipart, compressed, SSE-C and SSE-S3 layouts;
/// the current build must read every byte, expose the stored part layout
/// through GetObjectAttributes and partNumber reads, and replicate the objects
/// with the transport that matches their stored parts.
#[tokio::test]
#[ignore = "requires the pinned 1.0.0-rc.5 release binary"]
async fn direct_upgrade_from_rc5_preserves_multipart_layouts() -> TestResult {
#[ignore = "requires a pinned previous RustFS release binary"]
async fn direct_upgrade_from_previous_release_preserves_multipart_layouts() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let server_env = layout_server_env();
@@ -2741,18 +3245,18 @@ async fn direct_upgrade_from_rc5_preserves_multipart_layouts() -> TestResult {
enable_versioning(&old_client, LAYOUT_PLAIN_BUCKET).await?;
enable_versioning(&old_client, LAYOUT_ENCRYPTED_BUCKET).await?;
put_default_sse_s3_encryption(&old_client, LAYOUT_ENCRYPTED_BUCKET).await?;
assert_default_sse_s3_encryption(&old_client, LAYOUT_ENCRYPTED_BUCKET, "rc.5").await?;
assert_default_sse_s3_encryption(&old_client, LAYOUT_ENCRYPTED_BUCKET, "previous release").await?;
let mut cases = layout_cases();
for case in cases.iter_mut() {
layout_write(&old_client, case).await?;
let head = layout_head(&old_client, case).await?;
case.rc5_etag = head
case.source_etag = head
.e_tag()
.ok_or_else(|| format!("{}: rc.5 HEAD omitted the ETag", case.label()))?
.ok_or_else(|| format!("{}: previous-release HEAD omitted the ETag", case.label()))?
.trim_matches('"')
.to_string();
case.rc5_reported_parts = layout_attributes(&old_client, case)
case.source_reported_parts = layout_attributes(&old_client, case)
.await
.ok()
.and_then(|a| layout_reported_parts(&a));
@@ -2760,16 +3264,16 @@ async fn direct_upgrade_from_rc5_preserves_multipart_layouts() -> TestResult {
target: "e2e_test::upgrade_compatibility_test",
object = %case.label(),
parts = case.part_sizes.len(),
etag = %case.rc5_etag,
rc5_reported_parts = ?case.rc5_reported_parts,
"rc.5 wrote a legacy layout"
etag = %case.source_etag,
source_reported_parts = ?case.source_reported_parts,
"previous release wrote a layout"
);
}
// Prove the rc.5 writer persisted every original byte before upgrading.
// Prove the previous-release writer persisted every original byte before upgrading.
// Its compressed cross-part range reader has a legacy limitation; the
// upgraded reader must still satisfy every range and partNumber assertion.
for case in &cases {
assert_layout_full_body(&old_client, case, "rc.5").await?;
assert_layout_full_body(&old_client, case, "previous release").await?;
}
// Upgrade in place.
@@ -2824,19 +3328,19 @@ async fn direct_upgrade_from_rc5_preserves_multipart_layouts() -> TestResult {
.await?
.into_bytes();
assert_eq!(replica.len(), case.body.len(), "{label}: replica length");
assert!(replica == case.body, "{label}: replica body must equal the rc.5 upload");
assert!(replica == case.body, "{label}: replica body must equal the previous-release upload");
}
}
Ok(())
}
/// The same layouts replicated by rc.5 itself, without an upgrade. This is the
/// The same layouts replicated by the previous release itself, without an upgrade. This is the
/// baseline that tells a pre-existing transport failure apart from one the
/// current build introduced; it records the outcome per layout and only fails
/// when the fixture cannot run.
#[tokio::test]
#[ignore = "requires the pinned 1.0.0-rc.5 release binary"]
async fn rc5_baseline_replicates_multipart_layouts() -> TestResult {
#[ignore = "requires a pinned previous RustFS release binary"]
async fn previous_release_baseline_replicates_multipart_layouts() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let server_env = layout_server_env();
@@ -2855,9 +3359,9 @@ async fn rc5_baseline_replicates_multipart_layouts() -> TestResult {
for case in cases.iter_mut() {
layout_write(&client, case).await?;
let head = layout_head(&client, case).await?;
case.rc5_etag = head
case.source_etag = head
.e_tag()
.ok_or_else(|| format!("{}: rc.5 HEAD omitted the ETag", case.label()))?
.ok_or_else(|| format!("{}: previous-release HEAD omitted the ETag", case.label()))?
.trim_matches('"')
.to_string();
}
@@ -2875,7 +3379,7 @@ async fn rc5_baseline_replicates_multipart_layouts() -> TestResult {
)
})
.collect();
tracing::info!(target: "e2e_test::upgrade_compatibility_test", ?summary, "rc.5 baseline replication outcomes");
tracing::info!(target: "e2e_test::upgrade_compatibility_test", ?summary, "previous-release baseline replication outcomes");
Ok(())
}
@@ -2902,7 +3406,7 @@ async fn existing_object_replication_drives_each_layout_once() -> TestResult {
for case in cases.iter_mut() {
layout_write(&writer, case).await?;
let head = layout_head(&writer, case).await?;
case.rc5_etag = head
case.source_etag = head
.e_tag()
.ok_or_else(|| format!("{}: HEAD omitted the ETag", case.label()))?
.trim_matches('"')
+25 -14
View File
@@ -254,11 +254,11 @@ pub mod bucket {
pub mod quota {
pub use crate::bucket::quota::{
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V1, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError,
QuotaOperation,
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration,
};
pub mod checker {
pub use crate::bucket::quota::checker::QuotaChecker;
pub use crate::bucket::quota::checker::{QuotaChecker, record_reservation_protocol_migration};
}
}
@@ -343,8 +343,9 @@ pub mod cache {
pub mod capacity {
pub use crate::core::pools::{
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
is_pool_activation_fleet_proof_error, path2_bucket_object, path2_bucket_object_with_base_path,
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolMeta, PoolStatus, get_total_usable_capacity,
get_total_usable_capacity_free, is_pool_activation_fleet_proof_error, path2_bucket_object,
path2_bucket_object_with_base_path,
};
pub use crate::store::utils::is_reserved_or_invalid_bucket;
}
@@ -439,9 +440,9 @@ pub mod disk {
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, 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,
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;
@@ -510,17 +511,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,
};
}
@@ -614,6 +617,14 @@ pub mod set_disk {
pub fn hold_namespace_commit(store: &crate::store::ECStore) -> impl Send + Sync {
store.ctx.begin_namespace_commit()
}
/// Whether an ordinary namespace commit (a PUT rename fanout, including
/// its post-ACK tail) is still pending on this instance. Test fixtures
/// drain tails through this probe; the scanner's publication barrier no
/// longer reports them.
pub fn namespace_commits_pending(store: &crate::store::ECStore) -> bool {
store.ctx.namespace_commits_pending()
}
}
}
+178 -40
View File
@@ -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>>;
@@ -2012,7 +2012,7 @@ impl TargetClient {
.head_object()
.bucket(bucket)
.key(object)
.set_version_id(version_id)
.set_version_id(resolve_read_api_version_id(version_id))
.customize()
.map_request(move |mut req| {
for (k, v) in headers.clone().into_iter() {
@@ -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 {
@@ -4060,6 +4170,34 @@ mod tests {
);
}
/// The worker's convergence HEAD addresses a source null version (nil
/// UUID) as `versionId=null`, the spelling every S3 target understands,
/// like the proxy and audit reads do.
#[tokio::test]
async fn head_object_addresses_nil_source_version_as_null() {
let (client, request_uris) = recording_target_client();
let versioned = Uuid::new_v4().to_string();
// Only the request URI matters here; the canned empty response may or
// may not parse as a HEAD result.
let _ = client
.head_object("target-bucket", "object", Some(Uuid::nil().to_string()))
.await;
let _ = client.head_object("target-bucket", "object", Some(versioned.clone())).await;
let request_uris = request_uris.lock().expect("recorded request lock should not be poisoned");
assert_eq!(request_uris.len(), 2);
assert!(
request_uris[0].contains("versionId=null") && !request_uris[0].contains(&Uuid::nil().to_string()),
"a nil source version must be addressed as the null version: {}",
request_uris[0]
);
assert!(
request_uris[1].contains(&format!("versionId={versioned}")),
"a concrete source version must pass through unchanged: {}",
request_uris[1]
);
}
#[tokio::test]
async fn put_object_sends_source_version_id_query_to_target() {
// MinIO reads the replicated version only from the `versionId` query
@@ -881,7 +881,11 @@ fn free_version_cleanup_fences_current(
&& free_version_physical_topology_generation(api) == topology_generation
}
async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel: &CancellationToken) -> std::io::Result<bool> {
pub(crate) async fn cleanup_free_version_exact(
api: Arc<ECStore>,
oi: &ObjectInfo,
cancel: &CancellationToken,
) -> std::io::Result<bool> {
const FREE_VERSION_REMOTE_DEADLINE: StdDuration = StdDuration::from_secs(30);
let topology_generation = free_version_physical_topology_generation(&api);
@@ -1040,6 +1044,32 @@ pub struct ExpiryState {
recovery_notify: Arc<Notify>,
}
/// Keeps every lifecycle expiry task the process enqueues in its queue until
/// released, as if the expiry workers were busy.
#[cfg(all(test, feature = "test-util"))]
pub(crate) struct HeldExpiryQueue {
state: Arc<RwLock<ExpiryState>>,
previous: ExpiryState,
}
#[cfg(all(test, feature = "test-util"))]
impl HeldExpiryQueue {
pub(crate) async fn hold() -> Self {
let held = Arc::try_unwrap(ExpiryState::new_with_unconsumed_worker_channel(16))
.ok()
.expect("a new expiry state has a single owner")
.into_inner();
let state = runtime_sources::expiry_state_handle();
let previous = std::mem::replace(&mut *state.write().await, held);
Self { state, previous }
}
/// Drops the held tasks unrun and restores the previous expiry state.
pub(crate) async fn release(self) {
*self.state.write().await = self.previous;
}
}
impl ExpiryState {
#[allow(clippy::new_ret_no_self)]
pub fn new() -> Arc<RwLock<Self>> {
@@ -3968,44 +3998,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
@@ -4939,6 +4953,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);
@@ -5781,6 +5803,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;
@@ -5811,6 +5836,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()
@@ -6092,6 +6118,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,
@@ -6140,7 +6167,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,
@@ -12921,6 +12948,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`
@@ -12939,15 +12967,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<_>>();
@@ -12974,6 +13011,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() {
@@ -13061,6 +13189,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)
@@ -13231,6 +13367,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() {
@@ -13340,6 +13551,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
@@ -13354,6 +13566,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
@@ -13468,6 +13681,7 @@ 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
@@ -13579,6 +13793,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
@@ -13601,7 +13816,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
@@ -13757,6 +13976,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())
@@ -13874,6 +14094,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,
@@ -14054,6 +14275,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 = "Disabled".to_owned(),
"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<PersistedLifecycleConfiguration> {
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;
@@ -959,7 +959,7 @@ impl PersistedTierDeleteJournalEntry {
fn into_jentry(self) -> Result<Jentry> {
// Empty `version_id` is a legal sentinel for objects transitioned to an
// unversioned remote tier (see CLAUDE.md: a tier version of `None`/`""`
// unversioned remote tier (see AGENTS.md: a tier version of `None`/`""`
// means the tier bucket is unversioned, so the remote delete is issued
// without a versionId). Only reject entries missing the object or tier
// name, which are always populated for a TRANSITION_COMPLETE object.
@@ -626,7 +626,7 @@ fn mark_scan_truncated_if_needed(
fn is_recoverable_tier_free_version(oi: &ObjectInfo) -> bool {
// A free version transitioned to an unversioned remote tier legally carries an
// empty `version_id` (see CLAUDE.md: a tier version of `None`/`""` means the
// empty `version_id` (see AGENTS.md: a tier version of `None`/`""` means the
// tier bucket is unversioned). The worker path handles that by issuing a
// versionless remote delete, so the recovery gate must not treat an empty
// `version_id` as unrecoverable — the object name and tier are sufficient to
+96 -5
View File
@@ -522,6 +522,15 @@ pub(crate) async fn get_bucket_incarnation_id_in(ctx: &crate::runtime::instance:
sys.get_bucket_incarnation_id_from_disk(bucket).await
}
pub(crate) async fn find_bucket_incarnation_id_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<Option<Uuid>> {
let sys = bucket_metadata_sys_of(ctx)?;
let sys = sys.read().await.clone();
sys.find_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(
@@ -1015,6 +1024,52 @@ async fn update_quota_if_incarnation_stamped(
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data, updated_at).await
}
/// Rewrite the quota configuration only while the bucket still carries
/// `expected_incarnation_id` and `expected_quota_revision` (its
/// `quota_config_updated_at`), under the same guards and fleet proof as
/// [`update_quota_if_incarnation`].
///
/// The revision check turns a stale read into a typed
/// [`Error::PreconditionFailed`] instead of a lost update: the reservation
/// protocol migration (rustfs/backlog#2904) rewrites a configuration it read
/// from the cache, and an admin save landing in between must win. The system
/// is passed explicitly so the write publishes into the cache the caller
/// reads from.
pub async fn update_quota_with_sys_if_incarnation_and_revision(
sys: Arc<RwLock<BucketMetadataSys>>,
bucket: &str,
data: Vec<u8>,
expected_incarnation_id: Uuid,
expected_quota_revision: OffsetDateTime,
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
) -> Result<OffsetDateTime> {
let guard = Box::pin(acquire_config_write_guard_for_incarnation(
sys.clone(),
bucket,
Some(expected_incarnation_id),
))
.await?;
if !crate::services::notification_sys::cross_pool_fence_fleet_proof_matches(proof) {
return Err(Error::NamespaceLockQuorumUnavailable {
mode: "quota_capability",
bucket: bucket.to_string(),
object: rustfs_config::QUOTA_CONFIG_FILE.to_string(),
required: 1,
achieved: 0,
});
}
let metadata_sys = sys.read().await.clone();
match Box::pin(metadata_sys.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)).await? {
BucketMetadataAuthority::Authoritative(current) if current.quota_config_updated_at == expected_quota_revision => {}
BucketMetadataAuthority::Authoritative(_) => return Err(Error::PreconditionFailed),
BucketMetadataAuthority::MissingBucket => return Err(Error::BucketNotFound(bucket.to_string())),
BucketMetadataAuthority::Fabricated => {
return Err(Error::other("bucket quota metadata is not authoritative"));
}
}
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data, None).await
}
pub async fn update_bucket_targets_under_transaction_lock(
guard: &BucketMetadataMutationGuard,
bucket: &str,
@@ -1364,6 +1419,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();
@@ -1384,6 +1451,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)?,
@@ -2707,6 +2781,11 @@ impl BucketMetadataSys {
}
async fn get_bucket_incarnation_id_from_disk(&self, bucket: &str) -> Result<Uuid> {
let incarnation_id = self.find_bucket_incarnation_id_from_disk(bucket).await?;
Self::require_bucket_incarnation_id(bucket, incarnation_id)
}
async fn find_bucket_incarnation_id_from_disk(&self, bucket: &str) -> Result<Option<Uuid>> {
let transaction_lock = self
.object_store()
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
@@ -2714,7 +2793,7 @@ impl BucketMetadataSys {
let _transaction_guard = transaction_lock
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
.await?;
self.get_bucket_incarnation_id_under_transaction_lock(bucket, &_transaction_guard)
self.find_bucket_incarnation_id_under_transaction_lock(bucket, &_transaction_guard)
.await
}
@@ -2723,14 +2802,26 @@ impl BucketMetadataSys {
bucket: &str,
transaction_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<Uuid> {
let incarnation_id = self
.find_bucket_incarnation_id_under_transaction_lock(bucket, transaction_guard)
.await?;
Self::require_bucket_incarnation_id(bucket, incarnation_id)
}
fn require_bucket_incarnation_id(bucket: &str, incarnation_id: Option<Uuid>) -> Result<Uuid> {
incarnation_id.ok_or_else(|| Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
}
async fn find_bucket_incarnation_id_under_transaction_lock(
&self,
bucket: &str,
transaction_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<Option<Uuid>> {
let incarnation_id = load_bucket_incarnation(self.object_store(), bucket).await?;
if transaction_guard.is_lock_lost() {
return Err(Error::other(format!("bucket incarnation metadata transaction lock was lost: {bucket}")));
}
match incarnation_id {
Some(incarnation_id) if !incarnation_id.is_nil() => Ok(incarnation_id),
_ => Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}"))),
}
Ok(incarnation_id)
}
async fn get_metadata_authority(&self, bucket: &str) -> Result<BucketMetadataAuthority> {
+672 -20
View File
@@ -12,16 +12,33 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
use crate::data_usage::get_bucket_usage_memory;
use super::{
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError, QuotaMigrationSkipReason, QuotaOperation,
QuotaProtocolMigration,
};
use crate::bucket::metadata_sys::{
BucketMetadataSys, update, update_if_incarnation, update_quota_with_sys_if_incarnation_and_revision,
};
use crate::data_usage::{get_bucket_usage_memory, reload_unknown_bucket_usage};
use crate::error::StorageError;
use rustfs_config::QUOTA_CONFIG_FILE;
use rustfs_scanner_metrics::metrics::Metric;
use std::sync::Arc;
use std::time::Instant;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
use time::OffsetDateTime;
use tokio::sync::RwLock;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_QUOTA: &str = "quota";
const EVENT_QUOTA_USAGE_REPORT: &str = "quota_usage_report";
const EVENT_QUOTA_PROTOCOL_MIGRATION: &str = "quota_reservation_protocol_migration";
const METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL: &str = "rustfs_quota_usage_report_unavailable_total";
const METRIC_QUOTA_PROTOCOL_MIGRATIONS_TOTAL: &str = "rustfs_quota_reservation_protocol_migrations_total";
/// A lazy migration attempt that did not settle the bucket is retried no
/// sooner than this; the admin command is not rate limited.
const LAZY_MIGRATION_RETRY_INTERVAL: Duration = Duration::from_secs(300);
pub struct QuotaChecker {
metadata_sys: Arc<RwLock<BucketMetadataSys>>,
@@ -74,7 +91,15 @@ impl QuotaChecker {
Some(q) => q,
};
let current_usage = self.get_real_time_usage(bucket).await?;
// A durable quota is admitted by the reservation ledger at commit, so
// scanner usage is reporting input here: an unavailable lookup is
// reported as absent instead of failing the write (rustfs/backlog#2904).
// The snapshot protocol still admits from this value and fails closed.
let current_usage = if uses_durable_reservations {
self.report_only_usage(bucket).await
} else {
Some(self.get_real_time_usage(bucket).await?)
};
// The reporting path projects this operation; storage mutations reserve it at commit.
let admission_size = if uses_durable_reservations && !force_usage_calculation {
@@ -82,27 +107,23 @@ impl QuotaChecker {
} else {
operation_size
};
let expected_usage = match operation {
let expected_usage = current_usage.map(|current_usage| match operation {
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
current_usage.saturating_add(admission_size)
}
QuotaOperation::DeleteObject => current_usage.saturating_sub(operation_size),
};
});
let allowed = match operation {
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
quota_config.check_operation_allowed(current_usage, admission_size)
current_usage.is_none_or(|current_usage| quota_config.check_operation_allowed(current_usage, admission_size))
}
QuotaOperation::DeleteObject => true,
};
let remaining = if quota_limit >= expected_usage {
Some(quota_limit - expected_usage)
} else {
Some(0)
};
let remaining = expected_usage.map(|expected_usage| quota_limit.saturating_sub(expected_usage));
if !allowed {
if let (false, Some(current_usage)) = (allowed, current_usage) {
warn!(
"Quota exceeded for bucket: {}, current: {}, limit: {}, attempted: {}",
bucket, current_usage, quota_limit, operation_size
@@ -111,7 +132,7 @@ impl QuotaChecker {
let result = QuotaCheckResult {
allowed,
current_usage: Some(current_usage),
current_usage,
quota_limit: Some(quota_limit),
operation_size,
remaining,
@@ -152,6 +173,10 @@ impl QuotaChecker {
reason: format!("Failed to parse quota config: {}", e),
})?;
if quota.needs_reservation_protocol_migration() {
self.schedule_reservation_protocol_migration(bucket).await;
}
Ok(quota)
}
@@ -219,17 +244,170 @@ impl QuotaChecker {
}
let quota = self.get_quota_config(bucket).await?;
let current_usage = self.get_real_time_usage(bucket).await?;
let current_usage = if quota.uses_durable_reservations() {
self.report_only_usage(bucket).await
} else {
Some(self.get_real_time_usage(bucket).await?)
};
Ok((quota, Some(current_usage)))
Ok((quota, current_usage))
}
/// Scanner usage for a bucket the reservation ledger admits is reporting
/// input only, so a failed lookup degrades to "unknown" and is counted
/// instead of becoming an error the write path would fail on.
async fn report_only_usage(&self, bucket: &str) -> Option<u64> {
// The plain lookup: a report must not wait for an early snapshot reload.
let usage = get_bucket_usage_memory(bucket).await;
match self.usage_or_degraded_baseline(bucket, usage).await {
Ok(usage) => Some(usage),
Err(err) => {
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
debug!(
event = EVENT_QUOTA_USAGE_REPORT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
state = "usage_unknown",
bucket = %bucket,
error = %err,
"durable quota usage report degraded"
);
None
}
}
}
/// Re-save `bucket`'s quota under the sharded durable reservation
/// protocol (rustfs/backlog#2904). The limit and type are unchanged. The
/// write takes the incarnation- and revision-checked quota save path with
/// the fleet capability proof, so a mixed-version fleet fails the attempt
/// and a concurrent quota edit or bucket replacement leaves the newer
/// configuration untouched.
pub async fn migrate_reservation_protocol(&self, bucket: &str) -> Result<QuotaProtocolMigration, QuotaError> {
let (meta, _) = self
.metadata_sys
.read()
.await
.get_config(bucket)
.await
.map_err(QuotaError::StorageError)?;
if meta.quota_config_json.is_empty() {
return Ok(QuotaProtocolMigration::NoQuota);
}
let quota: BucketQuota = serde_json::from_slice(&meta.quota_config_json).map_err(|e| QuotaError::InvalidConfig {
reason: format!("Failed to parse quota config: {}", e),
})?;
if let Some(reservation_protocol) = quota.reservation_protocol {
return Ok(if quota.uses_durable_reservations() {
QuotaProtocolMigration::AlreadyDurable { reservation_protocol }
} else {
QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::UnsupportedReservationProtocol,
}
});
}
let Some(migrated) = quota.migrated_to_sharded_reservations() else {
return Ok(QuotaProtocolMigration::NoQuota);
};
if !meta.bucket_incarnation_sidecar || meta.bucket_incarnation_id.is_nil() {
return Err(QuotaError::StorageError(StorageError::other(
"bucket incarnation metadata is not authoritative",
)));
}
// Same gate as the admin save: a durable protocol must not reach a
// fleet that still has nodes that would fail closed on it.
let proof = crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().ok_or_else(|| {
QuotaError::StorageError(StorageError::NamespaceLockQuorumUnavailable {
mode: "quota_capability",
bucket: bucket.to_string(),
object: QUOTA_CONFIG_FILE.to_string(),
required: 1,
achieved: 0,
})
})?;
let json_data = serde_json::to_vec(&migrated).map_err(|e| QuotaError::InvalidConfig {
reason: format!("Failed to serialize quota config: {}", e),
})?;
let start_time = Instant::now();
let saved = update_quota_with_sys_if_incarnation_and_revision(
Arc::clone(&self.metadata_sys),
bucket,
json_data,
meta.bucket_incarnation_id,
meta.quota_config_updated_at,
&proof,
)
.await;
match saved {
Ok(updated_at) => {
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(QuotaProtocolMigration::Migrated {
reservation_protocol: QUOTA_RESERVATION_PROTOCOL_V2,
updated_at,
})
}
Err(StorageError::PreconditionFailed) => Ok(QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::QuotaRevisionChanged,
}),
Err(StorageError::BucketNotFound(_)) => Ok(QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::BucketIncarnationChanged,
}),
Err(err) => Err(QuotaError::StorageError(err)),
}
}
/// Lazy half of the migration: the first quota read of a bucket still on
/// the snapshot protocol re-saves it under the sharded durable protocol
/// in the background; the read itself is neither blocked nor failed by
/// it. Only the metadata system registered on the store's instance
/// context migrates, so the rewrite publishes into the cache every
/// reader of this store consults; a checker over an ad-hoc system
/// (tooling, tests) only reads. An attempt that does not settle the
/// bucket is retried no sooner than `LAZY_MIGRATION_RETRY_INTERVAL`.
async fn schedule_reservation_protocol_migration(&self, bucket: &str) {
let registered = self.metadata_sys.read().await.object_store().ctx.bucket_metadata_sys();
if !registered.is_some_and(|registered| Arc::ptr_eq(&registered, &self.metadata_sys)) {
return;
}
if !lazy_migration_attempt_due(bucket) {
return;
}
let checker = Self::new(Arc::clone(&self.metadata_sys));
let bucket = bucket.to_string();
tokio::spawn(async move {
let outcome = checker.migrate_reservation_protocol(&bucket).await;
record_reservation_protocol_migration(&bucket, "lazy", &outcome);
if matches!(
outcome,
Ok(QuotaProtocolMigration::Migrated { .. }
| QuotaProtocolMigration::AlreadyDurable { .. }
| QuotaProtocolMigration::NoQuota)
) {
lazy_migration_attempt_settled(&bucket);
}
});
}
pub async fn bucket_exists(&self, bucket: &str) -> bool {
self.metadata_sys.read().await.get(bucket).await.is_ok()
}
/// Usage for a caller that fails closed without it. In a distributed
/// deployment a bucket the cached usage snapshot does not cover yet may
/// reload that snapshot before the degraded fallback is consulted.
pub async fn get_real_time_usage(&self, bucket: &str) -> Result<u64, QuotaError> {
if let Some(usage) = get_bucket_usage_memory(bucket).await {
let usage = match get_bucket_usage_memory(bucket).await {
Some(usage) => Some(usage),
// Boxed like the degraded fallback below: the snapshot reload
// would otherwise grow every write's state machine, and it only
// runs for a bucket the cached snapshot does not cover.
None => Box::pin(reload_unknown_bucket_usage(bucket)).await,
};
self.usage_or_degraded_baseline(bucket, usage).await
}
async fn usage_or_degraded_baseline(&self, bucket: &str, usage: Option<u64>) -> Result<u64, QuotaError> {
if let Some(usage) = usage {
return Ok(usage);
}
@@ -262,10 +440,107 @@ impl QuotaChecker {
}
}
/// One entry per bucket whose lazy migration is in flight or did not settle;
/// the entry is dropped once the bucket settles, so the map is bounded by the
/// number of buckets still on the snapshot protocol.
fn lazy_migration_attempts() -> &'static Mutex<HashMap<String, Instant>> {
static ATTEMPTS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
ATTEMPTS.get_or_init(Mutex::default)
}
fn lazy_migration_attempt_due(bucket: &str) -> bool {
let mut attempts = lazy_migration_attempts().lock().unwrap_or_else(PoisonError::into_inner);
let now = Instant::now();
if attempts
.get(bucket)
.is_some_and(|last| now.duration_since(*last) < LAZY_MIGRATION_RETRY_INTERVAL)
{
return false;
}
attempts.insert(bucket.to_string(), now);
true
}
fn lazy_migration_attempt_settled(bucket: &str) {
lazy_migration_attempts()
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(bucket);
}
#[cfg(test)]
fn lazy_migration_attempt_pending(bucket: &str) -> bool {
lazy_migration_attempts()
.lock()
.unwrap_or_else(PoisonError::into_inner)
.contains_key(bucket)
}
/// One log line and one counter per migration attempt; `trigger` names the
/// caller (`lazy` from the quota read path, `admin` from the admin command).
pub fn record_reservation_protocol_migration(
bucket: &str,
trigger: &'static str,
outcome: &Result<QuotaProtocolMigration, QuotaError>,
) {
let result = match outcome {
Ok(QuotaProtocolMigration::Migrated { .. }) => "migrated",
Ok(QuotaProtocolMigration::AlreadyDurable { .. }) | Ok(QuotaProtocolMigration::NoQuota) => "noop",
Ok(QuotaProtocolMigration::Skipped { .. }) => "skipped",
Err(_) => "failed",
};
metrics::counter!(METRIC_QUOTA_PROTOCOL_MIGRATIONS_TOTAL, "trigger" => trigger, "result" => result).increment(1);
match outcome {
Ok(QuotaProtocolMigration::Migrated {
reservation_protocol, ..
}) => info!(
event = EVENT_QUOTA_PROTOCOL_MIGRATION,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
result,
trigger,
bucket = %bucket,
reservation_protocol,
"quota reservation protocol migrated"
),
Ok(QuotaProtocolMigration::Skipped { reason }) => debug!(
event = EVENT_QUOTA_PROTOCOL_MIGRATION,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
result,
trigger,
bucket = %bucket,
reason = reason.as_str(),
"quota reservation protocol migration skipped"
),
Ok(_) => debug!(
event = EVENT_QUOTA_PROTOCOL_MIGRATION,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
result,
trigger,
bucket = %bucket,
"quota reservation protocol migration not needed"
),
Err(err) => warn!(
event = EVENT_QUOTA_PROTOCOL_MIGRATION,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
result,
trigger,
bucket = %bucket,
error = %err,
"quota reservation protocol migration failed"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
use crate::bucket::quota::QuotaType;
use crate::error::StorageError;
use serial_test::serial;
use uuid::Uuid;
@@ -378,6 +653,383 @@ mod tests {
);
}
async fn isolated_durable_quota_store() -> (Vec<tempfile::TempDir>, Arc<crate::store::ECStore>, Arc<RwLock<BucketMetadataSys>>)
{
let (dirs, store) = isolated_store_over_temp_disks().await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let sys = crate::bucket::metadata_sys::bucket_metadata_sys_of(&store.ctx).expect("scoped metadata system");
(dirs, store, sys)
}
async fn create_bucket_with_quota_json(store: &Arc<crate::store::ECStore>, bucket: &str, quota_json: Vec<u8>) {
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create quota test bucket");
store
.update_bucket_metadata_config(bucket, rustfs_config::QUOTA_CONFIG_FILE, quota_json)
.await
.expect("persist quota config");
}
/// Regression (rustfs/backlog#2904, decision D3 in rustfs/backlog#2872): a
/// bucket whose quota is admitted by the durable reservation ledger must
/// not fail its write because scanner usage is unavailable. The checker
/// reports `current_usage = None` instead of `UsageUnavailable`, while the
/// ledger still rejects an over-limit write and a bucket without a
/// reservation protocol keeps failing closed.
#[tokio::test]
#[serial]
async fn durable_quota_check_reports_unknown_usage_without_failing_the_write() {
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::storage_api_contracts::object::ObjectIO as _;
let (_dirs, store, sys) = isolated_durable_quota_store().await;
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = format!("quota-durable-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(
&store,
&bucket,
serde_json::to_vec(&BucketQuota::new_sharded(4096)).expect("sharded quota should encode"),
)
.await;
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
let result = checker
.check_quota(&bucket, QuotaOperation::PutObject, 512)
.await
.expect("a ledger-admitted bucket must not fail the check without scanner usage");
assert!(result.allowed);
assert!(result.uses_durable_reservations);
assert_eq!(result.quota_limit, Some(4096));
assert_eq!(result.current_usage, None, "unknown usage is reported as absent, never as zero");
assert_eq!(result.remaining, None);
let (_, usage) = checker
.get_quota_stats(&bucket)
.await
.expect("stats for a ledger-admitted bucket degrade to an absent usage");
assert_eq!(usage, None);
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
0,
"reporting a ledger-admitted bucket's usage must not wait for an early snapshot reload"
);
// Negative control: the ledger, not the scanner, still refuses an
// over-limit write with the same error.
let mut too_large = PutObjReader::from_vec(vec![0x5a; 8192]);
let err = store
.put_object(&bucket, "too-large", &mut too_large, &ObjectOptions::default())
.await
.expect_err("the ledger must reject a write above the quota limit");
assert!(matches!(err, StorageError::QuotaExceeded { .. }), "{err:?}");
let mut fits = PutObjReader::from_vec(vec![0x5a; 512]);
store
.put_object(&bucket, "fits", &mut fits, &ObjectOptions::default())
.await
.expect("a write within the quota limit commits through the ledger");
// A bucket without a reservation protocol keeps the fail-closed path.
let legacy = format!("quota-legacy-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &legacy, br#"{"quota":4096,"quota_type":"Hard"}"#.to_vec()).await;
assert!(matches!(
checker.check_quota(&legacy, QuotaOperation::PutObject, 512).await,
Err(QuotaError::UsageUnavailable { .. })
));
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy),
1,
"fail-closed admission takes the early-reload lookup"
);
assert!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
assert_eq!(crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&legacy), 2);
assert_eq!(
crate::data_usage::reload_unknown_bucket_usage_calls_for_test(&bucket),
0,
"ledger-admitted writes never take the early-reload lookup"
);
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
.expect("test usage cache cleanup should succeed");
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
}
fn legacy_quota_json(limit: u64) -> Vec<u8> {
format!(r#"{{"quota":{limit},"quota_type":"Hard","created_at":"2024-01-01T00:00:00Z"}}"#).into_bytes()
}
async fn persisted_quota(store: &Arc<crate::store::ECStore>, bucket: &str) -> BucketQuota {
let (quota, _, _) = crate::bucket::metadata_sys::get_quota_config_and_incarnation_from_disk_in(&store.ctx, bucket)
.await
.expect("quota metadata should load from disk");
quota.expect("a quota should be persisted")
}
/// rustfs/backlog#2904: a quota saved before the reservation protocols is
/// re-saved under the sharded durable protocol with the same limit, type
/// and audit timestamp; durable and unknown protocols are left alone.
#[tokio::test]
#[serial]
async fn legacy_quota_migrates_to_sharded_reservation_protocol_once() {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = format!("quota-migrate-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
let outcome = checker
.migrate_reservation_protocol(&bucket)
.await
.expect("migration should succeed");
assert!(
matches!(
outcome,
QuotaProtocolMigration::Migrated {
reservation_protocol: QUOTA_RESERVATION_PROTOCOL_V2,
..
}
),
"{outcome:?}"
);
let migrated = persisted_quota(&store, &bucket).await;
assert!(migrated.uses_sharded_reservations());
assert_eq!(migrated.quota, Some(4096));
assert_eq!(migrated.quota_type, QuotaType::Hard);
assert_eq!(migrated.created_at.map(|at| at.unix_timestamp()), Some(1_704_067_200));
assert_eq!(
checker.get_quota_config(&bucket).await.expect("cached quota should load"),
migrated,
"the rewrite publishes into the registered cache"
);
assert_eq!(
checker
.migrate_reservation_protocol(&bucket)
.await
.expect("second attempt should succeed"),
QuotaProtocolMigration::AlreadyDurable {
reservation_protocol: QUOTA_RESERVATION_PROTOCOL_V2
}
);
let no_quota = format!("quota-none-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(
&store,
&no_quota,
serde_json::to_vec(&BucketQuota::new(None)).expect("empty quota should encode"),
)
.await;
assert_eq!(
checker
.migrate_reservation_protocol(&no_quota)
.await
.expect("no quota is a no-op"),
QuotaProtocolMigration::NoQuota
);
let unsupported = format!("quota-unsupported-{}", Uuid::new_v4().simple());
let unsupported_json = br#"{"quota":0,"quota_type":"Hard","reservation_protocol":3,"reservation_quota":2048}"#.to_vec();
create_bucket_with_quota_json(&store, &unsupported, unsupported_json.clone()).await;
assert_eq!(
checker
.migrate_reservation_protocol(&unsupported)
.await
.expect("an unknown protocol is skipped, never rewritten"),
QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::UnsupportedReservationProtocol
}
);
let (raw, _) = sys.read().await.get_config(&unsupported).await.expect("cached metadata");
assert_eq!(raw.quota_config_json, unsupported_json);
}
/// The guarded save refuses a replaced bucket and a quota edited since the
/// cached read; the migration reports the latter as a skip and leaves the
/// newer configuration for the next attempt.
#[tokio::test]
#[serial]
async fn quota_protocol_migration_requires_matching_incarnation_and_revision() {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = format!("quota-cas-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
let (meta, _) = sys.read().await.get_config(&bucket).await.expect("cached metadata");
let proof = crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().expect("fleet proof is installed");
let data = serde_json::to_vec(&BucketQuota::new_sharded(4096)).expect("sharded quota should encode");
let err = update_quota_with_sys_if_incarnation_and_revision(
Arc::clone(&sys),
&bucket,
data.clone(),
Uuid::new_v4(),
meta.quota_config_updated_at,
&proof,
)
.await
.expect_err("a different incarnation must not be rewritten");
assert!(matches!(err, StorageError::BucketNotFound(_)), "{err:?}");
let err = update_quota_with_sys_if_incarnation_and_revision(
Arc::clone(&sys),
&bucket,
data,
meta.bucket_incarnation_id,
meta.quota_config_updated_at + time::Duration::seconds(1),
&proof,
)
.await
.expect_err("a different quota revision must not be rewritten");
assert!(matches!(err, StorageError::PreconditionFailed), "{err:?}");
assert_eq!(persisted_quota(&store, &bucket).await.quota, Some(4096));
assert!(persisted_quota(&store, &bucket).await.needs_reservation_protocol_migration());
// Model a peer whose cache missed a newer save: the migration reads
// the stale entry, the guarded write sees the newer revision, and the
// newer configuration stays in place.
let (stale, _) = sys.read().await.get_config(&bucket).await.expect("cached metadata");
store
.update_bucket_metadata_config(&bucket, QUOTA_CONFIG_FILE, legacy_quota_json(8192))
.await
.expect("newer quota save");
sys.read().await.set(bucket.clone(), stale).await;
assert_eq!(
checker
.migrate_reservation_protocol(&bucket)
.await
.expect("a stale read is a skip, not a failure"),
QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::QuotaRevisionChanged
}
);
let on_disk = persisted_quota(&store, &bucket).await;
assert_eq!(on_disk.quota, Some(8192));
assert!(
on_disk.needs_reservation_protocol_migration(),
"the newer save is left for the next attempt"
);
}
/// The first quota read through the instance's registered metadata
/// system migrates the bucket in the background and still answers with
/// the configuration it found; a checker over an ad-hoc system only reads.
#[tokio::test]
#[serial]
async fn lazy_quota_protocol_migration_runs_from_the_quota_read_path() {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = format!("quota-lazy-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
let first = checker.get_quota_config(&bucket).await.expect("legacy quota should load");
assert!(
first.needs_reservation_protocol_migration(),
"the triggering read answers with what it found"
);
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let on_disk = persisted_quota(&store, &bucket).await;
if on_disk.uses_sharded_reservations() {
assert_eq!(on_disk.quota, Some(4096));
break;
}
assert!(Instant::now() < deadline, "lazy migration did not land");
tokio::time::sleep(Duration::from_millis(50)).await;
}
let settled = Instant::now() + Duration::from_secs(10);
while lazy_migration_attempt_pending(&bucket) {
assert!(Instant::now() < settled, "a migrated bucket must leave the retry map");
tokio::time::sleep(Duration::from_millis(20)).await;
}
let ad_hoc = QuotaChecker::new(Arc::new(RwLock::new(BucketMetadataSys::new(Arc::clone(&store)))));
let other = format!("quota-adhoc-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &other, legacy_quota_json(4096)).await;
assert!(
ad_hoc
.get_quota_config(&other)
.await
.expect("legacy quota should load")
.needs_reservation_protocol_migration()
);
assert!(
!lazy_migration_attempt_pending(&other),
"an ad-hoc metadata system never schedules a migration"
);
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(persisted_quota(&store, &other).await.needs_reservation_protocol_migration());
}
/// Migration while writes are in flight: objects committed under the
/// snapshot protocol before and around the rewrite are accounted exactly
/// once by the sharded ledger's exact bootstrap, so the limit holds to
/// the byte afterwards. (Under `cfg(test)` a handler-supplied snapshot
/// admission short-circuits `begin`, so every concurrent write here takes
/// the snapshot path; the bootstrap accounting is what is verified.)
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn concurrent_puts_across_protocol_migration_are_accounted_exactly_once() {
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::storage_api_contracts::object::ObjectIO as _;
const OBJECTS: u64 = 8;
const SIZE: u64 = 256;
const SLACK: u64 = 64;
const LIMIT: u64 = OBJECTS * SIZE + SLACK;
let (_dirs, store, sys) = isolated_durable_quota_store().await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let bucket = format!("quota-race-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(LIMIT)).await;
let mut writers = Vec::with_capacity(OBJECTS as usize);
for index in 0..OBJECTS {
let store = Arc::clone(&store);
let bucket = bucket.clone();
writers.push(tokio::spawn(async move {
// The admission a request handler supplies on the snapshot
// protocol; ignored once the ledger owns admission.
let mut opts = ObjectOptions::default();
assert!(opts.set_quota_admission(0, LIMIT));
let mut body = PutObjReader::from_vec(vec![0x5a; SIZE as usize]);
store.put_object(&bucket, &format!("object-{index}"), &mut body, &opts).await
}));
}
let migration = {
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = bucket.clone();
tokio::spawn(async move { checker.migrate_reservation_protocol(&bucket).await })
};
for writer in writers {
writer
.await
.expect("writer task should not panic")
.expect("every write fits the limit");
}
let outcome = migration
.await
.expect("migration task should not panic")
.expect("migration should succeed");
assert!(matches!(outcome, QuotaProtocolMigration::Migrated { .. }), "{outcome:?}");
let mut over = PutObjReader::from_vec(vec![0x5a; (SLACK + 1) as usize]);
let err = store
.put_object(&bucket, "over", &mut over, &ObjectOptions::default())
.await
.expect_err("one byte above the remaining quota must be rejected");
assert!(matches!(err, StorageError::QuotaExceeded { .. }), "{err:?}");
let mut exact = PutObjReader::from_vec(vec![0x5a; SLACK as usize]);
store
.put_object(&bucket, "exact", &mut exact, &ObjectOptions::default())
.await
.expect("exactly the remaining bytes fit");
}
#[tokio::test]
async fn test_quota_check_no_limit() {
let result = QuotaCheckResult {
+59
View File
@@ -164,6 +164,26 @@ impl BucketQuota {
.is_some_and(|version| !matches!(version, QUOTA_RESERVATION_PROTOCOL_V1 | QUOTA_RESERVATION_PROTOCOL_V2))
}
/// A limit saved before the reservation protocols existed: its writes
/// are still admitted from scanner usage snapshots. Durable and unknown
/// future protocols are never rewritten.
pub fn needs_reservation_protocol_migration(&self) -> bool {
self.quota.is_some() && self.reservation_protocol.is_none()
}
/// The same limit and type re-expressed under the sharded durable
/// reservation protocol; `created_at` is kept for audit continuity.
/// `None` when the quota is not a migration candidate.
pub fn migrated_to_sharded_reservations(&self) -> Option<Self> {
self.needs_reservation_protocol_migration().then(|| Self {
quota: self.quota,
quota_type: self.quota_type.clone(),
reservation_protocol: Some(QUOTA_RESERVATION_PROTOCOL_V2),
created_at: self.created_at,
updated_at: self.updated_at,
})
}
pub fn check_operation_allowed(&self, current_usage: u64, operation_size: u64) -> bool {
if operation_size == 0 {
return true;
@@ -200,6 +220,45 @@ pub enum QuotaOperation {
DeleteObject,
}
/// Outcome of re-saving one bucket's quota under the sharded durable
/// reservation protocol (rustfs/backlog#2904).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QuotaProtocolMigration {
/// No quota limit is configured; nothing to migrate.
NoQuota,
/// The quota already uses a durable reservation protocol.
AlreadyDurable { reservation_protocol: u32 },
/// The quota was re-saved under the sharded protocol at this revision.
Migrated {
reservation_protocol: u32,
updated_at: OffsetDateTime,
},
/// The quota was left untouched for the stated reason.
Skipped { reason: QuotaMigrationSkipReason },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuotaMigrationSkipReason {
/// A reservation protocol this node does not understand; rewriting it
/// would downgrade a newer fleet's quota.
UnsupportedReservationProtocol,
/// The quota configuration changed between the cached read and the
/// guarded write; the newer configuration wins.
QuotaRevisionChanged,
/// The bucket was replaced or removed since the cached read.
BucketIncarnationChanged,
}
impl QuotaMigrationSkipReason {
pub fn as_str(self) -> &'static str {
match self {
Self::UnsupportedReservationProtocol => "unsupported_reservation_protocol",
Self::QuotaRevisionChanged => "quota_revision_changed",
Self::BucketIncarnationChanged => "bucket_incarnation_changed",
}
}
}
#[derive(Debug, Error)]
pub enum QuotaError {
#[error("Bucket quota exceeded: current={current}, limit={limit}, operation={operation}")]
+6 -51
View File
@@ -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?;
}
@@ -864,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,
@@ -883,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,
@@ -902,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,
@@ -994,19 +967,9 @@ pub(crate) async fn begin(
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_POLICY_EVALUATE,
policy_evaluate_started,
);
let capability_proof_started = rustfs_io_metrics::put_stage_timer();
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_CAPABILITY_PROOF,
capability_proof_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);
@@ -1067,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),
@@ -1085,16 +1047,6 @@ fn quota_cache_reason(quota: Option<&crate::bucket::quota::BucketQuota>) -> &'st
}
}
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,
}
}
pub(crate) async fn replaced_logical_size(set_disks: &SetDisks, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<u64> {
if opts.versioned && !opts.version_suspended && opts.version_id.is_none() {
return Ok(0);
@@ -2507,7 +2459,10 @@ mod tests {
}
}
// Its writes read the process-wide usage snapshot cache through the
// degraded-baseline fallback, which the data-usage cache tests rewrite.
#[tokio::test]
#[serial]
async fn sharded_reservation_reloads_allocator_after_concurrent_refill() {
use crate::object_api::PutObjReader;
use crate::storage_api_contracts::bucket::{BucketOperations, MakeBucketOptions};
@@ -84,6 +84,8 @@ pub use replication_queue_boundary::{
};
pub use replication_resync_boundary::ResyncStatusType;
pub use replication_resync_boundary::{BucketReplicationResyncStatus, ResyncOpts, TargetReplicationResyncStatus};
#[cfg(test)]
pub(crate) use replication_resyncer::read_source_delete_marker;
pub use replication_scanner_bridge::ReplicationScannerBridge;
pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
@@ -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";
@@ -5199,6 +5304,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;
+222 -127
View File
@@ -30,6 +30,7 @@ use rustfs_gateway_types::persistence::{
serialize_object_lock, serialize_public_access_block, serialize_replication, serialize_request_payment, serialize_versioning,
serialize_website,
};
use rustfs_utils::WindowsFileNameError;
use rustfs_utils::path::SLASH_SEPARATOR;
use tracing::instrument;
@@ -328,8 +329,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.
@@ -337,37 +337,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<()> {
@@ -379,33 +388,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(())
@@ -466,10 +475,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(())
}
@@ -770,103 +775,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]
+273 -15
View File
@@ -372,7 +372,7 @@ async fn list_path_raw_inner(
let cancel_rx_clone = cancel_rx.clone();
let producer_errs_clone = producer_errs.clone();
let disk_io_context_clone = disk_io_context.clone();
let (rd, wr) = tokio::io::duplex(64);
let (rd, wr) = tokio::io::duplex(64 * 1024);
readers.push(MetacacheReader::new(rd));
jobs.spawn(with_optional_disk_io_context(disk_io_context_clone, async move {
let mut wr = PublishedBytesWriter::new(wr);
@@ -702,6 +702,7 @@ async fn list_path_raw_inner(
loop {
let mut current = MetaCacheEntry::default();
let mut current_idx: Option<usize> = None;
// warn!(
// "list_path_raw: loop start, bucket: {}, path: {}, current: {:?}",
@@ -840,30 +841,32 @@ async fn list_path_raw_inner(
// If no current, add it.
if current.name.is_empty() {
top_entries[i] = Some(entry.clone());
current_idx = Some(i);
current = entry;
agree += 1;
continue;
}
// If exact match, we agree.
if let (_, true) = current.matches(Some(&entry), true) {
top_entries[i] = Some(entry);
agree += 1;
continue;
}
// If only the name matches we didn't agree, but add it for resolution.
if entry.name == current.name {
top_entries[i] = Some(entry);
continue;
}
// We got different entries
if entry.name > current.name {
pending_entries[i] = Some(entry);
continue;
}
if entry.name == current.name {
// If exact match, we agree.
if current.matches_strict(&entry) {
top_entries[i] = Some(entry);
agree += 1;
} else {
// If only the name matches we didn't agree, but add it for resolution.
top_entries[i] = Some(entry);
}
continue;
}
if let Some(idx) = current_idx.take() {
pending_entries[idx] = Some(std::mem::take(&mut current));
}
for (idx, item) in top_entries.iter_mut().enumerate().take(i) {
if let Some(entry) = item.take() {
pending_entries[idx] = Some(entry);
@@ -871,7 +874,7 @@ async fn list_path_raw_inner(
}
agree = 1;
top_entries[i] = Some(entry.clone());
current_idx = Some(i);
current = entry;
}
@@ -977,6 +980,10 @@ async fn list_path_raw_inner(
// warn!("list_path_raw: skip start, current: {:?}", &current.name);
if let Some(idx) = current_idx.take() {
top_entries[idx] = Some(current);
}
for (i, r) in readers.iter_mut().enumerate() {
if top_entries[i].is_some() {
let _ = r.skip(1).await;
@@ -2180,4 +2187,255 @@ mod tests {
assert_eq!(err, DiskError::FileAccessDenied);
}
#[tokio::test]
async fn list_path_raw_demotes_current_and_agreed_peers_across_cascading_smaller_keys() {
fn make_entry(name: &str, vid: u128, etag: &str) -> MetaCacheEntry {
let mut meta = FileMeta::default();
let mut fi = FileInfo::new(name, 2, 2);
fi.version_id = Some(Uuid::from_u128(vid));
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
fi.metadata.insert("etag".to_string(), etag.to_string());
meta.add_version(fi).expect("add version");
MetaCacheEntry {
name: name.to_string(),
metadata: meta.marshal_msg().expect("marshal metadata"),
cached: None,
reusable: false,
}
}
// Round 1: Disks 0 and 1 BOTH start at "key-d" and strictly agree, populating `current_idx = Some(0)`
// and `top_entries[1] = Some(key-d)`. Disk 2 then yields "key-b" (< "key-d"), forcing demotion of
// BOTH `current_idx` (`pending_entries[0]`) AND `top_entries[1]` (`pending_entries[1]`).
// Disk 3 then yields "key-a" (< "key-b"), forcing a second cascading demotion of Disk 2 to `pending_entries[2]`.
// Later at "key-e", all 4 disks present the same name, version_id, and mod_time, but Disk 2 has a different
// ETag ("etag-e2" vs "etag-e1"), proving `matches_strict` routes all 4 replicas to `partial` instead of `agreed`.
let k_a = make_entry("key-a", 1, "etag-a");
let k_b = make_entry("key-b", 2, "etag-b");
let k_d = make_entry("key-d", 4, "etag-d");
let k_e_v1 = make_entry("key-e", 5, "etag-e1");
let k_e_v2 = make_entry("key-e", 5, "etag-e2");
let disk0 = vec![k_d.clone(), k_e_v1.clone()];
let disk1 = vec![k_d.clone(), k_e_v1.clone()];
let disk2 = vec![k_b.clone(), k_d.clone(), k_e_v2];
let disk3 = vec![k_a, k_b, k_d, k_e_v1];
let events = Arc::new(Mutex::new(Vec::new()));
let agreed_events = events.clone();
let partial_events = events.clone();
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None, None, None, None],
min_disks: 1,
test_reader_behaviors: vec![
TestReaderBehavior::Entries(disk0),
TestReaderBehavior::Entries(disk1),
TestReaderBehavior::Entries(disk2),
TestReaderBehavior::Entries(disk3),
],
agreed: Some(Box::new(move |entry| {
let agreed_events = agreed_events.clone();
Box::pin(async move {
agreed_events.lock().expect("events lock").push((entry.name, 4usize, true));
})
})),
partial: Some(Box::new(move |entries, _| {
let partial_events = partial_events.clone();
Box::pin(async move {
let present = entries.0.iter().flatten().count();
let name = entries
.0
.iter()
.flatten()
.next()
.expect("at least one entry in partial")
.name
.clone();
partial_events.lock().expect("events lock").push((name, present, false));
})
})),
..Default::default()
},
)
.await
.expect("cascading demotion listing should succeed");
assert_eq!(
events.lock().expect("events lock").as_slice(),
&[
("key-a".to_string(), 1, false),
("key-b".to_string(), 2, false),
("key-d".to_string(), 4, true),
("key-e".to_string(), 4, false),
],
"cascading demotions must preserve pending_entries from both current_idx and top_entries, and 4-disk ETag divergence must take partial"
);
}
#[tokio::test]
async fn list_path_raw_seeded_multi_disk_streams_match_reference_oracle() {
type Step = (String, Vec<Option<String>>, bool);
fn make_entry(name: &str, variant: u64) -> MetaCacheEntry {
if name.ends_with('/') {
return MetaCacheEntry {
name: name.to_string(),
metadata: Vec::new(),
cached: None,
reusable: false,
};
}
let mut meta = FileMeta::default();
let mut fi = FileInfo::new(name, 2, 2);
fi.version_id = Some(Uuid::from_u128(1));
fi.mod_time = Some(OffsetDateTime::from_unix_timestamp(1_705_312_300).expect("valid timestamp"));
fi.metadata.insert("etag".to_string(), format!("etag-{variant}"));
meta.add_version(fi).expect("add version");
MetaCacheEntry {
name: name.to_string(),
metadata: meta.marshal_msg().expect("marshal metadata"),
cached: None,
reusable: false,
}
}
fn describe_entry(entry: &MetaCacheEntry) -> String {
if entry.is_dir() {
return format!("{}:0", entry.name);
}
let fi = entry.to_fileinfo("bucket").expect("decode test entry");
let etag = fi.metadata.get("etag").expect("etag present");
let v = etag.strip_prefix("etag-").expect("etag prefix");
format!("{}:{v}", entry.name)
}
let universe = ["a", "a/", "a/b", "a/b/", "a/c", "a0", "b", "b/", "b/1", "c", "d/", "e"];
// Deterministic LCG seed using high bits across 24 random 4-disk stream schedules.
let mut seed = 0x5eed_cafe_1234_5678_u64;
let mut next_rand = || {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
seed >> 33
};
let mut total_agreed = 0usize;
let mut total_partial = 0usize;
let mut total_preserved_all_four = 0usize;
for round in 0..24 {
let preserve_replica_metadata = round % 3 == 0;
let mut disk_streams: Vec<Vec<MetaCacheEntry>> = vec![Vec::new(); 4];
let mut expected_steps: Vec<Step> = Vec::new();
for &key in &universe {
// High-bit mask in 1..=15; force mask 15 on every 4th key so 4-disk agreement/divergence is heavily exercised.
let mask = if next_rand() % 4 == 0 {
0b1111_u64
} else {
(next_rand() % 15) + 1
};
let mut slot_variants: Vec<Option<u64>> = Vec::with_capacity(4);
let mut first_variant = None;
let mut all_present_and_equal = true;
for (disk_idx, stream) in disk_streams.iter_mut().enumerate() {
if (mask & (1_u64 << disk_idx)) != 0 {
// Directory entries carry no metadata, so their variant is always 0.
let v = if key.ends_with('/') {
first_variant = Some(0);
0
} else if first_variant.is_none() || (next_rand() % 4 != 0) {
let v = first_variant.unwrap_or_else(|| next_rand() % 3);
first_variant = Some(v);
v
} else {
all_present_and_equal = false;
(first_variant.unwrap_or(0) + 1) % 3
};
stream.push(make_entry(key, v));
slot_variants.push(Some(v));
} else {
all_present_and_equal = false;
slot_variants.push(None);
}
}
let expect_agreed = all_present_and_equal && !preserve_replica_metadata;
if expect_agreed {
total_agreed += 1;
} else {
total_partial += 1;
if all_present_and_equal && preserve_replica_metadata {
total_preserved_all_four += 1;
}
}
let expected_slots = slot_variants
.into_iter()
.map(|opt| opt.map(|v| format!("{key}:{v}")))
.collect();
expected_steps.push((key.to_string(), expected_slots, expect_agreed));
}
let observed: Arc<Mutex<Vec<Step>>> = Arc::new(Mutex::new(Vec::new()));
let agreed_obs = observed.clone();
let partial_obs = observed.clone();
list_path_raw(
CancellationToken::new(),
ListPathRawOptions {
disks: vec![None; 4],
min_disks: 1,
preserve_replica_metadata,
test_reader_behaviors: disk_streams.into_iter().map(TestReaderBehavior::Entries).collect(),
agreed: Some(Box::new(move |entry| {
let agreed_obs = agreed_obs.clone();
let desc = describe_entry(&entry);
let name = entry.name;
Box::pin(async move {
agreed_obs.lock().expect("obs lock").push((name, vec![Some(desc); 4], true));
})
})),
partial: Some(Box::new(move |entries, _| {
let partial_obs = partial_obs.clone();
let name = entries
.0
.iter()
.flatten()
.next()
.expect("partial must contain at least one entry")
.name
.clone();
let slots = entries
.0
.iter()
.map(|opt| opt.as_ref().map(describe_entry))
.collect::<Vec<_>>();
Box::pin(async move {
partial_obs.lock().expect("obs lock").push((name, slots, false));
})
})),
..Default::default()
},
)
.await
.expect("seeded list_path_raw must succeed");
assert_eq!(
*observed.lock().expect("obs lock"),
expected_steps,
"round={round}, preserve_replica_metadata={preserve_replica_metadata}"
);
}
assert!(total_agreed > 0, "seeded schedule must exercise agreed fast-path");
assert!(total_partial > 0, "seeded schedule must exercise partial path");
assert!(
total_preserved_all_four > 0,
"seeded schedule must exercise preserve_replica_metadata on 4-disk agreement"
);
}
}
+3 -1
View File
@@ -356,7 +356,9 @@ 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/RenameData"
| "/node_service.NodeService/RenameDataAtIncarnation"
| "/node_service.NodeService/RenameDataWithQuotaFence"
)
}
@@ -48,14 +48,15 @@ use rustfs_protos::proto_gen::node_service::{
GetPartitionsRequest, GetProcInfoRequest, GetSeLinuxInfoRequest, GetSysConfigRequest, GetSysErrorsRequest,
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
LocalStorageInfoRequest, Mss, PingRequest, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest,
ReplacementRecoveryStatusRequest, ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageSnapshotRequest,
ScannerDirtyUsageSnapshotResponse, ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest,
ScannerPublicationLeaseResponse, ScannerScopedDirtyUsageAckRequest, ScannerScopedDirtyUsageAckResponse,
ScannerScopedDirtyUsageEntry, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
StartProfilingRequest, StopRebalanceRequest, TierDailyStatsRequest, TierMutationAbortRequest, TierMutationCommitRequest,
TierMutationControlResponse, TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest,
node_service_client::NodeServiceClient, tier_mutation_control_service_client::TierMutationControlServiceClient,
LocalStorageInfoRequest, Mss, PeerDriveAdmissionRequest, PingRequest, ReloadPoolMetaRequest,
ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest, ScannerActivityRequest, ScannerActivityResponse,
ScannerDirtyUsageSnapshotRequest, ScannerDirtyUsageSnapshotResponse, ScannerPublicationLeaseReleaseRequest,
ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse, ScannerScopedDirtyUsageAckRequest,
ScannerScopedDirtyUsageAckResponse, ScannerScopedDirtyUsageEntry, ServerInfoRequest, SignalServiceRequest,
SignalServiceResponse, StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest, TierDailyStatsRequest,
TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse, TierMutationFailureClass,
TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
tier_mutation_control_service_client::TierMutationControlServiceClient,
};
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
use rustfs_protos::{TierMutationRpcPhase, evict_failed_connection};
@@ -89,6 +90,7 @@ pub const SERVICE_SIGNAL_RELOAD_DYNAMIC: u64 = 2;
pub const KMS_SIGNAL_SUBSYSTEM: &str = "kms";
const BACKGROUND_HEAL_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
const REPLACEMENT_RECOVERY_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
const PEER_DRIVE_ADMISSION_MAX_MESSAGE_SIZE: usize = 256 * 1024;
const HEAL_CONTROL_FINGERPRINT_MAX_SIZE: usize = 256;
const HEAL_CONTROL_PAYLOAD_MAX_SIZE: usize = 64 * 1024;
const PEER_REST_RECOVERY_MAX_ATTEMPTS: u32 = 60;
@@ -711,6 +713,16 @@ fn scanner_publication_lease_deadline(
.ok_or_else(|| Error::other("scanner publication lease deadline overflowed"))
}
/// A peer's readiness view of one drive served by another host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PeerDriveAdmissionView {
pub pool_index: i32,
pub set_index: i32,
pub disk_index: i32,
/// True when the peer counts the drive towards its own read quorum.
pub online: bool,
}
#[derive(Clone, Debug)]
pub struct PeerRestClient {
pub host: XHost,
@@ -864,6 +876,11 @@ impl PeerRestClient {
)
}
/// Topology `host:port` this client talks to, as written in the endpoint list.
pub fn topology_member(&self) -> &str {
&self.topology_member
}
pub fn new(host: XHost, grid_host: String) -> Self {
let topology_member = host.to_string();
Self {
@@ -1677,6 +1694,51 @@ impl PeerRestClient {
.await
}
/// Ask the peer how it currently sees the drives served by `host`.
///
/// `Ok(None)` means the peer predates the RPC and cannot answer.
pub async fn peer_drive_admission(&self, host: &str) -> Result<Option<Vec<PeerDriveAdmissionView>>> {
self.finalize_result(
async {
let mut client = self
.get_client()
.await?
.max_decoding_message_size(PEER_DRIVE_ADMISSION_MAX_MESSAGE_SIZE);
let response = match client
.peer_drive_admission(Request::new(PeerDriveAdmissionRequest { host: host.to_string() }))
.await
{
Ok(response) => response.into_inner(),
Err(status) if status.code() == tonic::Code::Unimplemented => {
// RUSTFS_COMPAT_TODO(peer-drive-admission-v1): old peers cannot report how they see a restarting node, so its startup re-admission gate skips them. Remove after the minimum supported RustFS peer version implements PeerDriveAdmission.
return Ok(None);
}
Err(status) => return Err(status.into()),
};
if !response.success {
return Err(match (response.error_code, response.error_info) {
(None, None) => Error::other("peer drive admission failed without an error"),
(error_code, error_info) => control_plane_failure("peer_drive_admission", None, error_code, error_info),
});
}
Ok(Some(
response
.drives
.into_iter()
.map(|drive| PeerDriveAdmissionView {
pool_index: drive.pool_index,
set_index: drive.set_index,
disk_index: drive.disk_index,
online: drive.online,
})
.collect(),
))
}
.await,
)
.await
}
pub async fn prepare_tier_mutation(&self, mutation_id: Uuid, canonical_payload: Bytes) -> Result<PeerTierMutationOutcome> {
self.tier_mutation_control(TierMutationRpcPhase::Prepare, mutation_id, canonical_payload)
.await
+483 -55
View File
@@ -54,6 +54,7 @@ use rustfs_io_metrics::internode_metrics::{
INTERNODE_STAGE_BATCH_READ_VERSION_RPC_ROUNDTRIP, INTERNODE_STAGE_READ_VERSION_REQUEST_ENCODE,
INTERNODE_STAGE_READ_VERSION_RESPONSE_DECODE, INTERNODE_STAGE_READ_VERSION_RPC_ROUNDTRIP,
};
use rustfs_io_metrics::walk_metrics::RemoteWalkGuard;
use rustfs_protos::ChannelClass;
use rustfs_protos::evict_failed_connection;
use rustfs_protos::proto_gen::node_service::RenamePartRequest;
@@ -218,6 +219,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()
@@ -288,23 +307,60 @@ fn record_batch_read_version_gate_decision(mode: BatchMetadataRpcMode, decision:
.increment(1);
}
async fn copy_stream_with_buffer<R, W>(reader: &mut R, writer: &mut W, buffer_size: usize) -> io::Result<u64>
struct ObservedWalkWriter<'a, W> {
inner: &'a mut W,
observation: &'a mut RemoteWalkGuard,
}
impl<W: AsyncWrite + Unpin> AsyncWrite for ObservedWalkWriter<'_, W> {
fn poll_write(mut self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
match Pin::new(&mut *self.inner).poll_write(cx, buf) {
Poll::Ready(Ok(written)) => {
self.observation.written(written);
Poll::Ready(Ok(written))
}
result => result,
}
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut *self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut *self.inner).poll_shutdown(cx)
}
}
async fn copy_stream_with_buffer<R, W>(
reader: &mut R,
writer: &mut W,
buffer_size: usize,
observation: &mut RemoteWalkGuard,
) -> io::Result<u64>
where
R: AsyncRead + Unpin,
W: AsyncWrite + Unpin,
{
let mut copied = 0_u64;
let mut buffer = vec![0_u8; buffer_size];
let _scratch = observation.scratch_allocated(buffer.capacity());
loop {
let bytes_read = reader.read(&mut buffer).await?;
observation.read(bytes_read);
if bytes_read == 0 {
writer.flush().await?;
return Ok(copied);
}
writer.write_all(&buffer[..bytes_read]).await?;
copied += bytes_read as u64;
ObservedWalkWriter {
inner: writer,
observation,
}
.write_all(&buffer[..bytes_read])
.await?;
copied += u64::try_from(bytes_read).unwrap_or(u64::MAX);
}
}
@@ -947,6 +1003,16 @@ impl RemoteDisk {
.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,
@@ -2348,7 +2414,13 @@ impl RemoteDisk {
});
let canonical_body = 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)?;
@@ -2363,7 +2435,10 @@ impl RemoteDisk {
let rpc_started = rustfs_io_metrics::put_stage_timer();
let response = rustfs_io_metrics::observe_put_stage_future(
async {
if incarnation_bound {
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 {
@@ -3036,8 +3111,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(),
@@ -3078,8 +3160,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());
}
@@ -3548,8 +3637,15 @@ impl DiskAPI for RemoteDisk {
"Remote disk RPC started"
);
let mut observation = RemoteWalkGuard::start();
let disk = self.disk_ref().await;
let body = serde_json::to_vec(&opts)?;
let body = match serde_json::to_vec(&opts) {
Ok(body) => body,
Err(err) => {
observation.finish(false);
return Err(err.into());
}
};
let stall_timeout = opts.stall_timeout_duration().unwrap_or_else(get_drive_walkdir_stall_timeout);
let bucket = opts.bucket.clone();
let base_dir = opts.base_dir.clone();
@@ -3560,56 +3656,59 @@ impl DiskAPI for RemoteDisk {
opts.timeout_duration().unwrap_or_else(get_drive_walkdir_timeout)
};
self.execute_with_timeout_for_op_and_health_action(
"walk_dir",
|| async {
let mut last_err = None;
let result = self
.execute_with_timeout_for_op_and_health_action(
"walk_dir",
|| async {
let mut last_err = None;
for attempt in 1..=2 {
let mut reader = match self
.data_transport
.open_walk_dir(WalkDirStreamRequest {
endpoint: self.endpoint.grid_host(),
disk: disk.clone(),
body: body.clone(),
stall_timeout: Some(stall_timeout),
})
.await
{
Ok(reader) => reader,
Err(err) => {
if attempt == 1 && Self::is_retryable_walk_dir_error(&err) {
warn!(
endpoint = %self.endpoint,
addr = %self.addr,
disk = %disk_for_log,
bucket = %bucket,
base_dir = %base_dir,
attempt,
stall_timeout_ms = stall_timeout.as_millis(),
error = %err,
"remote walk_dir returned retryable transport error; retrying"
);
last_err = Some(err);
continue;
for attempt in 1..=2 {
let mut reader = match self
.data_transport
.open_walk_dir(WalkDirStreamRequest {
endpoint: self.endpoint.grid_host(),
disk: disk.clone(),
body: body.clone(),
stall_timeout: Some(stall_timeout),
})
.await
{
Ok(reader) => reader,
Err(err) => {
if attempt == 1 && Self::is_retryable_walk_dir_error(&err) {
warn!(
endpoint = %self.endpoint,
addr = %self.addr,
disk = %disk_for_log,
bucket = %bucket,
base_dir = %base_dir,
attempt,
stall_timeout_ms = stall_timeout.as_millis(),
error = %err,
"remote walk_dir returned retryable transport error; retrying"
);
last_err = Some(err);
continue;
}
return Err(err);
}
};
return Err(err);
match copy_stream_with_buffer(&mut reader, wr, DEFAULT_READ_BUFFER_SIZE, &mut observation).await {
Ok(_) => return Ok(()),
Err(io_err) => return Err(DiskError::Io(io_err)),
}
};
match copy_stream_with_buffer(&mut reader, wr, DEFAULT_READ_BUFFER_SIZE).await {
Ok(_) => return Ok(()),
Err(io_err) => return Err(DiskError::Io(io_err)),
}
}
Err(last_err.unwrap_or_else(|| DiskError::other("walk_dir retry exhausted without captured error")))
},
timeout_duration,
FailureHealthAction::IgnoreFailure,
)
.await
Err(last_err.unwrap_or_else(|| DiskError::other("walk_dir retry exhausted without captured error")))
},
timeout_duration,
FailureHealthAction::IgnoreFailure,
)
.await;
observation.finish(result.is_ok());
result
}
#[tracing::instrument(level = "trace", skip_all)]
@@ -3905,6 +4004,21 @@ impl DiskAPI for RemoteDisk {
}
}
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,
@@ -4404,6 +4518,60 @@ mod tests {
static INIT: Once = Once::new();
#[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();
@@ -8008,7 +8176,7 @@ mod tests {
let copy_task = tokio::spawn(async move {
let mut cursor = Cursor::new(payload);
copy_stream_with_buffer(&mut cursor, &mut write_half, 4 * 1024)
copy_stream_with_buffer(&mut cursor, &mut write_half, 4 * 1024, &mut RemoteWalkGuard::default())
.await
.expect("operation should succeed");
});
@@ -8020,6 +8188,266 @@ mod tests {
assert_eq!(copied, expected);
}
struct WalkTestWriter {
bytes: Vec<u8>,
accept_limit: usize,
zero_write: bool,
fail_flush: bool,
flushed: bool,
}
impl WalkTestWriter {
fn new(accept_limit: usize) -> Self {
Self {
bytes: Vec::new(),
accept_limit,
zero_write: false,
fail_flush: false,
flushed: false,
}
}
}
impl AsyncWrite for WalkTestWriter {
fn poll_write(mut self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<io::Result<usize>> {
if self.zero_write {
return Poll::Ready(Ok(0));
}
let remaining = self.accept_limit.saturating_sub(self.bytes.len());
if remaining == 0 {
return Poll::Ready(Err(io::Error::new(io::ErrorKind::BrokenPipe, "injected downstream failure")));
}
let written = remaining.min(buf.len()).min(7);
self.bytes.extend_from_slice(&buf[..written]);
Poll::Ready(Ok(written))
}
fn poll_flush(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.flushed = true;
Poll::Ready(if self.fail_flush {
Err(io::Error::other("injected flush failure"))
} else {
Ok(())
})
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.poll_flush(cx)
}
}
#[tokio::test]
async fn walk_stream_preserves_boundaries_and_short_writes() {
for size in [
0,
1,
DEFAULT_READ_BUFFER_SIZE - 1,
DEFAULT_READ_BUFFER_SIZE,
DEFAULT_READ_BUFFER_SIZE + 1,
4 * 1024 * 1024,
] {
let payload: Vec<u8> = (0..=250).cycle().take(size).collect();
let mut reader = Cursor::new(&payload);
let mut writer = WalkTestWriter::new(usize::MAX);
let copied =
copy_stream_with_buffer(&mut reader, &mut writer, DEFAULT_READ_BUFFER_SIZE, &mut RemoteWalkGuard::default())
.await
.expect("walk boundary copy");
assert_eq!(copied, u64::try_from(size).expect("test size fits"));
assert_eq!(writer.bytes, payload);
assert!(writer.flushed, "EOF must flush even an empty walk");
}
}
#[tokio::test]
async fn walk_stream_propagates_write_zero_and_flush_failures() {
for (zero_write, fail_flush, expected) in [(true, false, io::ErrorKind::WriteZero), (false, true, io::ErrorKind::Other)] {
let mut reader = Cursor::new(b"walk payload");
let mut writer = WalkTestWriter::new(usize::MAX);
writer.zero_write = zero_write;
writer.fail_flush = fail_flush;
let error =
copy_stream_with_buffer(&mut reader, &mut writer, DEFAULT_READ_BUFFER_SIZE, &mut RemoteWalkGuard::default())
.await
.expect_err("injected write/flush failure must propagate");
assert_eq!(error.kind(), expected);
if fail_flush {
assert_eq!(writer.bytes, b"walk payload");
}
}
}
#[tokio::test]
async fn walk_stream_backpressure_limits_read_ahead_and_is_cancellable() {
let mut reader = Cursor::new(vec![5; 4 * DEFAULT_READ_BUFFER_SIZE]);
let (mut writer, mut consumer) = duplex(64);
{
let mut observation = RemoteWalkGuard::default();
let copy = copy_stream_with_buffer(&mut reader, &mut writer, DEFAULT_READ_BUFFER_SIZE, &mut observation);
tokio::pin!(copy);
assert!(futures::poll!(&mut copy).is_pending(), "a stalled consumer must backpressure the walk");
}
assert_eq!(reader.position(), u64::try_from(DEFAULT_READ_BUFFER_SIZE).expect("buffer fits"));
drop(writer);
let mut published = Vec::new();
consumer
.read_to_end(&mut published)
.await
.expect("read published prefix after cancellation");
assert_eq!(published, vec![5; 64], "cancellation must not publish additional buffered bytes");
}
struct WalkMetricsTestGate(bool);
impl WalkMetricsTestGate {
fn enable() -> Self {
let previous = rustfs_io_metrics::metrics_enabled();
rustfs_io_metrics::set_metrics_enabled(true);
Self(previous)
}
}
impl Drop for WalkMetricsTestGate {
fn drop(&mut self) {
rustfs_io_metrics::set_metrics_enabled(self.0);
}
}
#[test]
#[serial_test::serial]
fn walk_stream_counts_partial_output_before_failure() {
let recorder = crate::test_metrics::CapturingRecorder::default();
let _gate = WalkMetricsTestGate::enable();
metrics::with_local_recorder(&recorder, || {
let runtime = tokio::runtime::Builder::new_current_thread().build().expect("test runtime");
runtime.block_on(async {
let mut observation = RemoteWalkGuard::start();
let mut reader = Cursor::new(b"walk payload");
let mut writer = WalkTestWriter::new(9);
let result = copy_stream_with_buffer(&mut reader, &mut writer, DEFAULT_READ_BUFFER_SIZE, &mut observation).await;
observation.finish(result.is_ok());
assert_eq!(result.expect_err("partial downstream failure").kind(), io::ErrorKind::BrokenPipe);
assert_eq!(writer.bytes, b"walk payl");
});
});
assert_eq!(recorder.counter_value("rustfs_remote_walk_read_bytes_total", &[]), 12);
assert_eq!(recorder.counter_value("rustfs_remote_walk_written_bytes_total", &[]), 9);
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", "error")]), 1);
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocated_bytes_total", &[]), 1_048_576);
}
#[test]
#[serial_test::serial]
fn walk_stream_open_retry_is_one_logical_observation() {
let _gate = WalkMetricsTestGate::enable();
let recorder = crate::test_metrics::CapturingRecorder::default();
metrics::with_local_recorder(&recorder, || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("test runtime");
runtime.block_on(async {
let transport = RetryingWalkDirInternodeDataTransport::with_steps(vec![
WalkDirTestStep::Error(DiskError::Io(io::Error::new(io::ErrorKind::ConnectionReset, "retryable open"))),
WalkDirTestStep::Data(b"walk payload".to_vec()),
]);
let disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
let mut output = Vec::new();
disk.walk_dir(WalkDirOptions::default(), &mut output)
.await
.expect("retried walk must complete");
assert_eq!(output, b"walk payload");
assert_eq!(transport.calls().len(), 2);
});
});
assert_eq!(recorder.counter_value("rustfs_remote_walk_started_total", &[]), 1);
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", "ok")]), 1);
assert_eq!(recorder.counter_value("rustfs_remote_walk_read_bytes_total", &[]), 12);
assert_eq!(recorder.counter_value("rustfs_remote_walk_written_bytes_total", &[]), 12);
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocations_total", &[]), 1);
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocated_bytes_total", &[]), 1_048_576);
}
#[test]
#[serial_test::serial]
fn walk_stream_counts_partial_input_before_reader_failure() {
let _gate = WalkMetricsTestGate::enable();
let recorder = crate::test_metrics::CapturingRecorder::default();
metrics::with_local_recorder(&recorder, || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.expect("test runtime");
runtime.block_on(async {
let transport = RetryingWalkDirInternodeDataTransport::with_steps(vec![WalkDirTestStep::PartialDataThenError {
data: b"walk payload".to_vec(),
error: io::Error::new(io::ErrorKind::ConnectionReset, "partial input failure"),
}]);
let disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
let mut output = Vec::new();
let error = disk
.walk_dir(WalkDirOptions::default(), &mut output)
.await
.expect_err("partial input must fail");
assert!(matches!(error, DiskError::Io(error) if error.kind() == io::ErrorKind::ConnectionReset));
assert_eq!(output, b"walk payload");
assert_eq!(transport.calls().len(), 1);
});
});
assert_eq!(recorder.counter_value("rustfs_remote_walk_read_bytes_total", &[]), 12);
assert_eq!(recorder.counter_value("rustfs_remote_walk_written_bytes_total", &[]), 12);
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", "error")]), 1);
}
#[test]
#[serial_test::serial]
fn walk_stream_remote_cancellation_and_timeout_release_tracking() {
let _gate = WalkMetricsTestGate::enable();
for cancelled in [true, false] {
let recorder = crate::test_metrics::CapturingRecorder::default();
metrics::with_local_recorder(&recorder, || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.start_paused(true)
.build()
.expect("paused runtime");
runtime.block_on(async {
let transport = RetryingWalkDirInternodeDataTransport::with_steps(vec![WalkDirTestStep::Data(vec![
6;
DEFAULT_READ_BUFFER_SIZE
* 4
])]);
let disk = new_remote_disk_with_transport(Arc::new(transport.clone())).await;
let opts = WalkDirOptions {
timeout_ms: Some(1000),
..Default::default()
};
let (mut writer, mut consumer) = duplex(64);
let mut walk = Box::pin(disk.walk_dir(opts, &mut writer));
assert!(futures::poll!(&mut walk).is_pending(), "walk must reach a backpressured await");
assert_eq!(transport.calls().len(), 1);
if cancelled {
drop(walk);
} else {
tokio::time::advance(Duration::from_secs(1)).await;
assert!(matches!(walk.await, Err(DiskError::Timeout)));
}
assert_eq!(disk.health.waiting_count(), 0, "health tracking must be released");
drop(writer);
let mut published = Vec::new();
consumer.read_to_end(&mut published).await.expect("published prefix");
assert_eq!(published, vec![6; 64]);
});
});
let outcome = if cancelled { "cancelled" } else { "error" };
assert_eq!(recorder.counter_value("rustfs_remote_walk_started_total", &[]), 1);
assert_eq!(recorder.counter_value("rustfs_remote_walk_completed_total", &[("outcome", outcome)]), 1);
assert_eq!(recorder.counter_value("rustfs_remote_walk_read_bytes_total", &[]), 1_048_576);
assert_eq!(recorder.counter_value("rustfs_remote_walk_written_bytes_total", &[]), 64);
assert_eq!(recorder.counter_value("rustfs_remote_walk_scratch_allocations_total", &[]), 1);
}
}
#[tokio::test]
async fn test_remote_disk_disk_id() {
let url = url::Url::parse("http://remote-server:9000").expect("operation should succeed");
@@ -69,9 +69,10 @@ pub(crate) fn read_version_request_caller_from_location(file: &str) -> ReadVersi
ReadVersionRequestCaller::Peer
} else if path_ends_with(file, "/store/init.rs") {
ReadVersionRequestCaller::Startup
} else if path_ends_with(file, "/store/heal.rs") || path_ends_with(file, "/set_disk/ops/heal.rs") {
ReadVersionRequestCaller::Heal
} else if path_ends_with(file, "/set_disk/ops/heal_walk.rs") {
} else if path_ends_with(file, "/store/heal.rs")
|| path_ends_with(file, "/set_disk/ops/heal.rs")
|| path_ends_with(file, "/set_disk/ops/heal_walk.rs")
{
ReadVersionRequestCaller::Heal
} else if path_ends_with(file, "/store/list_objects.rs") {
ReadVersionRequestCaller::ListObjects
+186 -5
View File
@@ -69,6 +69,26 @@ fn config_task_join_error(operation: &'static str, error: tokio::task::JoinError
Error::other(format!("{operation} task {outcome}"))
}
fn spawn_config_task<F>(future: F) -> tokio::task::JoinHandle<F::Output>
where
F: std::future::Future + Send + 'static,
F::Output: Send + 'static,
{
// Capture before spawning; detached transactions outlive their request waiter.
#[cfg(feature = "diagnostic-read-version-caller")]
let future = crate::cluster::rpc::runtime_sources::with_read_version_diagnostic_context(
crate::cluster::rpc::runtime_sources::ReadVersionDiagnosticContext::capture(),
rustfs_io_metrics::internode_metrics::ReadVersionContextBoundary::ConfigTask,
future,
);
#[cfg(not(feature = "diagnostic-read-version-caller"))]
let future = rustfs_io_metrics::s3_http_metrics::with_s3_http_operation_context(
rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_context(),
future,
);
tokio::spawn(future)
}
/// Runs one complete server-config transaction while holding both the local
/// process guard and the distributed namespace write lock for `config.json`.
/// The operation must use the corresponding no-lock read/save functions.
@@ -78,7 +98,7 @@ where
Fut: std::future::Future<Output = T> + Send + 'static,
T: Send + 'static,
{
tokio::spawn(async move {
spawn_config_task(async move {
// Lock order: SERVER_CONFIG_LOCK -> transaction lock -> object lock.
let _local_guard = SERVER_CONFIG_LOCK.write().await;
let transaction_lock = server_config_transaction_lock_path();
@@ -101,7 +121,7 @@ where
Fut: std::future::Future<Output = T> + Send + 'static,
T: Send + 'static,
{
tokio::spawn(async move {
spawn_config_task(async move {
// Lock order: SERVER_CONFIG_LOCK -> transaction lock -> object lock.
let _local_guard = SERVER_CONFIG_LOCK.read().await;
let transaction_lock = server_config_transaction_lock_path();
@@ -123,7 +143,7 @@ where
Fut: std::future::Future<Output = T> + Send + 'static,
T: Send + 'static,
{
tokio::spawn(async move {
spawn_config_task(async move {
let namespace_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &object).await?;
let write_guard = crate::object_api::WriteCommitGuard::acquire(&namespace_lock, get_lock_acquire_timeout()).await?;
Ok(operation(write_guard.clone()).await)
@@ -140,7 +160,7 @@ where
Fut: std::future::Future<Output = T> + Send + 'static,
T: Send + 'static,
{
tokio::spawn(async move {
spawn_config_task(async move {
let namespace_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &object).await?;
let _read_guard = namespace_lock.get_read_lock(get_lock_acquire_timeout()).await?;
Ok(operation().await)
@@ -2853,7 +2873,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]
@@ -2867,6 +2887,163 @@ mod tests {
assert!(!rendered.contains("do-not-expose-payload"));
}
#[tokio::test]
async fn config_tasks_preserve_operation_across_nested_spawns() {
use rustfs_io_metrics::s3_http_metrics::{S3HttpRequestGuard, current_s3_http_operation_name, observe_s3_http_operation};
let mut request = S3HttpRequestGuard::new("PUT");
let child = request.in_scope(|| {
observe_s3_http_operation(rustfs_s3_ops::S3Operation::CreateBucket);
super::spawn_config_task(async {
assert_eq!(current_s3_http_operation_name(), Some("s3:CreateBucket"));
super::spawn_config_task(async { current_s3_http_operation_name() })
.await
.expect("nested transaction task completes")
})
});
drop(request);
assert_eq!(child.await.expect("transaction task completes"), Some("s3:CreateBucket"));
assert_eq!(current_s3_http_operation_name(), None);
assert_eq!(
super::spawn_config_task(async { current_s3_http_operation_name() })
.await
.expect("internal transaction task completes"),
None
);
}
#[tokio::test]
async fn config_tasks_finish_after_the_waiter_drops() {
use rustfs_io_metrics::s3_http_metrics::{S3HttpRequestGuard, current_s3_http_operation_name, observe_s3_http_operation};
let admission = Arc::new(tokio::sync::Semaphore::new(1));
let permit = admission.clone().acquire_owned().await.expect("admission permit");
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let (finished_tx, finished_rx) = tokio::sync::oneshot::channel();
let mut request = S3HttpRequestGuard::new("DELETE");
let child = request.in_scope(|| {
observe_s3_http_operation(rustfs_s3_ops::S3Operation::DeleteBucket);
super::spawn_config_task(async move {
entered_tx.send(()).expect("waiter is still present");
release_rx.await.expect("transaction may finish");
assert_eq!(current_s3_http_operation_name(), Some("s3:DeleteBucket"));
drop(permit);
finished_tx.send(()).expect("completion observer is still present");
})
});
entered_rx.await.expect("transaction started");
drop(child);
drop(request);
assert_eq!(admission.available_permits(), 0);
release_tx.send(()).expect("detached transaction remains alive");
finished_rx.await.expect("detached transaction finished");
assert_eq!(admission.available_permits(), 1);
assert_eq!(current_s3_http_operation_name(), None);
}
#[cfg(feature = "diagnostic-read-version-caller")]
#[tokio::test]
async fn diagnostic_config_tasks_preserve_first_owner_across_nested_spawns() {
use crate::cluster::rpc::runtime_sources::{
current_read_version_entry_source, current_read_version_request_caller, with_read_version_entry,
with_read_version_request_caller,
};
use rustfs_io_metrics::internode_metrics::{ReadVersionEntrySource, ReadVersionRequestCaller};
use rustfs_io_metrics::s3_http_metrics::{
S3HttpRequestGuard, current_s3_http_operation_context, current_s3_http_operation_name, observe_s3_http_operation,
with_s3_http_operation_context,
};
let mut request = S3HttpRequestGuard::new("PUT");
let operation = request.in_scope(|| {
observe_s3_http_operation(rustfs_s3_ops::S3Operation::CreateBucket);
current_s3_http_operation_context().expect("captured bucket operation")
});
let task = with_s3_http_operation_context(Some(operation), async {
with_read_version_request_caller(ReadVersionRequestCaller::Object, async {
with_read_version_entry(ReadVersionEntrySource::TierConfigReload, async {
super::spawn_config_task(async {
assert_eq!(current_s3_http_operation_name(), Some("s3:CreateBucket"));
assert_eq!(current_read_version_request_caller(), Some(ReadVersionRequestCaller::Object));
assert_eq!(current_read_version_entry_source(), Some(ReadVersionEntrySource::TierConfigReload));
with_read_version_entry(ReadVersionEntrySource::ConfigRead, async {
super::spawn_config_task(async {
assert_eq!(current_s3_http_operation_name(), Some("s3:CreateBucket"));
assert_eq!(current_read_version_request_caller(), Some(ReadVersionRequestCaller::Object));
assert_eq!(current_read_version_entry_source(), Some(ReadVersionEntrySource::TierConfigReload));
Err::<(), _>("config failure")
})
.await
.expect("nested config task joins")
})
.await
})
})
.await
})
.await
})
.await;
drop(request);
assert_eq!(task.await.expect("config task joins"), Err("config failure"));
assert_eq!(current_s3_http_operation_name(), None);
assert_eq!(current_read_version_request_caller(), None);
assert_eq!(current_read_version_entry_source(), None);
super::spawn_config_task(async {
assert_eq!(current_s3_http_operation_name(), None);
assert_eq!(current_read_version_request_caller(), None);
assert_eq!(current_read_version_entry_source(), None, "internal work invents no entry owner");
with_read_version_entry(ReadVersionEntrySource::ConfigRead, async {
assert_eq!(current_read_version_entry_source(), Some(ReadVersionEntrySource::ConfigRead));
})
.await;
})
.await
.expect("unscoped config task joins");
}
#[cfg(feature = "diagnostic-read-version-caller")]
#[tokio::test]
async fn diagnostic_config_tasks_keep_context_and_permit_after_waiter_drop() {
use crate::cluster::rpc::runtime_sources::{
current_read_version_entry_source, current_read_version_request_caller, with_read_version_entry,
with_read_version_request_caller,
};
use rustfs_io_metrics::internode_metrics::{ReadVersionEntrySource, ReadVersionRequestCaller};
let admission = Arc::new(tokio::sync::Semaphore::new(1));
let permit = admission.clone().acquire_owned().await.expect("config task permit");
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
let (finished_tx, finished_rx) = tokio::sync::oneshot::channel();
let task = with_read_version_request_caller(ReadVersionRequestCaller::MetadataRead, async {
with_read_version_entry(ReadVersionEntrySource::ServerInfoPools, async {
super::spawn_config_task(async move {
entered_tx.send(()).expect("waiter is present");
release_rx.await.expect("config task may finish");
let observed = (current_read_version_request_caller(), current_read_version_entry_source());
drop(permit);
finished_tx.send(observed).expect("completion observer is present");
})
})
.await
})
.await;
entered_rx.await.expect("config task started");
drop(task);
assert_eq!(admission.available_permits(), 0, "dropping the waiter keeps admission owned");
release_tx.send(()).expect("detached config task remains alive");
assert_eq!(
finished_rx.await.expect("detached config task completes"),
(
Some(ReadVersionRequestCaller::MetadataRead),
Some(ReadVersionEntrySource::ServerInfoPools)
)
);
assert_eq!(admission.available_permits(), 1);
assert_eq!(current_read_version_request_caller(), None);
assert_eq!(current_read_version_entry_source(), None);
}
#[test]
fn system_metadata_volume_failures_map_to_retryable_write_errors() {
for error in [Error::VolumeNotFound, Error::BucketNotFound(RUSTFS_META_BUCKET.to_string())] {
@@ -4201,6 +4378,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());
@@ -4222,6 +4401,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");
+22 -6
View File
@@ -15,12 +15,13 @@
use rustfs_config::server_config::{KV, KVS};
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,
DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS, DEFAULT_SCANNER_CYCLE_MAX_OBJECTS, DEFAULT_SCANNER_DISK_IO_BUDGET,
DEFAULT_SCANNER_IDLE_MODE, DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS, DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS,
DEFAULT_SCANNER_SPEED, 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_DISK_IO_BUDGET_BYTES_PER_SEC, SCANNER_DISK_IO_BUDGET_IOPS, 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 +93,21 @@ 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_DISK_IO_BUDGET_IOPS.to_owned(),
value: DEFAULT_SCANNER_DISK_IO_BUDGET.to_owned(),
hidden_if_empty: false,
},
KV {
key: SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC.to_owned(),
value: DEFAULT_SCANNER_DISK_IO_BUDGET.to_owned(),
hidden_if_empty: false,
},
KV {
key: SCANNER_YIELD_EVERY_N_OBJECTS.to_owned(),
value: DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS.to_string(),
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
// Copyright 2026 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.
//! Test-only barriers inside the data-movement commit windows.
//!
//! Compiled only with the `e2e-test-hooks` feature and inert unless
//! `RUSTFS_E2E_DATA_MOVEMENT_BARRIER_DIR` names an absolute directory holding
//! an `arm.json`. A mover that matches the arm records one `reached.*.json`
//! observation and then blocks until `release.<nonce>` exists. A release body
//! of `io_error` fails the window instead of continuing, so a fixture can
//! inject a target-commit or source-cleanup failure at a proven point. The
//! observation file is the only evidence that an injection reached its window;
//! a fixture must not count an unreached barrier as a fault test.
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::fs;
use tokio::time::Instant;
pub(crate) const BARRIER_DIR_ENV: &str = "RUSTFS_E2E_DATA_MOVEMENT_BARRIER_DIR";
/// Every target part is staged and the target version is not yet published.
pub(crate) const STAGE_BEFORE_TARGET_COMMIT: &str = "before_target_commit";
/// The target commit is durable and the source preflight matched; the source
/// delete has not been issued.
pub(crate) const STAGE_BEFORE_SOURCE_CLEANUP: &str = "before_source_cleanup";
/// A decommission start is durable in `pool.bin`; no worker has started and
/// the admin request has not been acknowledged.
pub(crate) const STAGE_AFTER_RESPONSIBILITY_PERSISTED: &str = "after_responsibility_persisted";
const RELEASE_TIMEOUT: Duration = Duration::from_secs(120);
const RELEASE_POLL: Duration = Duration::from_millis(20);
const RELEASE_IO_ERROR: &[u8] = b"io_error";
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct BarrierArm {
nonce: String,
bucket: String,
object: String,
stage: String,
/// `decommission` or `rebalance`; absent matches either mover. Movers label
/// their stages with that word or a `<word>_` prefix (`decommission_object`).
#[serde(default)]
operation: Option<String>,
}
fn valid_nonce(nonce: &str) -> bool {
!nonce.is_empty()
&& nonce.len() <= 64
&& nonce
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
}
async fn read_arm(directory: &Path) -> std::io::Result<Option<BarrierArm>> {
match fs::read(directory.join("arm.json")).await {
Ok(bytes) => serde_json::from_slice(&bytes).map(Some).map_err(std::io::Error::other),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
/// Hold `operation` at `stage` for `bucket/object` while a matching arm exists.
pub(crate) async fn wait(operation: &str, bucket: &str, object: &str, stage: &str) -> std::io::Result<()> {
let Some(directory) = std::env::var_os(BARRIER_DIR_ENV).map(PathBuf::from) else {
return Ok(());
};
let Some(arm) = read_arm(&directory).await? else {
return Ok(());
};
if arm.bucket != bucket
|| arm.object != object
|| arm.stage != stage
|| arm
.operation
.as_deref()
.is_some_and(|armed| armed != operation && !operation.starts_with(&format!("{armed}_")))
{
return Ok(());
}
if !directory.is_absolute() || !valid_nonce(&arm.nonce) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"data movement test barrier requires an absolute directory and a safe nonce",
));
}
let pid = std::process::id();
let observation = serde_json::json!({
"operation": operation,
"bucket": bucket,
"object": object,
"stage": stage,
"pid": pid,
"nonce": arm.nonce,
"reached_at_unix_ms": time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000,
});
let reached = directory.join(format!("reached.{}.{pid}.{}.json", arm.nonce, uuid::Uuid::new_v4().simple()));
fs::write(&reached, serde_json::to_vec(&observation)?).await?;
let release = directory.join(format!("release.{}", arm.nonce));
let deadline = Instant::now() + RELEASE_TIMEOUT;
loop {
match fs::read(&release).await {
Ok(body) if body == RELEASE_IO_ERROR => {
// The reached observation already names the stage and object.
return Err(std::io::Error::other("injected data movement barrier failure"));
}
Ok(_) => return Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) => return Err(err),
}
if Instant::now() >= deadline {
return Err(std::io::Error::new(ErrorKind::TimedOut, "data movement test barrier release timed out"));
}
tokio::time::sleep(RELEASE_POLL).await;
}
}

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