Compare commits

..

188 Commits

Author SHA1 Message Date
唐小鸭 8553853761 Merge branch 'fix/replication-target-version-ledger' into fix/replication-check-ledger-probe 2026-09-07 16:45:49 +08:00
唐小鸭 3b62044485 Merge remote-tracking branch 'origin/main' into fix/replication-target-version-ledger 2026-09-07 16:38:04 +08:00
唐小鸭 91fccdcac2 Merge branch 'fix/replication-target-version-ledger' into fix/replication-check-ledger-probe 2026-09-07 16:37:38 +08:00
唐小鸭 6448fa54c4 fix(scanner): drop the unused Digest import
Same one-line change as rustfs/rustfs#7366 (main is red with it under -D warnings); carried here so the stacked PRs' merge commits compile until that fix lands.
2026-09-07 16:37:34 +08:00
唐小鸭 ee73203791 fix(admin): probe replication-check mutations by the assigned version id
On a target that mints its own version ids the DeleteMarker and
VersionDelete phases of ?replication-check were skipped: they addressed
the source id, which such a target never had. The replication worker now
addresses the id the target assigned (the target-version ledger), and
the probe already holds that id from its own PUT, so run both phases
against it. VersionFidelity keeps failing with the mismatch code and the
target stays FAILED; the phases report whether ledger-addressed purges
work against this endpoint (rustfs/backlog#2340).
2026-09-07 16:08:55 +08:00
唐小鸭 46a387dffe fix(replication): resolve drifted replicas via a target version ledger
A replication target that mints its own version ids (Wasabi, AWS S3)
never answers to the source uuid, so every version-addressed mutation
after the initial PUT failed forever: permanent version deletes answered
NoSuchVersion every heal cycle, and tag / retention / legal-hold updates
re-PUT the object, minting one more target version per update
(rustfs/backlog#2340).

Record the id the target assigned as a per-target ledger on the source
version (replication-target-version-<arn>, written through the existing
status writeback) and resolve every later mutation through it: version
deletes DELETE the ledger id, metadata updates go through the
metadata-only Object Lock and tagging APIs. Replicas written before the
ledger existed are located by exact key and ETag, minus the candidates
other generations of the key already claim through their own ledgers; an
ambiguous remainder is refused with a backoff instead of guessed, since a
wrong pick would destroy a live generation. A fresh write never consults
content identity. NoSuchVersion on a version-addressed DELETE counts as
purged.

The fake target gains the Wasabi shape (404 NoSuchVersion on an unknown
id, per-version Object Lock APIs) and the matrix covers the three
mutation classes plus the same-bytes generation case.
2026-09-07 15:40:54 +08:00
GatewayJ c9c6bb7a24 fix(oidc): support workload discovery and JWKS negotiation (#7348)
* fix(oidc): support workload discovery and JWKS negotiation

* test(oidc): cover existing Console authorization code flows

* test(oidc): cover admin validation and document workload setup
2026-09-07 06:38:04 +00:00
houseme ad0c44dc63 fix(ecstore): keep pool meta read probes retryable (#7359)
Evaluate read-only pool metadata planning probes through an isolated write-state clone so transient unreadable replicas fail the current admission without permanently latching the shared writer gate.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 14:31:25 +08:00
houseme 2d6f9417ff feat(scanner): persist raw enumeration cancellation cursor (#7358)
Return a partial data-usage cache when raw filesystem enumeration is cancelled before object progress can be written. The partial cache now carries a validated raw enumeration cursor for V2 checkpoint scans while keeping the snapshot incomplete and clearing older frontier/checkpoint metadata.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 14:27:59 +08:00
houseme 638da7a2e3 fix(scanner): confirm lost dirty usage acknowledgements (#7357)
When the remote dirty-usage ACK response is lost, re-probe scanner activity once and accept the ACK only if every target host still reports the same scanner instance with no dirty usage pending. Duplicate targets, restarted peers, unverified activity, and concurrent writes remain pending.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 14:27:53 +08:00
houseme 7c7b48aaca test(heal): cover MRF replay across process restart (#7355)
Add an ECStore-backed child-process fixture that publishes the MRF journal in one OS process, then verifies a restarted manager can replay the complete snapshot while retaining the journal when bounded admission accepts only a prefix.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 14:16:31 +08:00
houseme c40988dae7 feat(scanner): replay recovery intents at startup (#7354)
Discover durable scanner recovery intents during startup and sequentially re-drive accepted or running usage full-rebuild work. Disabled scanner startup also replays existing durable intents without enabling the normal scanner loop.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 14:16:19 +08:00
houseme 481c1b7939 chore(deps): refresh scanner heal continuation lockfile (#7351) 2026-09-07 13:31:49 +08:00
houseme 26d827f946 test(heal): cover set bulkhead scheduler isolation (#7347)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 12:54:55 +08:00
houseme 376cb037a0 test(scanner): classify raw enumeration replay (#7339)
Record bounded first/last raw entry markers in the restart diagnostic worker and have the driver classify repeated raw windows when retained coverage does not advance.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 12:54:49 +08:00
houseme 64829c704d fix(heal): retain MRF replay journal until retry anchor (#7340)
Keep the startup journal on disk when replay cannot fully re-arm or the heal manager refuses a replayed intent with Full/QueueFull. The next live snapshot can still advance the journal after the retry anchor is durable again.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 12:54:38 +08:00
houseme 17ba30f648 fix(ecstore): roll back failed CAS directory fsync (#7343)
Restore the previous control-file bytes, or remove a newly created file, when the Unix compare-and-update path reaches the rename but then fails to fsync the parent directory. This keeps failed metadata CAS publications from advancing recovery anchors.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 12:54:28 +08:00
houseme 21015cfac8 feat(scanner): accept durable recovery intents (#7344)
* feat(scanner): accept durable recovery intents

Add a CAS-backed scanner usage recovery intent record for async full rebuild admission. The admin reset endpoint can now persist and replay idempotent intent acceptance before returning 202, and a read-only status route exposes the durable request state.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(madmin): add scanner recovery intent helpers (#7345)

Expose madmin helpers for accepting and querying asynchronous scanner usage-state full-rebuild recovery intents. Keep the legacy synchronous helper unchanged and pin the new request/response wire contract with focused client tests.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): execute recovery intents asynchronously

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 12:54:04 +08:00
houseme c04cd089e9 test(scanner): parameterize heal evidence oracles (#7346)
Allow the scanner/heal release evidence e2e helper to bind the active case identity and oracle file through an explicit descriptor instead of hard-coding the background target restart artifact. Extend the checker self-test so a single run can finish multiple registry-declared oracles while release gates remain pending until every required lane is complete.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 12:53:54 +08:00
houseme bffdf0809f feat(scanner): add raw enumeration cursor metadata (#7349)
Add a durable raw enumeration cursor shape to scanner usage metadata and validate it against bucket identity, source, bounds, version, and page digest before preserving it across checkpoint preparation.

Keep empty cursor metadata omitted so existing pinned .usage-cache.bin bytes stay unchanged, while legacy readers still ignore the additive field when it is present.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 12:53:32 +08:00
Zhengchao An 19a69ee897 fix(ecstore): hide and reclaim delete residue in prefix listings (#7342)
The never-versioned listing fast path emitted any non-empty directory
without xl.meta as a prefix, which surfaced the data dirs deleted
versions leave behind as phantom folders that HEAD, listing and
recursive remove could not touch. Probe such directories for the
delete-residue shape before emitting them, and let any complete empty
first page of a prefix listing trigger the existing fail-closed orphan
purge so ordinary browse and remove traffic reclaims committed residue.
2026-09-07 04:42:14 +00:00
Zhengchao An 1499295393 fix(admin): probe set drives concurrently for storage info (#7338)
The admin storage walk probed a set's drives one after another, each
bounded by the disk_info timeout, so a few drives still recovering
after a power cut pushed the local snapshot past the peer probe budget
and healthy peers rendered as unknown. Probe all drives at once so the
walk costs one timeout at most, and add a test-only probe delay hook to
pin that bound.
2026-09-07 04:35:01 +00:00
Zhengchao An 4c4dcb6f5e fix(storage): queue multipart parts for foreground write permits (#7337)
Multipart parts shared the 250 ms direct-PutObject wait on the foreground
write permit pool, so SDK-default concurrency (many parts per upload in
flight at once) was rejected wholesale with SlowDown at stock settings.
Keep the pool that bounds in-flight bodies, but let parts wait in a
bounded queue with their own timeout before body ingest, report the
queue depth in the ForegroundWrite admission snapshot, and document the
foreground write admission environment variables.
2026-09-07 04:13:30 +00:00
Zhengchao An 672087ec0d ci(docker): move latest on every prerelease until first stable tag (#7341)
Since rc.1 the latest tag has been frozen at 1.0.0-beta.12 because the
docker workflow only allowed alpha/beta prereleases to update latest
(#2732 dropped the rc case). Before 1.0.0 GA, latest is expected to
track the newest test build.

- Prereleases (alpha/beta/rc) now update latest as long as no stable
  vX.Y.Z tag exists on origin, so the rule retires itself at GA.
- Channel tags (alpha/beta/rc) are now always added for prereleases;
  the previous if/elif skipped the channel tag whenever latest was
  created, which is why no :beta tag was ever published.
2026-09-07 11:25:40 +08:00
houseme 9dbeae1b45 chore(deps): refresh scanner batch baseline (#7336)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 11:04:34 +08:00
cxymds 6018dd372f perf(ilm): reduce transition transaction mutations (#7320)
* perf(ilm): reduce transition transaction mutations

* test(ilm): rename transition kill points

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-07 02:40:57 +00:00
houseme d633a635ec feat(madmin): add scanner reset client helpers (#7333)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 10:28:42 +08:00
houseme fbd30a6f43 test(scanner): cover concurrent cycle reset convergence (#7335)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 10:28:37 +08:00
houseme a32360198c test(scanner): cover scoped ack resolver fallback (#7332)
* test(scanner): cover scoped ack resolver fallback

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): reduce scoped resolver helper args

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 10:28:19 +08:00
GatewayJ 05efc584b0 perf(s3-tables): avoid duplicate registration prefix scans (#7310)
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-07 09:55:42 +08:00
cxymds a70c96d520 feat(ilm): execute legacy recovery dispositions (#7304)
* feat(ilm): execute legacy recovery dispositions

* feat(ilm): retry retained transition recovery (#7308)

* fix(admin): use gateway errors for recovery retries

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-07 01:46:43 +00:00
Zhengchao An b0f68f5d0a test(upgrade): read and replicate rc.5 multipart layouts (#7334) 2026-09-07 09:37:21 +08:00
Zhengchao An df554439b0 fix(heal): fall back to set-wide format for directory-backed targets (#7331)
fix(heal): fall back to set-wide format for directory-backed replacement targets

Since #7018 `renew_disk` routes an unformatted local endpoint through the automatic replacement heal, which requires the target to be an independently mounted disk. Directory-backed deployments (the operator set `RUSTFS_UNSAFE_BYPASS_DISK_CHECK`, which the startup disk-independence check already requires for endpoints sharing a device) can never pass that admission, so a runtime-wiped or replaced directory disk stayed unformatted forever: the heal task failed with "replacement target is not a stable mounted disk" and the auto-scan kept deferring the endpoint. This broke the Issue #1533 contract and the `heal_erasure_disk_rebuild_test` lane on main since 2026-09-02.

When the disk-check bypass is set and the replacement target fails admission, the erasure-set heal now logs a warning and downgrades to the ordinary set-wide `heal_format` path that predated replacement admission, and the auto-scan no longer defers such endpoints. The mount admission itself is unchanged and still cannot be bypassed by any environment variable; deployments without the bypass keep failing closed.

The endpoint-blackhole heal scenario now probes whether `iptables` can read the OUTPUT chain and logs an explicit skip when the host lacks `CAP_NET_ADMIN` (containerised runners report "Permission denied" from the nf_tables backend even under sudo); `RUSTFS_E2E_REQUIRE_NET_FAULT_INJECTION=1` turns that into a failure for lanes that provision the capability. The CI full-gate job surfaces the missing capability as a workflow warning, and the runtime-wipe fixture retries `remove_dir_all` on the listing race macOS surfaces as `DirectoryNotEmpty`.

Refs rustfs/backlog#2357.
2026-09-07 09:25:44 +08:00
唐小鸭 3b404e56c0 fix(replication): forward single-part object checksums as headers (#7313) 2026-09-07 01:17:24 +00:00
Zhengchao An ff00872922 docs(ilm): restore transition gate doc comment after handler reorder (#7325)
Commit 086ee8e48 moved authorize_recovery_admin_request above authorize_transition_admin_request and left the transition gate's doc comment attached to the recovery gate, which returns a hashed actor rather than the masked access key the comment describes. Move the comment back onto the transition gate and give the recovery gate its own accurate description. No behavior change.
2026-09-07 08:59:20 +08:00
唐小鸭 4d1ce9618a fix(scanner): pass dirty scopes to distributed scope resolution (#7329) 2026-09-07 07:42:17 +08:00
houseme a2bad0953f test(scanner): collect heal evidence oracles from registry (#7328) 2026-09-06 21:44:01 +00:00
唐小鸭 14c99a994c fix(filemeta): keep data dir of a version awaiting purge replication (#7307) 2026-09-07 05:30:44 +08:00
houseme f6c2a9bfe0 test(scanner): cover overflow service cohort rotation (#7324) 2026-09-07 05:29:59 +08:00
houseme 640d7e0e3c test(heal): cover MRF snapshot recovery bounds (#7327) 2026-09-07 05:29:25 +08:00
houseme 409ac3de66 test(scanner): cover reset cleanup process crash boundaries (#7326) 2026-09-07 05:29:14 +08:00
唐小鸭 2a63fcbea6 fix(replication): stop duplicate re-drives on own-version-id targets (#7323) 2026-09-07 05:29:00 +08:00
houseme f4049598e4 feat(scanner): send scoped dirty usage acknowledgements (#7322) 2026-09-07 05:28:50 +08:00
GatewayJ 1651541d38 fix(s3-tables): preserve encoded paths in client signatures (#7309) 2026-09-07 05:28:06 +08:00
cxymds 85849788af chore(tier): remove remaining blanket lint allowances (#7306) 2026-09-07 05:27:29 +08:00
Henry Guo 975983abdd feat(scanner): reuse clean local bucket prefixes (#7208) 2026-09-07 05:26:56 +08:00
唐小鸭 cf1c45eb91 test(replication): pin directory-marker null version and replication (#7315) 2026-09-06 19:26:09 +00:00
唐小鸭 536c283716 test(ilm): realign source-marker assertions with handler order (#7321) 2026-09-07 02:10:13 +08:00
houseme a04ddc237e chore: remove Go heal outcome compatibility fixture (#7319)
* del go code

* chore: record ILM lifecycle validation on go-del (#7318)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 23:49:56 +08:00
houseme 7de6ac82e1 test(scanner): cover reset cleanup across store reopen (#7317)
Add a real ECStore reopen regression for scanner usage-state reset cleanup boundaries. The fixture seeds each partially completed cleanup state, recreates the store, then verifies the reset resumes without rewriting the bootstrap intent or deleting unrelated metadata.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 23:40:31 +08:00
唐小鸭 7993e11058 docs(s3): record object-key path-segment validation as intentional (#7314) 2026-09-06 23:27:10 +08:00
houseme 37ead2f69c test(ilm): align feature matrix lifecycle assertions (#7311)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 23:27:01 +08:00
houseme 1b1e590df7 test(scanner): verify quota state across reset and owner restart (#7303)
* test(scanner): verify quota state across reset and owner restart

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix: restore ILM transition and lifecycle validation (#7312)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 23:26:01 +08:00
houseme 086ee8e48a fix(scanner): require root publication proof before dirty ack (#7297)
* fix(scanner): require root publication proof before dirty ack

Bind ACK expectations to validated scan candidates and confirm the actual primary-root revision and readback. Retain saved outcomes and dirty responsibility when stronger evidence is unavailable. Isolate CAS attempt confirmation and invalidate proof after scope mutations.

Revalidate observed candidate reuse before issuing a new publication proof, preserve the exact validated authoritative baseline work digest, and settle fixture commit tails before stable maintenance scans. Keep scoped ACK production disabled.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix: restore ILM transition and lifecycle validation (#7316)

Co-authored-by: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 23:25:40 +08:00
Zhengchao An 22bff27aee fix(storage): derive multipart identity from stored parts (#7305) 2026-09-06 22:04:15 +08:00
houseme 8a20498705 fix(heal): retain completed reports during clock rollback (#7299)
Treat a negative wall-clock age as zero without bypassing count or byte eviction. Cover canonical and alias queries, terminal outcomes, exact TTL expiry, and capacity limits during rollback.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 21:28:22 +08:00
houseme c3e6d90c6f docs(heal): clarify start retries and execution budgets (#7300)
Distinguish control requests, cumulative task execution and object retries. Explain ambiguous responses and bounded envelope replay without promising HTTP idempotency or changing runtime policy.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 21:27:37 +08:00
Zhengchao An d2af8f073e test(s3): exclude invalid lifecycle filter vector (#7295)
Co-authored-by: houseme <housemecn@gmail.com>
2026-09-06 21:21:12 +08:00
Zhengchao An 2baba1bda7 fix(scanner): preserve verified maintenance digest (#7293)
Co-authored-by: houseme <housemecn@gmail.com>
2026-09-06 21:20:58 +08:00
Zhengchao An bf2ca9113f test(scanner): settle fixture writes before activity baseline (#7290) 2026-09-06 21:20:46 +08:00
Zhengchao An f474ea30d4 fix(admin): preserve decommission readiness error (#7285) 2026-09-06 21:20:32 +08:00
cxymds 002ac9544c feat(ilm): add immutable legacy recovery exports (#7283)
* feat(ilm): add immutable legacy recovery exports

* feat(ilm): add legacy recovery disposition records (#7292)

* fix(ilm): stabilize legacy recovery decode errors

* fix(admin): route recovery auth errors through gateway

* fix(ilm): resolve recovery disposition clippy errors

* fix(ilm): remove redundant recovery test clones
2026-09-06 21:20:16 +08:00
Zhengchao An d74d970e24 fix(ecstore): retain namespace ownership during multipart commit (#7282) 2026-09-06 21:20:02 +08:00
Zhengchao An cc15eae479 fix(lifecycle): allow multiple filter predicates without And wrapper (#7298) 2026-09-06 21:09:11 +08:00
dependabot[bot] c130d00d4b chore(deps): bump golang.org/x/net from 0.54.0 to 0.55.0 in /scripts/compat/heal-outcome in the go_modules group across 1 directory (#7296)
chore(deps): bump golang.org/x/net

Bumps the go_modules group with 1 update in the /scripts/compat/heal-outcome directory: [golang.org/x/net](https://github.com/golang/net).


Updates `golang.org/x/net` from 0.54.0 to 0.55.0
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-06 19:13:21 +08:00
dependabot[bot] e49d9cdea2 chore(deps): bump the go_modules group across 1 directory with 2 updates (#7288)
Bumps the go_modules group with 1 update in the /scripts/compat/heal-outcome directory: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.37.0 to 0.52.0
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.52.0)

Updates `golang.org/x/net` from 0.39.0 to 0.54.0
- [Commits](https://github.com/golang/net/compare/v0.39.0...v0.54.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: indirect
  dependency-group: go_modules
- dependency-name: golang.org/x/net
  dependency-version: 0.54.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-06 18:56:54 +08:00
houseme f5778d8b97 chore(deps): refresh concurrency and protocol dependencies (#7289)
chore(deps): update Crossbeam, Redis, DER, and ipnet

Refresh the shared concurrency, Redis client, DER decoding and IP network dependencies while retaining existing feature selections and the hotpath pin.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 18:51:12 +08:00
Zhengchao An 9da42a899a test(connect): refresh offline enrollment e2e chain (#7294) 2026-09-06 18:30:04 +08:00
Henry Guo 3d46ed312a feat(scanner): reuse complete observed scan candidates (#7206) 2026-09-06 17:52:52 +08:00
Zhengchao An 3496277e7c test(ecstore): drain source writes before corrupting shards (#7291) 2026-09-06 17:51:07 +08:00
cxymds 0b72f39023 fix(tier): avoid re-fencing published mutations (#7274) 2026-09-06 16:57:03 +08:00
Zhengchao An 30a0937a7d fix(ecstore): retain single-delete physical namespace ownership (#7287) 2026-09-06 16:56:24 +08:00
houseme 5b962b6c58 feat(heal): expose compatible canonical v3 outcomes (#7256)
* feat(heal): expose compatible canonical v3 outcomes

Serialize the existing canonical heal outcome, retain the v3 summary vocabulary, and reject or conservatively adapt contradictory peer success responses. Preserve progress and bounded result cursors without treating legacy storage responses as repair proof.

Add optional SDK outcome and cursor support with shared Rust fixtures, pinned legacy Go decoder and mc polling checks, and explicit compatibility limits.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(madmin): box heal stop task status outcome

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 16:46:42 +08:00
houseme 0ee5408b94 feat(scanner): preserve bounded bootstrap admission fairness (#7260)
feat(scanner): retain bounded bootstrap admission fairness

Keep a leader-local bounded cohort across scanner retries and preserve
waiting bucket priority during dirty arrivals and capacity overflow.
Order source permits in the dispatcher without changing result identity,
parent budgets, explicit cycle timing, or persistent coverage evidence.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 16:45:57 +08:00
houseme cb3100a252 fix(scanner): visit compacted subtrees during deep scans (#7270)
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 16:45:17 +08:00
houseme 3a4afe9b38 fix(heal): bound cross-page object retry delays (#7273)
Retain failed identities in an execution-local count and byte bounded window so healthy later pages can advance. Preserve retry jitter, deadlines, terminal accounting and pressure pacing, with deterministic head-of-line and capacity regressions.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 16:44:59 +08:00
houseme 71859ff83c fix(scanner): resume committed cleanup when scanning is disabled (#7281)
Run one supervised cleanup attempt for an existing operator reset, with
strict phase and revision checks under the original leader lock. Keep v3
reset authorization and responses unchanged, report deferred status, and
bound probe and shutdown waits without aborting in-flight reset ownership.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 16:44:45 +08:00
houseme cb72df269a fix(heal): retain hints without verified repair receipts (#7275)
Stop task completion and legacy notices from discharging scanner retry hints. Preserve existing hints and their retry due time across admission observations, bound retry scheduling, and synchronize changed batches once even on cancellation.

Exercise the production MRF consumer, manager, event channel and scanner ledger. Document producer durability gaps without enabling successor activation or garbage collection.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-06 16:44:30 +08:00
Zhengchao An d7b7d1835f test(admin): validate repair futures with the default stack (#7280) 2026-09-06 15:43:13 +08:00
houseme b92392a04a ci: give target repair tests larger stack (#7272) 2026-09-06 15:38:05 +08:00
Zhengchao An a40ec8a6f3 fix(odm): preserve progress when list-through is disabled (#7278)
* test(odm): cover disabled list-through continuation progress

* test(odm): cover literal cache tags in disabled list cursors

* fix(odm): preserve progress when list-through is disabled
2026-09-06 15:24:59 +08:00
Zhengchao An c99efd9477 test(admin): box remote target repair scenarios (#7277) 2026-09-06 15:23:30 +08:00
Zhengchao An 27d593fa35 test(odm): bypass loopback proxies in native list fixtures (#7276) 2026-09-06 15:22:50 +08:00
Zhengchao An 0137d14064 test(odm): refresh verified Linux E2E selection (#7271) 2026-09-06 14:48:41 +08:00
houseme 1dddf357cd test(scanner): add bounded cache cost microprofile (#7261) 2026-09-06 14:14:07 +08:00
houseme 395ba797fc fix(admin): bound peer probe retries to one round deadline (#7257) 2026-09-06 14:13:47 +08:00
houseme 51893abfbf feat(heal): pace running admin work at safe boundaries (#7255) 2026-09-06 14:13:35 +08:00
RustFS 92c17af8e3 ci(e2e): run distributed e2e on ubuntu-latest (#7253) 2026-09-06 14:13:21 +08:00
cxymds f17f31a3df feat(ilm): persist recovery controls for legacy tier journals (#7252) 2026-09-06 14:13:09 +08:00
Zhengchao An 1a88870809 fix(odm): preserve cursor compatibility and native source semantics (#7238) 2026-09-06 14:12:56 +08:00
唐小鸭 760c9d65be fix(replication): close IAM snapshot, marker purge and broadcast gaps (#7195) 2026-09-06 14:12:25 +08:00
houseme 08283d1fdc test(scanner): verify default scoped entry fallback walks (#7241) 2026-09-06 14:11:45 +08:00
houseme 6a323c3e91 test(heal): cover start retry and deadline outcome contracts (#7242) 2026-09-06 14:11:24 +08:00
Zhengchao An 1650f3c2a6 test(odm): verify global disable across restarts (#7268)
* test(odm): cover global disable across restarts

* test(odm): accept omitted V1 next marker

* test(odm): gate backfill GET until the crash completes

* test(odm): remove unused fault action import

* test(odm): assert GET gate suspension without network races

* test(odm): refresh verified Darwin E2E selection
2026-09-06 13:54:59 +08:00
Zhengchao An 38611d2510 fix(admin): compile metadata test helper only in tests (#7266)
fix(admin): compile metadata update facade only for tests
2026-09-06 13:28:58 +08:00
houseme 1a459d650f chore(deps): update flake.lock (#7264)
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:NixOS/nixpkgs/e8be781' (2026-08-29)
  → 'github:NixOS/nixpkgs/17de0b9' (2026-09-04)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/996e9b0' (2026-08-29)
  → 'github:oxalica/rust-overlay/c361047' (2026-09-05)
2026-09-06 13:19:50 +08:00
Zhengchao An 5aef1796cc fix(odm): reject ambiguous native source dot segments (#7263)
* fix(odm): reject ambiguous native source dot segments

* docs(odm): align native provider limitations with implementation
2026-09-06 13:11:35 +08:00
Zhengchao An a8aaadb886 fix(replication): preserve concurrent remote target changes (#7262)
* test(replication): cover concurrent remote target writes

* fix(replication): merge remote target writes under transactions

* test(replication): preserve concurrent repairs during target updates

* fix(replication): retain removal guards after request cancellation

* test(replication): enable loopback in ordinary target fixtures

* test(replication): isolate remote target mutation scenarios

* refactor(admin): keep target writes within existing boundaries

* test(replication): assert removed target cache state

* test(replication): inspect target cache before client refresh
2026-09-06 12:51:44 +08:00
Zhengchao An 71f1dcf859 fix(admin): follow recovery facade and error boundaries (#7259) 2026-09-06 12:35:54 +08:00
Zhengchao An d44244f60f fix(admin): preserve metadata during export and target repair (#7258)
* fix(admin): reject incomplete metadata backups

* fix(admin): repair remote targets from locked disk state

* test(admin): fence target repair against source changes

* fix(admin): report unreadable XML in metadata exports

* test(admin): enable loopback in target repair fixtures

* refactor(admin): remove unused metadata getter forwards

* test(admin): box direct target repair futures

* test(admin): box target repair scenarios at env boundary
2026-09-06 12:18:00 +08:00
Zhengchao An d3884ed3ea test(odm): use fixed-size Azure request chunks (#7254) 2026-09-06 12:10:39 +08:00
Zhengchao An 282d6d5efe fix(odm): decode encoded Azure blob names exactly once (#7251)
* fix(odm): decode encoded Azure blob names exactly once

* test(odm): cover Azure encoded name transport matrix

* test(odm): keep Azure cursor continuation query stable
2026-09-06 11:10:40 +08:00
RustFS f0d865728c ci(e2e): use tmpfs for distributed pool isolation (#7207)
* test(e2e): add distributed 4x4 validation

* ci(e2e): use tmpfs for distributed pool isolation

sm-standard-4 is an ARC pod without usable loop devices, so
mount -o loop fails with ENOENT before any pool filesystem is
attached. Sized tmpfs still gives each pool a distinct st_dev
and independent 1G statfs capacity.

Co-authored-by: RustFS <hello@rustfs.com>

* test(e2e): prove operations overlap data movement

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-09-06 10:52:12 +08:00
cxymds 3df39ef4d7 fix(scanner): separate write retries from movement backlog (#7249) 2026-09-06 10:51:46 +08:00
Zhengchao An b0c73c1224 fix(ecstore): retain namespace owners through local disk completion (#7245)
* fix(ecstore): retain namespace owners through local physical tails

(cherry picked from commit a2f242463316e87604feadbdac5e4148140e72c0)

* test(ecstore): expose stale fsync group cleanup

* fix(ecstore): capture complete fsync worker guard

(cherry picked from commit 1dc90bb836e20ea9ee45d0a629a9201e20d231c4)

* fix(ecstore): preserve successor fsync group registration

(cherry picked from commit c7dfaad90526052e56c57dafffa4813bdcde46ca)

* test(ecstore): mark physical owner fixtures as inline

* test(ecstore): wait for namespace owner release before asserting

The namespace owner tests decided that ownership had ended when the Weak probe stopped upgrading or when the mutation lease could be reacquired. Both signals fire before the owner guard's Drop decrements the pending counter: Arc releases its strong count before running Drop, and the lease drops its locks before its owner field. The rio-v2 lane hit that window in undo_fresh_version_keeps_physical_namespace_owner_after_timeout.

Extend every drain wait to also require namespace_commits_pending() to be false, so the assertions observe the completed release instead of racing it.
2026-09-06 10:51:38 +08:00
houseme 54c11ef28b test(scanner): bound segment observation diagnostics (#7240)
* test(scanner): bound segment observation diagnostics

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): observe committed fixture changes during walks

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): validate segment fixture metadata and off state

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-06 10:51:30 +08:00
houseme e99c41a9bf test(scanner): verify restart evidence against tested builds (#7232)
* chore(deps): refresh scanner heal batch dependency baseline

Regenerate compatible lockfile selections before the next implementation
batch. Cargo upgrade leaves direct requirements unchanged.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* chore(deps): refresh profiling dependencies for the next batch

Update hotpath and its macro crate to the compatible patch release before
the next dependency-ready implementation tasks.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(deps): preserve supported hotpath focus expressions

Keep the profiler runtime before its regex-lite compatibility regression.
Track the opt-in validation required to remove this constraint in backlog.

Refs rustfs/backlog#2302.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add bounded ABBA validation harness

Refs rustfs/backlog#2266 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): verify real restart evidence before release gates

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(test): bind scanner evidence to execution and build identity

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* docs(test): use the nextest workspace report directory

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(test): reap ABBA leaders only after process-group cleanup

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(test): preserve inclusive ABBA thresholds

Use decimal boundary comparisons for ABBA ratio checks and cover exact documented p99, throughput, and P1 limits.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-06 10:51:22 +08:00
houseme fddecf0afe test(scanner): diagnose raw enumeration across restarts (#7228)
* test(scanner): diagnose raw enumeration across process restarts

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): observe the canonical synthetic disk path

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): reject unobserved enumeration budget evidence

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): remove redundant disk path clone

* fix(app): keep list-through header import test-only

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-06 10:51:14 +08:00
houseme 55fd73ed48 feat(heal): add pending legacy MRF migration staging (#7219)
* chore(deps): refresh scanner heal batch dependency baseline

Regenerate compatible lockfile selections before the next implementation
batch. Cargo upgrade leaves direct requirements unchanged.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* chore(deps): refresh profiling dependencies for the next batch

Update hotpath and its macro crate to the compatible patch release before
the next dependency-ready implementation tasks.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(deps): preserve supported hotpath focus expressions

Keep the profiler runtime before its regex-lite compatibility regression.
Track the opt-in validation required to remove this constraint in backlog.

Refs rustfs/backlog#2302.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(heal): add explicit committed MRF snapshot reader

Refs rustfs/backlog#2263 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* docs(heal): register legacy MRF inspection cleanup

State the compatibility removal condition on the source marker and in
the architecture cleanup register.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover ambiguous complete legacy MRF replicas

Cover complete subset replicas, differing sets and unknown scope in both
disk orders, preserving all original journal evidence during inspection.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(heal): stage conservative legacy MRF migration evidence

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): reject incomplete migration lineage before staging

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): validate migration retries against complete lineage

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): keep reused MRF migration slots retryable

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(heal): cover source-change retry on reused MRF slots

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): retain unmatched migration manifest evidence

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-06 10:51:04 +08:00
houseme 1ae80c41ec feat(heal): record canonical object and task outcomes (#7218)
* chore(deps): refresh scanner heal batch dependency baseline

Regenerate compatible lockfile selections before the next implementation
batch. Cargo upgrade leaves direct requirements unchanged.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* chore(deps): refresh profiling dependencies for the next batch

Update hotpath and its macro crate to the compatible patch release before
the next dependency-ready implementation tasks.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(deps): preserve supported hotpath focus expressions

Keep the profiler runtime before its regex-lite compatibility regression.
Track the opt-in validation required to remove this constraint in backlog.

Refs rustfs/backlog#2302.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(heal): record bounded canonical object and task outcomes

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): preserve cancellation and retry only failed listing pages

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): preserve compatible listing EOF outcomes

Keep truncated heal listings without continuation tokens as complete compatibility EOFs and assert the canonical task outcome.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): drop test locks before awaits

Limit synchronous mock mutex guards to pre-await scopes in canonical outcome tests.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-06 10:50:56 +08:00
GatewayJ 9fc9b5e69c fix(ecstore): align platform and test helper compilation (#7214)
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-06 10:50:48 +08:00
Zhengchao An 61e0edce16 test(odm): pin gcs error classes and require the backend contracts in ci (#7250)
* test(odm): pin gcs source status-to-error-class mapping

The native GCS backend classifies every failure from the HTTP status
alone, because GCS states its error code in a body this backend never
reads. Only NotFound is negative-cached and only a retryable class may be
re-sent, so cover 401/403 -> AccessDenied, 429/503 -> Throttled,
500/502 -> ServerError and 404 -> NotFound over both HEAD and GET.

* ci(odm): require the source-backend contract tests in test-and-lint

The shared contract tests already run in ci/test-and-lint, but only
because gcs is a rustfs default feature; nothing failed if that
selection went away. Pin the S3, Azure and native GCS contracts in the
core required-test manifest so a lost selection fails the lane.
2026-09-06 10:45:09 +08:00
houseme 2b5c739343 fix(scanner): require complete publication coverage (#7176)
* chore(deps): refresh SDKs and pin clock skew regression coverage

Refresh compatible dependencies for Scanner/Heal V2 batch 1 and verify
the production S3 retry/signing path with a deterministic clock.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): require complete publication coverage

Refs rustfs/backlog#2261 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ci): keep s3s footprint ratchet tight

Route bucket list-through test-only S3 wire types through the app storage facade again so the PR does not add a direct s3s-importing file.

Retighten the s3_error! footprint baseline to the current lower count.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(odm): expose store context after relocation

Expose the ECStore instance context through a narrow accessor so relocated on-demand migration backfill code no longer reaches into private storage fields.

Declare the faster-hex dependency used by the relocated native HTTP source implementation.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): stabilize usage and ODM regressions

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-06 10:43:43 +08:00
Zhengchao An 5dca076efe fix(connect): sync protocol fixture consumers (#7167)
* fix(connect): sync protocol fixture consumers

* fix(connect): preserve enrollment validation order

* fix(connect): satisfy base64 length lint

* fix(connect): restore signature validation order

* fix(connect): preserve signature precedence across chain parsing

* fix(connect): preserve signature error classification

* test(ci): provide log path in workflow harness
2026-09-06 10:12:51 +08:00
RustFS 07833379b4 test(e2e): add distributed cluster regression coverage (#7158)
* test(e2e): add distributed 4x4 validation

* test(e2e): prove operations overlap data movement

---------

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-06 10:12:06 +08:00
Zhengchao An 6655272c90 fix(ilm): reject invalid retention counts and validate lifecycle filters (#7132)
* fix(ilm): reject invalid retention counts and validate lifecycle filters

`NewerNoncurrentVersions` had no lower bound at PUT, and evaluation read a
negative count through `usize::try_from(...).unwrap_or(usize::MAX)`. An
HTTP-accepted rule therefore retained (almost) everything and silently
stopped expiring versions — the one outcome a retention rule must never
produce by accident.

Reject a negative count during validation, and stop reading one as
"retain everything" anywhere it can still arrive from older persistence
or an import: evaluation takes no action for such a rule and says so in a
diagnostic, the batch limit path yields no event, and `Evaluator::eval`
reports a typed corruption error to callers that can surface one.

A count-only noncurrent expiration is a MinIO extension, not an AWS form.
It used to be rejected as an actionless rule and was never executed. It
is now accepted and honoured with the semantics MinIO gives it: the
newest N noncurrent versions are kept and every older one is due as soon
as it became noncurrent. Zero keeps the meaning the batch limit path has
always given it — no count constraint — so a zero-count rule with no age
condition still has no action.

`LifecycleRuleFilter` is an all-`Option` DTO, so the schema constraints
were not checked anywhere: validate at most one top-level predicate, an
`And` that combines at least two, no repeated tag key, tag key/value
limits, non-negative sizes, and `ObjectSizeGreaterThan <
ObjectSizeLessThan`. An empty filter stays valid — AWS documents it as
"every object in the bucket".

Schema-shape violations are reported with a distinct `ErrorKind` so the
S3 boundary answers them with `MalformedXML`; rejected values keep the
`InvalidArgument` this path has always returned.

backlog#2201

* fix(ilm): satisfy lifecycle clippy checks

* fix(ilm): fail closed on invalid lifecycle rules

* fix: initialize optional migration source fields

---------

Co-authored-by: cxymds <cxymds@gmail.com>
2026-09-06 10:11:05 +08:00
cxymds 0a5d4cef0e fix(tier): drain cleanup before tier removal (#7213)
Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-06 10:10:48 +08:00
cxymds 941fae61a0 fix(ilm): persist bounded transition recovery controls (#7200)
* fix(ilm): persist bounded transition recovery controls

* test(ilm): keep expiry sentinel within control range

* fix(ilm): repair recovery control CI regressions

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-06 10:10:24 +08:00
Zhengchao An 03fa62cc7d fix(admin): keep exporting past unreadable configs and allow explicit target repair (#7247)
* test(ecstore): pin MinIO array-shaped targets blob as unreadable

* fix(admin): mark unreadable configs instead of aborting export

* feat(admin): opt-in replacement of unreadable bucket targets
2026-09-06 10:03:32 +08:00
cxymds b59dea826f fix(ci): enable test utilities in migration gate (#7246) 2026-09-06 01:21:11 +00:00
houseme 081a8b61d8 test(scanner): add bounded ABBA validation harness (#7181)
* chore(deps): refresh SDKs and pin clock skew regression coverage

Refresh compatible dependencies for Scanner/Heal V2 batch 1 and verify
the production S3 retry/signing path with a deterministic clock.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add bounded ABBA validation harness

Refs rustfs/backlog#2266 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): harden ABBA threshold evaluation

Handle exact threshold comparisons without binary floating point boundary drift and mark unstable P1 walk controls inconclusive.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): reject invalid ABBA evidence and boundary drift

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-06 07:27:46 +08:00
houseme 159dc13548 fix(scanner): bind resumable scans and cache publication coverage (#7210)
* chore(deps): refresh scanner heal batch dependency baseline

Regenerate compatible lockfile selections before the next implementation
batch. Cargo upgrade leaves direct requirements unchanged.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* chore(deps): refresh profiling dependencies for the next batch

Update hotpath and its macro crate to the compatible patch release before
the next dependency-ready implementation tasks.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(deps): preserve supported hotpath focus expressions

Keep the profiler runtime before its regex-lite compatibility regression.
Track the opt-in validation required to remove this constraint in backlog.

Refs rustfs/backlog#2302.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): require complete publication coverage

Refs rustfs/backlog#2261 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): retain scoped partial coverage across dirty plans

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): keep stable snapshot rescan behavior

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): verify coverage receipts and scan strength

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): use valid modification times in checkpoint fixtures

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): keep maintenance cycles outside dirty bucket scopes

Force complete bucket scope for deep scans and scheduled maintenance while
preserving the existing planner for verified ordinary dirty work.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): refresh scope safety independently of idle backoff

Inspect maintenance on multi-disk startup and refresh changed or failed
evidence even when explicit bitrot configuration disables idle backoff.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): bind bucket cache reuse to scan work requirements

Carry stable scan mode and full-maintenance requirements in the existing
opaque bucket digest before local and remote cache admission. Different
requirements cannot replay a same-cycle Normal cache after root delivery
failure; matching requirements remain reusable for the same intent.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): fence set snapshot reuse with the scan work proof

Prevent same-cycle set publication from replacing freshly scanned maintenance
results with an older Normal aggregate. Recognize uniform completed
maintenance baselines when planning later ordinary dirty-bucket work.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): reproduce same-cycle dirty aggregate replay

Cover a Normal-to-Normal retry with a new dirty bucket generation after
bucket persistence and root delivery failure.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): fence same-cycle caches with full activity coverage

Keep structural baseline identity separate from the full activity coverage
required by bucket admission and set publication. Require complete set
coverage proofs while retaining revision CAS and epoch regression checks.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): supply explicit coverage in publication fixtures

Keep the confirmed-empty namespace fixture authoritative under the required
coverage contract and qualify the bucket cache metadata test type.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): verify joint checkpoint coverage metadata

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): satisfy cache prefix sort lint

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-06 07:27:18 +08:00
Zhengchao An bde4b78a9f test(odm): run multipart race on large stack (#7239) 2026-09-06 04:46:44 +08:00
Zhengchao An c861fe3a57 ci(e2e): install network fault-injection tools (#7244) 2026-09-06 04:37:40 +08:00
Zhengchao An e1608fbd9c test(odm): exercise overflow and invalid cursors reliably (#7236) 2026-09-06 03:09:39 +08:00
Zhengchao An eb1b17802c test(odm): provide the source region in access fixture (#7235) 2026-09-06 02:34:56 +08:00
Zhengchao An 112f70914d fix(build): scope migration helpers to their features (#7234)
* test(odm): keep listing header import test scoped

* fix(build): gate GCS-only migration HTTP helpers
2026-09-06 02:24:04 +08:00
Zhengchao An ea9aa53fd8 docs(odm): record upgrade limits in release notes (#7233) 2026-09-06 02:09:44 +08:00
Zhengchao An dd368f0f5b fix(odm): fence source work against bucket recreation (#7231)
* fix(odm): fence backfill checkpoints by bucket incarnation

* fix(odm): bind source work to the bucket incarnation

* fix(odm): retain checkpoint fences through owned commit tails

* docs(odm): explain application service and incarnation boundaries

* test(odm): probe lifecycle fence after checkpoint waiter aborts

* fix(odm): defer source identity errors past local reads

* docs(metadata): clarify MinIO target recovery limits

* fix(odm): keep source-free reads independent of capture errors

* fix(odm): retain one source policy snapshot across lookup

* test(odm): name recorded metadata hook snapshots
2026-09-06 02:05:43 +08:00
Zhengchao An 6d8606412e fix(odm): compile relocated instance-bound backfill service (#7230) 2026-09-06 01:43:14 +08:00
Zhengchao An 037354cec0 fix(build): declare relocated migration service dependencies (#7229) 2026-09-06 01:37:02 +08:00
Zhengchao An 30ab919bb3 fix(ci): route v1 listing test types through application bridge (#7227) 2026-09-06 01:35:05 +08:00
Zhengchao An 8fc1c9281e refactor(odm): move migration orchestration into application (#7226)
* refactor(odm): move migration orchestration into application

* style(odm): format relocated listing test imports
2026-09-06 01:31:58 +08:00
Zhengchao An 14cef91423 fix(admin): add isolated bucket metadata diagnostics (#7225) 2026-09-06 01:30:18 +08:00
Zhengchao An c9acc33720 test(odm): verify rc5 rollback configuration recovery (#7224)
* test(odm): verify rc5 rollback configuration recovery

* ci(e2e): run the ODM rollback recovery scenario
2026-09-06 01:28:54 +08:00
Zhengchao An 955d491174 feat(build): make native GCS backends optional (#7223)
* feat(build): make native GCS backends optional

* test(odm): cover native Azure runtime credentials
2026-09-06 01:27:36 +08:00
Zhengchao An 1c4e9f1b65 fix(odm): order initial installation against configuration removal (#7222)
* fix(odm): retain removal generation before initial install

* fix(odm): reserve generations only for configured buckets
2026-09-06 01:26:57 +08:00
houseme a9f01dbbdb fix(ecstore): restore odm source contract tests (#7215)
* fix(ecstore): restore odm source contract tests

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(ci): initialize replication evidence in chain test

Run the replication workflow's evidence initialization before the chain handoff self-test executes the suite step. This keeps the test model aligned with the workflow-provided LOG_FILE and TMPDIR values.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(odm): distinguish missing GCS buckets from object misses (#7221)

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-06 01:26:08 +08:00
Zhengchao An 35aefbb2a5 fix(s3): keep ListObjects v1 local during migration (#7220)
* fix(s3): keep ListObjects v1 local during migration

* test(s3): use the v1 listing request DTO directly
2026-09-06 01:25:44 +08:00
Zhengchao An 8fb335cf19 test(e2e): pin upgrade compatibility to rc.5 and cover bucket configuration (#7217)
test(e2e): prove bucket config survives rc.5 upgrade and rollback

Add two upgrade-compatibility scenarios pinned to the on-demand-migration
series' on-disk surfaces: BucketMetadata's 44 -> 46 msgpack keys, the
fail-closed bucket-config reads of rustfs#7172, the encryption-gated PUT
path of rustfs#7183, and the default-on migration module of rustfs#7089.

The upgrade case writes versioning, SSE-S3 default encryption, a validated
replication target plus rule, lifecycle, tags, quota, a public access block,
a bucket policy and an object lock configuration with the pinned previous
release, then asserts each one reads back unchanged on the current build,
that list-remote-targets still reports the target, that writes to the
encrypted and plain buckets keep their encryption posture, that every
pre-upgrade object including a multipart one is byte-identical, and that an
unconfigured bucket reports no migration and still answers NoSuchKey.

The rollback case is the reverse: the current build writes the 46-key blob
and the previous release must decode it by skipping the two unknown keys.
2026-09-06 01:08:22 +08:00
Zhengchao An 8f763fb1a2 fix(ci): run existing script contracts in quick checks (#7203)
* fix(ci): share quick checks and lint workflows

* fix(ci): install actionlint from its verified release

* fix(ci): reject dependencies on required quick checks

* fix(ci): run existing script contracts in quick checks
2026-09-06 00:50:06 +08:00
Zhengchao An a6b5da64f2 fix(ci): serialize performance on shared functional VMs (#7204) 2026-09-06 00:15:03 +08:00
Zhengchao An 1210428b6d fix(ci): publish immutable nightly package candidates (#7202) 2026-09-06 00:14:37 +08:00
Zhengchao An d5426f59ec fix(ci): isolate functional evidence and preserve every result (#7201)
* fix(ci): preserve reported functional suite failures

* fix(ci): isolate functional evidence and preserve every result

* fix(ci): exclude sensitive scratch files from suite artifacts
2026-09-06 00:14:22 +08:00
houseme f54323b062 chore(deps): preserve scanner and heal validation compatibility (#7209)
* chore(deps): refresh scanner heal batch dependency baseline

Regenerate compatible lockfile selections before the next implementation
batch. Cargo upgrade leaves direct requirements unchanged.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* chore(deps): refresh profiling dependencies for the next batch

Update hotpath and its macro crate to the compatible patch release before
the next dependency-ready implementation tasks.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(deps): preserve supported hotpath focus expressions

Keep the profiler runtime before its regex-lite compatibility regression.
Track the opt-in validation required to remove this constraint in backlog.

Refs rustfs/backlog#2302.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(rustfs): complete list-through source config

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-05 23:50:43 +08:00
hector cc5060ac20 ci(functional): fix dashboard report upload argv overflow and security checkout clobbering (#7212)
Two fixes for the functional test chain:

1. Report upload fails with 'jq: Argument list too long' when the base64
   report is passed through '--arg content' (pool reports exceed the OS
   argv limit; last night's pool run lost its Step Results report this
   way). Write the base64 payload to a temp file and load it in jq via
   --rawfile instead. Applied uniformly to all nine suite workflows
   that share this upload step.

2. The security workflow cloned rustfs/auto-testing into the workspace
   and then ran actions/checkout at the workspace root for the OIDC
   live gate script, which wiped the auto-testing clone and killed the
   suite with 'chmod: cannot access auto-testing/rustfs-security-test.sh'.
   Check out the repository into the rustfs-repo/ subdirectory instead
   and point RUSTFS_SECURITY_OIDC_LIVE_SCRIPT there.

Co-authored-by: rustfs-ci <ci@rustfs.com>
2026-09-05 23:06:28 +08:00
Zhengchao An 188f380b3b feat(ecstore): add native azure blob and gcs migration sources (#7211)
* feat(ecstore): add a native azure blob odm source backend

* feat(ecstore): add a native gcs odm source backend and one backend contract

* fix(ecstore): refuse an empty azure account key at client build

* fix(ecstore): probe gcs sources with the listing permission

* fix(app): drop a redundant match guard on the sse config lookup

* fix(ecstore): drop stale rename commit duplicates from local.rs

* test(ecstore): use the sanctioned placeholder key in the gcs fixture
2026-09-05 22:06:30 +08:00
Zhengchao An e2a921bc16 fix(storage): harden ODM and scanner publication (#7187)
* fix(storage): harden ODM and scanner publication

* fix(app): simplify absent SSE configuration matching

* test(heal): settle PUT rename tails before disk-wipe fixtures

* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ci): satisfy new clippy lints

* style(scanner): order merged test imports

* fix(scanner): invalidate bucket work after namespace completion

* fix(scanner): fence cached snapshots by scan execution

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 13:47:12 +00:00
houseme 447f3c704b feat(heal): add explicit committed MRF snapshot reader (#7179)
* chore(deps): refresh SDKs and pin clock skew regression coverage

Refresh compatible dependencies for Scanner/Heal V2 batch 1 and verify
the production S3 retry/signing path with a deterministic clock.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(heal): add explicit committed MRF snapshot reader

Refs rustfs/backlog#2263 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* docs(heal): register legacy MRF inspection cleanup

State the compatibility removal condition on the source marker and in
the architecture cleanup register.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 13:33:41 +00:00
Zhengchao An d915f9565e fix(ci): preserve reported functional suite failures (#7199) 2026-09-05 13:26:23 +00:00
Zhengchao An 55ad7508b9 fix(tier): persist coordinator intent before waking refresh (#7171) 2026-09-05 13:24:00 +00:00
Zhengchao An 33fd056000 fix(ecstore): release heal disk snapshot before nested reads (#7189)
* fix(ecstore): release heal disk snapshot before nested reads

* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(app): simplify absent SSE configuration matching

* fix(tests): satisfy new clippy lints

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 12:49:40 +00:00
RustFS af2e9df821 fix(lifecycle): correct expiration and transition evaluation (#7169) 2026-09-05 12:42:22 +00:00
cxymds 0a92a7d98c fix(tier): bound remote transition requests (#7147)
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-05 12:41:11 +00:00
Zhengchao An c589fd2439 fix(dev): install a lightweight formatting commit hook (#7198) 2026-09-05 12:22:56 +00:00
Zhengchao An 3e5d4ebb09 fix(ecstore): release multipart disk snapshot before nested reads (#7184)
* fix(ecstore): release multipart disk snapshot before nested reads

* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(app): simplify absent SSE configuration matching

* fix(tests): satisfy new clippy lints

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 12:20:50 +00:00
houseme 3677871468 chore(deps): bump zstd to 0.14 (#7173)
Signed-off-by: houseme <housemecn@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 12:16:46 +00:00
Zhengchao An cc1ec6b992 fix(ci): share quick checks and lint workflows (#7194)
* fix(ci): share quick checks and lint workflows

* fix(ci): install actionlint from its verified release

* fix(ci): reject dependencies on required quick checks
2026-09-05 12:03:59 +00:00
Zhengchao An 9e2545244c fix(odm): bound empty pagination chains with staged tokens (#7197)
* fix(odm): add staged cross-request pagination progress budgets

* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(app): simplify absent SSE configuration matching

* fix(tests): satisfy new clippy lints

---------

Co-authored-by: houseme <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 11:44:49 +00:00
Zhengchao An f053862aad docs: request concrete behavior evidence in pull requests (#7196) 2026-09-05 18:56:26 +08:00
houseme e8a7f4bc4a fix(ecstore): remove duplicate local rename implementation (#7190)
* fix(ecstore): remove duplicate local rename implementation

Keep the canonical commit module after concurrent storage changes merged.
The control-write and rollback changes are already present there.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(ci): satisfy new clippy lints

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-05 10:44:01 +00:00
houseme 7ba5cd6888 chore(deps): refresh SDKs and verify clock skew behavior (#7174)
chore(deps): refresh SDKs and pin clock skew regression coverage

Refresh compatible dependencies for Scanner/Heal V2 batch 1 and verify
the production S3 retry/signing path with a deterministic clock.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 09:06:52 +00:00
houseme acfeef55ab feat(scanner): add bounded incarnation-scoped ACK receiver (#7182)
* chore(deps): refresh SDKs and pin clock skew regression coverage

Refresh compatible dependencies for Scanner/Heal V2 batch 1 and verify
the production S3 retry/signing path with a deterministic clock.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* feat(scanner): add bounded incarnation-scoped ACK receiver

Refs rustfs/backlog#2265 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 08:54:55 +00:00
Zhengchao An 0d1b312673 fix(ci): require fresh successful scheduled validations (#7192) 2026-09-05 16:49:58 +08:00
houseme 42c32381b6 fix(scanner): make reset cleanup safely reentrant (#7180)
* chore(deps): refresh SDKs and pin clock skew regression coverage

Refresh compatible dependencies for Scanner/Heal V2 batch 1 and verify
the production S3 retry/signing path with a deterministic clock.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(scanner): make reset cleanup safely reentrant

Refs rustfs/backlog#2264 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 08:45:53 +00:00
houseme e6bf2a4646 fix(admin): report partial background heal coverage (#7178)
* chore(deps): refresh SDKs and pin clock skew regression coverage

Refresh compatible dependencies for Scanner/Heal V2 batch 1 and verify
the production S3 retry/signing path with a deterministic clock.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(admin): report partial background heal coverage

Refs rustfs/backlog#2035 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 08:45:45 +00:00
houseme cf9688898d fix(heal): retain completed task progress (#7177)
* chore(deps): refresh SDKs and pin clock skew regression coverage

Refresh compatible dependencies for Scanner/Heal V2 batch 1 and verify
the production S3 retry/signing path with a deterministic clock.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* fix(heal): retain completed task progress

Refs rustfs/backlog#2262 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 08:39:26 +00:00
Henry Guo 2d159635ed feat(scanner): plan dirty bucket cache refreshes (#7146)
* feat(scanner): plan dirty bucket cache refreshes

* fix(scanner): route peer snapshot through storage boundary

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: cxymds <cxymds@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-05 16:32:02 +08:00
Zhengchao An 2f02d1d2d8 fix(ci): preserve security suite failures and isolate reports (#7188) 2026-09-05 16:26:13 +08:00
Zhengchao An 8ae8fb7eea fix(ecstore): drain control writes and preserve uncertain rollback (#7163)
* fix(ecstore): drain durable control-plane write tails

* fix(ecstore): retain PUT staging after incomplete rollback

* fix(ecstore): drain backfill checkpoint before confirmation

* fix(ecstore): retain per-disk rename rollback outcomes

* fix(ecstore): retain indeterminate rename recovery evidence

* test(ecstore): mark rollback fixtures as inline data

* test(ecstore): match sealed context fixture map type

* fix(ecstore): preserve known preflight rename rejections

* test(ecstore): cover observed rename outer failures

* test(ecstore): count decommission faults across retry restarts
2026-09-05 08:16:53 +00:00
cxymds bbd7b9ef17 fix(site-replication): bound and order outage recovery (#7148)
* fix(site-replication): wake retry drain after peer recovery

* fix(site-replication): replay configure after bucket make

* fix(site-replication): serialize retry replay state

* fix(site-replication): persist destructive retry intents

* fix(site-replication): bound retry recovery rounds

* fix(site-replication): keep recovery replay live

* fix(site-replication): preserve retry ordering

* fix(site-replication): bound retry coordination

* fix(site-replication): serialize topology replay

* fix(site-replication): fence distributed retry state

* fix(site-replication): bound outage retry drain

* fix(site-replication): drop unsafe delete retry intents

* fix(site-replication): order bucket mutation replay

* fix(site-replication): harden outage retry replay

* fix(site-replication): fence destructive peer delivery

* fix(site-replication): avoid peer edit retry deadlock

* fix(site-replication): fence retry error classification

* fix(site-replication): classify connect timeouts

* fix(site-replication): close recovery review races

* test(site-replication): cover timeout endpoint text

* fix(site-replication): close destructive recovery gaps

* fix(site-replication): fence recovery revisions

* fix(site-replication): replay bucket metadata on recovery

* fix(site-replication): preserve s3gate boundary

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-09-05 15:55:54 +08:00
Zhengchao An 15e9bc5ed0 test(ecstore): count injected faults across retry restarts (#7170)
test(ecstore): count decommission faults across retry restarts
2026-09-05 07:54:56 +00:00
Zhengchao An 882d9ca8a4 refactor(ecstore): isolate metadata quorum decisions (#7165)
* refactor(ecstore): isolate metadata quorum decisions

* test(ecstore): match sealed context fixture map type

* test(ecstore): count decommission faults across retry restarts
2026-09-05 07:46:13 +00:00
cxymds 19a29a7027 test(s3): add Snowball tar-codec compatibility fixtures (#7157)
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-05 07:41:48 +00:00
houseme 2e4ab045b6 test(scanner): add durable checkpoint diagnostics (#7175)
* chore(deps): refresh SDKs and pin clock skew regression coverage

Refresh compatible dependencies for Scanner/Heal V2 batch 1 and verify
the production S3 retry/signing path with a deterministic clock.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

* test(scanner): add durable checkpoint diagnostics

Refs rustfs/backlog#2260 and rustfs/backlog#2240.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-05 07:34:29 +00:00
Zhengchao An cbfd5b92f4 refactor(ecstore): isolate local object rename commit (#7166)
* fix(ecstore): drain durable control-plane write tails

* fix(ecstore): retain PUT staging after incomplete rollback

* fix(ecstore): drain backfill checkpoint before confirmation

* refactor(ecstore): isolate local object rename commit

* refactor(ecstore): remove moved quota fence import

* fix(ecstore): retain per-disk rename rollback outcomes

* fix(ecstore): retain indeterminate rename recovery evidence

* test(ecstore): mark rollback fixtures as inline data

* test(ecstore): match sealed context fixture map type

* test(ecstore): match sealed context fixture map type

* fix(ecstore): preserve known preflight rename rejections

* test(ecstore): cover observed rename outer failures

* test(ecstore): count decommission faults across retry restarts
2026-09-05 07:19:29 +00:00
Zhengchao An 971f9acdf4 fix(ecstore): reject stalled ODM pagination before merging pages (#7164)
* fix(odm): reject non-progressing listing cursors

* docs(odm): clarify folded source probe pagination

* test(odm): match SDK bucket-root listing requests

* test(ecstore): match sealed context fixture map type

* test(odm): use app facade for listing wire types

* fix(odm): resolve pagination Clippy failures
2026-09-05 07:17:25 +00:00
cxymds a6589c19e3 chore(tier): remove stage-a blanket lint allowances (#7153)
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-05 06:43:05 +00:00
hector 0885c721fe ci(upgrade): support manual runs between any two release versions (#7145)
The workflow_dispatch inputs already accept arbitrary release tags, but
the run failed late and unclearly when a tag had no .deb asset, and the
from_version default pointed at 1.0.0-rc.4-preview.1, whose release
ships no .deb at all - so scheduled runs died on a 404 while installing
the old package.

- Add a fail-fast preflight that resolves each requested tag via the
  GitHub release API and verifies the rustfs_<tag>_amd64.deb asset
  exists before the suite starts, with an actionable error message
  otherwise (e.g. 1.0.0-rc.4 ships only zip/sbom assets).
- Change the from_version default to 1.0.0-rc.3, the newest release
  that actually ships a .deb asset.
- Reword the from_version/to_version descriptions so manual triggers
  state the .deb-asset requirement and the nightly fallback.
- Pass PF_TESTING_GH_TOKEN as GH_TOKEN to the suite step for the gh api
  release lookups, matching the other functional workflows.

Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-05 06:39:47 +00:00
Zhengchao An eaf5159d0f ci: refresh Linux full E2E membership after test additions (#7156)
Co-authored-by: cxymds <cxymds@gmail.com>
2026-09-05 06:27:59 +00:00
Zhengchao An 2477e31059 test(ecstore): require core regressions in the existing CI lane (#7162)
* test(ecstore): require core invariant tests in existing CI lane

* test(ci): require a fresh core JUnit report

* test(ecstore): match sealed context fixture map type
2026-09-05 06:22:48 +00:00
Zhengchao An d8c3b1bb26 fix(app): fail closed on an unreadable bucket encryption config (#7183)
The object write path read the bucket default encryption configuration
with `.ok()`, which made "this bucket has no default encryption" and "the
encryption configuration cannot be read" the same value. A bucket whose
encryption blob is damaged therefore stored plaintext objects the
operator had mandated be encrypted, with nothing returned to the client
and nothing in the object to tell those writes apart afterwards.

PUT, COPY and the snowball extract path now share one resolver: an
absent configuration still writes plaintext exactly as before, and every
other outcome refuses the write, carrying the accessor's typed error so
a damaged blob surfaces as a deterministic InternalError while a
transient metadata read failure surfaces as the retryable
ServiceUnavailable. A missing bucket and a cold metadata cache both
still resolve to "no configuration", so neither becomes a refusal. This
matches `prepare_sse_configuration` in `storage::sse`, the resolver the
multipart writer has always used, which fails closed on this lookup.
2026-09-05 14:13:59 +08:00
cxymds a3b8183be9 test(ecstore): narrow barrier re-export cfgs (#7152)
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-05 06:12:45 +00:00
cxymds 4dbc58887a fix(tier): probe legacy transition version state (#7138) 2026-09-05 06:00:14 +00:00
Zhengchao An 123967e729 fix(ecstore): fail closed on an unreadable bucket-targets blob (#7172)
* fix(ecstore): correct sealed-credential test helper parameter type

The helper took a HashMap that nothing imports, so the ecstore test target did not compile.

* fix(ecstore): fail closed on an unreadable bucket-targets blob

An undecodable bucket-targets.json was replaced by an empty BucketTargets,
so every replication target of that bucket disappeared, replication stopped,
and no caller saw an error. A missing secretKey alone triggers it, because
Credentials has no struct-level serde(default).

parse_all_configs now retains the failure instead: the raw bytes stay and the
typed field stays None, which BucketMetadata::bucket_targets_unreadable reads
as "exists but cannot be read" — the same distinction the fabricated marker
draws for bucket metadata as a whole. One corrupt sub-config still never fails
the metadata load, so an unreadable bucket cannot take down its neighbours or
the node.

BucketTargetSys records such buckets and answers every targets query with the
new BucketRemoteTargetsUnreadable, leaving any snapshot from an earlier
readable load in place so in-flight replication is not torn down. The
replication heal queue reports Missed rather than scheduling against an empty
target set, and the admin listing surfaces the fault instead of an empty list.

Refs: rustfs/backlog#2282

* fix(ecstore): report corrupt permissive bucket configs as invalid

Audit of the remaining parse_all_configs branches. Policy, versioning, object
lock and replication already fail closed at their accessors; encryption,
public access block and quota did not, and for those three "absent" is exactly
the state that grants something — plaintext storage, anonymous access,
unbounded capacity. They now report a stored-but-undecodable payload as
invalid rather than as ConfigNotFound, matching the guard the versioning and
object-lock accessors already use. The quota enforcement path already refused
such a payload; only the metadata read path was misreporting it.

The branches left degrading, and the concrete reason each is safe, are
recorded in the table on parse_all_configs.

Refs: rustfs/backlog#2282
2026-09-05 13:02:23 +08:00
Zhengchao An 4b0d597d4d test(ecstore): fix sealed context fixture map type (#7161)
test(ecstore): match sealed context fixture map type
2026-09-05 11:54:24 +08:00
Zhengchao An 13e6424e99 docs(architecture): settle remote credential sealing threat model (#7168)
* docs(architecture): settle remote credential sealing threat model

* docs(architecture): index sealing ADR threat-model scope
2026-09-05 11:20:58 +08:00
cxymds b33693fc19 feat(tier): fence legacy state reconciliation (#7144) 2026-09-05 01:42:24 +00:00
395 changed files with 87347 additions and 9348 deletions
+2
View File
@@ -0,0 +1,2 @@
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=a881fd7d3f5cb94654221ca85b8b30cce1b95e608824a55a15339cbc294e6d34 sha256-darwin=53b05ac745905809d3828c6994bdd8ecf9d20b2b61a8a9d80fe15eb62f932193
sha256-linux=e9a8d64e73f627c4d26c236dbbba690c9ee03a9e26d42a4244515b4439365535 sha256-linux=7c892afa4b9d1591b46bd79c976b647109a277284fddb3b98edced4b0297eda2
+1 -1
View File
@@ -1 +1 @@
sha256=a2542dc86bbff56b2177efc621785c56fa7e8d813b209b7d935e1e41a9f0ad15 sha256=5db88c6fec94d4f269c7d9cfc128bd2adc27b3d7021127e2fa0b1daccc5f900f
+87
View File
@@ -0,0 +1,87 @@
{
"lane": "ci/test-and-lint",
"tests": [
{
"invariant": "write-quorum",
"suite": "rustfs-ecstore",
"name": "set_disk::ops::object::inline_put_commit_path_tests::inline_put_direct_commit_accepts_exact_quorum_and_rejects_quorum_minus_one"
},
{
"invariant": "metadata-rollback",
"suite": "rustfs-ecstore",
"name": "set_disk::core::io_primitives::tests::write_unique_file_info_reverts_metadata_when_write_quorum_fails"
},
{
"invariant": "stale-writer",
"suite": "rustfs-ecstore",
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::put_object_no_lock_aborts_after_outer_namespace_lock_loss"
},
{
"invariant": "range-body",
"suite": "rustfs-ecstore",
"name": "set_disk::ops::object::transition_upload_integrity_tests::transitioned_compressed_object_range_get_returns_plaintext_slice"
},
{
"invariant": "multipart-cancellation",
"suite": "rustfs-ecstore",
"name": "set_disk::ops::multipart::tests::cancelled_complete_keeps_upload_lock_through_tail_cleanup"
},
{
"invariant": "list-uncommitted-version",
"suite": "rustfs-filemeta",
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
},
{
"invariant": "minio-object-fixture",
"suite": "rustfs-filemeta",
"name": "filemeta::test::parses_real_minio_object_xlmeta"
},
{
"invariant": "corrupt-part-arrays",
"suite": "rustfs-filemeta",
"name": "filemeta::test::crc_valid_but_part_arrays_corrupt_into_fileinfo_errors_not_panics"
},
{
"invariant": "odm-source-contract-s3",
"suite": "rustfs",
"name": "on_demand_migration::source_client::tests::s3_backend_satisfies_the_shared_backend_contract"
},
{
"invariant": "odm-source-contract-azure",
"suite": "rustfs",
"name": "on_demand_migration::azure::tests::azure_backend_satisfies_the_shared_backend_contract"
},
{
"invariant": "odm-source-contract-gcs",
"suite": "rustfs",
"name": "on_demand_migration::gcs::tests::gcs_native_backend_satisfies_the_shared_backend_contract"
}
],
"fixtures": [
{
"path": "crates/filemeta/tests/fixtures/minio/object_large_bin.xlmeta.hex",
"sha256": "e8093767806d701e639b48d023190e858fbc4cde69bcfd83c22af8cba8452ce5",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
},
{
"path": "crates/filemeta/tests/fixtures/minio/object_small_txt.xlmeta.hex",
"sha256": "2a415ad3a3be5a9440035d4026ff880e0e8c1ec1701be9f4e077734e8dce03da",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
},
{
"path": "crates/filemeta/tests/fixtures/minio/object_versioned_txt.xlmeta.hex",
"sha256": "7f21f50c326dd8b0228deb6dbdb7052b3d0a3f8ee6c85d43486f0e6bb7a97261",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
},
{
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata.blob.hex",
"sha256": "f2b6e260aff106adf6039feb1c645686e84e75404ff725491fb18668be5db203",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
},
{
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata_full.xlmeta.hex",
"sha256": "3b6de589519c08a1614c8bd409bb8199c17d42043861b07bce513075e6fbfc12",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
}
]
}
+3 -2
View File
@@ -3,9 +3,10 @@
.NOTPARALLEL: pre-commit pre-pr dev-check .NOTPARALLEL: pre-commit pre-pr dev-check
.PHONY: setup-hooks .PHONY: setup-hooks
setup-hooks: ## Set up git hooks setup-hooks: ## Install the configured pre-commit hooks
@echo "🔧 Setting up git hooks..." @echo "🔧 Setting up git hooks..."
chmod +x .git/hooks/pre-commit pre-commit validate-config
pre-commit install
@echo "✅ Git hooks setup complete!" @echo "✅ Git hooks setup complete!"
.PHONY: doc-paths-check .PHONY: doc-paths-check
+3
View File
@@ -31,6 +31,7 @@ script-tests: ## Run shell script tests
./scripts/test_object_batch_bench_enhanced.sh ./scripts/test_object_batch_bench_enhanced.sh
./scripts/test_hotpath_warp_ab_gate.sh ./scripts/test_hotpath_warp_ab_gate.sh
./scripts/test_hotpath_warp_abba.sh ./scripts/test_hotpath_warp_abba.sh
./scripts/test_scanner_validation_harness.sh
./scripts/test_exact_1mib_handoff_abba.sh ./scripts/test_exact_1mib_handoff_abba.sh
./scripts/test_pinned_paired_abba_bench.sh ./scripts/test_pinned_paired_abba_bench.sh
./scripts/test_manual_transition_runbooks.sh ./scripts/test_manual_transition_runbooks.sh
@@ -40,6 +41,8 @@ script-tests: ## Run shell script tests
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test $(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test $(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test $(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py $(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test $(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
+5 -4
View File
@@ -1,10 +1,11 @@
# Committed floor for the number of tests selected by the migration-critical # Committed floor for the number of tests selected by the migration-critical
# CI gate (see scripts/check_migration_gate_count.sh, backlog#1153 infra-12). # CI gate (see scripts/check_migration_gate_count.sh, backlog#1153 infra-12).
# #
# The floor equals the exact count of rustfs-ecstore --lib tests matching the # The floor equals the exact count of rustfs-ecstore --lib tests, with the
# gate filter (name substrings: data_movement, rebalance, decommission, # test-util feature enabled, matching the gate filter (name substrings:
# source_cleanup, delete_marker) at the time this file was last updated. # data_movement, rebalance, decommission, source_cleanup, delete_marker) at
# the time this file was last updated.
# CI fails if the selected count drops below this number, so renames or # CI fails if the selected count drops below this number, so renames or
# removals that thin the gate must update this file in the same PR. # removals that thin the gate must update this file in the same PR.
# Adding tests does not require a bump, but bumping keeps the guard tight. # Adding tests does not require a bump, but bumping keeps the guard tight.
571 946
+33
View File
@@ -183,6 +183,13 @@ test-group = 'e2e-reliability'
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)' filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
test-group = 'e2e-inline-boundaries' test-group = 'e2e-inline-boundaries'
# 4-node 4-drive distributed Actions suite: each case starts four rustfs
# processes and up to sixteen data directories. Serialize across nextest's
# process boundary so several 4x4 clusters never overlap.
[[profile.default.overrides]]
filter = 'package(e2e_test) & test(/^distributed::/)'
test-group = 'e2e-cluster-nightly'
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial] # Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
# does not cross nextest process boundaries, so keep every Vault-backed test in # does not cross nextest process boundaries, so keep every Vault-backed test in
# one group. # one group.
@@ -526,6 +533,27 @@ path = "junit.xml"
filter = 'package(e2e_test)' filter = 'package(e2e_test)'
test-group = 'e2e-cluster-nightly' test-group = 'e2e-cluster-nightly'
# ---------------------------------------------------------------------------
# e2e-distributed profile — 4-node 4-disk Actions suite
# ---------------------------------------------------------------------------
# Storage-sensitive PR / nightly / dispatch lane owned by
# .github/workflows/e2e-distributed.yml.
# Each case starts four rustfs processes (and for site replication, two
# clusters). Upgrade cases also require RUSTFS_UPGRADE_SOURCE_BINARY.
# Serialized via e2e-cluster-nightly with no retries.
[profile.e2e-distributed]
default-filter = 'package(e2e_test) & test(/^distributed::/)'
fail-fast = false
# Decommission / rebalance cases poll for up to 180s with little stdout.
slow-timeout = { period = "120s", terminate-after = 6 }
[profile.e2e-distributed.junit]
path = "junit.xml"
[[profile.e2e-distributed.overrides]]
filter = 'package(e2e_test)'
test-group = 'e2e-cluster-nightly'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20) # e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -586,6 +614,10 @@ path = "junit.xml"
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not # cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
# excluded here because backlog#2213 promotes core heal rebuild coverage to # excluded here because backlog#2213 promotes core heal rebuild coverage to
# this merge/main lane while retaining nightly coverage. # this merge/main lane while retaining nightly coverage.
# * distributed:: — 4-node 4-disk Actions suite (S3, lock, versioning,
# replication, quota, observability, expand/decommission/rebalance, site
# replication, chaos, upgrade history/IAM). Owns [profile.e2e-distributed] and
# .github/workflows/e2e-distributed.yml.
# * on_demand_migration::interop_test — the ODM-20 provider interoperability # * on_demand_migration::interop_test — the ODM-20 provider interoperability
# cases, which are meaningless without a source: they run in the dedicated # cases, which are meaningless without a source: they run in the dedicated
# [profile.e2e-odm-interop] lane below, where the workflow points them at a # [profile.e2e-odm-interop] lane below, where the workflow points them at a
@@ -607,6 +639,7 @@ default-filter = """
package(e2e_test) package(e2e_test)
& !test(/^protocols::/) & !test(/^protocols::/)
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/) & !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& !test(/^distributed::/)
& !test(/^replication_extension_test::/) & !test(/^replication_extension_test::/)
& !test(/^replication_target_matrix_test::/) & !test(/^replication_target_matrix_test::/)
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/) & !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
+40
View File
@@ -0,0 +1,40 @@
{
"schema": 1,
"cases": {
"background-target-restart": {
"gate": "G14",
"task": "W21",
"lane": "e2e-nightly",
"suite": "e2e_test",
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart",
"oracle": "background-target-restart.json",
"min_objects": 9,
"max_objects": 65,
"topology": {"nodes": 4, "drives_per_node": 1},
"scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
}
},
"release_pending": {
"G01": "W02/W04 complete root and quota authority coverage",
"G02": "W03 bounded checkpoint progress and independent version inventory",
"G03": "W17/W18 exact scoped ACK with durable publication and mixed peers",
"G04": "W03/W15/W16 crash at every cache/root/floor/intent boundary",
"G05": "W06/W07 per-object outcomes and bounded terminal retention",
"G06": "W06/W08/W23 concurrent status, legacy clients and truncation",
"G07": "W12/W13/W14 durable MRF responsibility at every commit boundary",
"G08": "W12/W13/W14 MRF capacity, disk-full and replica-loss matrix",
"G09": "W13/W18/W23 actual mixed-version reader/writer and rollback payloads",
"G10": "W05/W09/W10/W11 bounded scheduling and pressure recovery",
"G11": "W04/W19/W24 maintenance and complete producer coverage",
"G12": "W02/W15/W16 both quota paths during reset and settlement",
"G13": "W07/W14 quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
"G14": "W20/W21 same-window field evidence; 3x4 EC8+4 and multi-set/pool coverage",
"P1": "W20 measured cold-walk share and foreground latency/throughput",
"P2": "W20/W24 measured post-stop convergence and cold segment reuse",
"P3": "W20 measured two-hour pressure/heal capacity and recovery window",
"P4": "W20 measured MRF scale and replay cost with retained responsibility",
"R-E": "W03/W05 fixed-budget real process restart through enumeration and classification",
"R-D": "W07/W14 manager-to-event-to-ledger exact disposition, including grace",
"R-L": "W13/W14 legacy source conflicts, migration gaps and crash-safe source retirement"
}
}
+120
View File
@@ -0,0 +1,120 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Quick Checks
description: Run the shared compile-free RustFS quality checks.
runs:
using: composite
steps:
- name: Install quality tools
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
with:
tool: |
ripgrep@15.2.0
shellcheck@0.11.0
- name: Install actionlint
shell: bash
run: |
actionlint_dir="$(mktemp -d "${RUNNER_TEMP}/actionlint.XXXXXX")"
curl --fail --location --silent --show-error \
--output "$actionlint_dir/actionlint.tar.gz" \
https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $actionlint_dir/actionlint.tar.gz" | sha256sum --check --status
tar -xzf "$actionlint_dir/actionlint.tar.gz" -C "$actionlint_dir" actionlint
rm "$actionlint_dir/actionlint.tar.gz"
echo "$actionlint_dir" >> "$GITHUB_PATH"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: rustfmt
- name: Check workflow syntax and shell scripts
shell: bash
run: shellcheck --version && actionlint
- name: Check code formatting
shell: bash
run: cargo fmt --all --check
- name: Check unsafe code allowances
shell: bash
run: ./scripts/check_unsafe_code_allowances.sh
- name: Check layered dependencies
shell: bash
run: ./scripts/check_layer_dependencies.sh
- name: Check architecture migration rules
shell: bash
run: ./scripts/check_architecture_migration_rules.sh
- name: Check logging guardrails
shell: bash
run: ./scripts/check_logging_guardrails.sh
- name: Check error other(format!) ratchet
shell: bash
run: ./scripts/check_error_other_format_ratchet.sh
- name: Check tokio io-uring feature guard
shell: bash
run: ./scripts/check_no_tokio_io_uring.sh
- name: Check extension schema boundaries
shell: bash
run: ./scripts/check_extension_schema_boundaries.sh
- name: Check body-cache whitelist guard
shell: bash
run: ./scripts/check_body_cache_whitelist.sh
- name: Check s3s footprint ratchet
shell: bash
run: ./scripts/check_s3s_footprint.sh
- name: Check cryptographic capability wording
shell: bash
run: ./scripts/check_fips_wording.sh
- name: Check no embedded secret material
shell: bash
run: ./scripts/check_embedded_secrets.sh
- name: Run script contract tests
shell: bash
run: make script-tests
- name: Check test wiring
shell: bash
run: |
python3 ./scripts/check_test_wiring.py --self-test
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
python3 ./scripts/test_security_workflow.py
python3 ./scripts/test_nightly_candidate.py
python3 ./scripts/check_test_wiring.py
- name: Check no planning docs committed
shell: bash
run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
shell: bash
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition
shell: bash
run: ./scripts/check_uring_lane_lib_only.sh
+5 -5
View File
@@ -10,16 +10,16 @@ Use N/A when there is no related issue.
## Summary of Changes ## Summary of Changes
<!-- <!--
Briefly explain what changed and why reviewers should accept it. Describe the concrete problem and resulting behavior. For a behavior change, name the input or state that triggers it and the expected outcome. Explain any new dependency or abstraction that the change needs.
Focus on behavior, compatibility, and review-relevant context.
--> -->
## Verification ## Verification
<!-- <!--
List the commands or checks you ran, for example: Give 13 concrete pieces of evidence for the changed behavior: the test or command, its observed result, and the regression it catches. For a bug fix, record a failing-before/passing-after check or explain why it was unavailable.
- `make pre-commit`
Use N/A only when verification is not applicable. Identify the tested commit and any local changes. When testing a prebuilt binary or external service, include its source/version and artifact identity; a successful run against a different build is not evidence for this change.
List relevant checks not run and the remaining risk. Use the validation tier in AGENTS.md; do not run broader checks solely to fill this section. For documentation-only changes, list the applicable documentation checks. Use N/A only when verification is not applicable.
--> -->
## Impact ## Impact
+5
View File
@@ -4,6 +4,11 @@
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 }, { "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 }, { "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 }, { "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
{
"workflow": ".github/workflows/e2e-distributed.yml",
"max_age_hours": 36,
"never_ran_grace_until": "2026-09-18T00:00:00Z"
},
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 }, { "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 }, { "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 }, { "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
+6 -88
View File
@@ -12,24 +12,10 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# Companion to ci.yml for required status checks. # Reports the existing required checks for paths excluded by ci.yml.
# # Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
# ci.yml skips docs-only pull requests via paths-ignore, but the branch ruleset # action to keep validation coverage aligned. Keep this paths list in sync with
# requires a check named "Test and Lint" — without this workflow a docs-only PR # ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
# would wait on it forever. This workflow triggers on exactly the paths ci.yml
# ignores and reports success under the same job name. Mixed PRs trigger both
# workflows and the real check still gates: a required check with any failing
# run blocks the merge.
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
#
# "Quick Checks" is mirrored here ahead of the ruleset change that will make it
# required too (rustfs/backlog#1599). Until that change lands this job is
# inert; mirroring it first is what lets the ruleset change happen without
# stranding docs-only PRs on a check nobody reports.
#
# Keep the paths list below in sync with the pull_request paths-ignore list
# in ci.yml, and keep the quick-checks steps below byte-identical to the
# quick-checks job in ci.yml.
name: Continuous Integration (docs only) name: Continuous Integration (docs only)
@@ -59,19 +45,6 @@ permissions:
contents: read contents: read
jobs: jobs:
# Deliberately NOT a bare `echo`. Once "Quick Checks" becomes a required
# check, ci.yml gates every expensive job behind it, so a mixed PR reports
# two check runs with this name: the real one (45-51s) and this companion.
# GitHub has no written contract for how it picks between same-named
# required check runs ("latest wins" vs "any failure blocks"), so instead of
# relying on ordering we make both runs execute the same commands against
# the same merge ref — their conclusions are then necessarily identical and
# the choice does not matter. Keep these steps byte-identical to the
# quick-checks job in ci.yml (a guard script that asserts this, and the paths
# sync below, is tracked in rustfs/backlog#1603).
#
# For a genuinely docs-only PR this adds no strictness (no code changed, so
# fmt and the guards always pass) and costs ~50s of ubuntu-latest.
quick-checks: quick-checks:
name: Quick Checks name: Quick Checks
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -82,63 +55,8 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install ripgrep - name: Run shared quick checks
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2 uses: ./.github/actions/quick-checks
with:
tool: ripgrep@15.2.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: rustfmt
- name: Check code formatting
run: cargo fmt --all --check
- name: Check unsafe code allowances
run: ./scripts/check_unsafe_code_allowances.sh
- name: Check layered dependencies
run: ./scripts/check_layer_dependencies.sh
- name: Check architecture migration rules
run: ./scripts/check_architecture_migration_rules.sh
- name: Check logging guardrails
run: ./scripts/check_logging_guardrails.sh
- name: Check tokio io-uring feature guard
run: ./scripts/check_no_tokio_io_uring.sh
- name: Check extension schema boundaries
run: ./scripts/check_extension_schema_boundaries.sh
- name: Check body-cache whitelist guard
run: ./scripts/check_body_cache_whitelist.sh
- name: Check s3s footprint ratchet
run: ./scripts/check_s3s_footprint.sh
- name: Check cryptographic capability wording
run: ./scripts/check_fips_wording.sh
- name: Check no embedded secret material
run: ./scripts/check_embedded_secrets.sh
- name: Check test wiring
run: |
python3 ./scripts/check_test_wiring.py --self-test
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
python3 ./scripts/check_test_wiring.py
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition
run: ./scripts/check_uring_lane_lib_only.sh
test-and-lint: test-and-lint:
name: Test and Lint name: Test and Lint
+21 -66
View File
@@ -100,12 +100,7 @@ jobs:
- name: Typos check with custom config file - name: Typos check with custom config file
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
# Fast, compile-free checks that fail early so contributors get feedback in # Fail early with compile-free checks shared with docs-only CI.
# ~1 minute instead of waiting for the full test job.
#
# These steps are mirrored byte-for-byte in ci-docs-only.yml so that a mixed
# PR, which reports two check runs named "Quick Checks", cannot get one red
# and one green. Edit both jobs together.
quick-checks: quick-checks:
name: Quick Checks name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed' if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -117,66 +112,8 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
- name: Install ripgrep - name: Run shared quick checks
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2 uses: ./.github/actions/quick-checks
with:
tool: ripgrep@15.2.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: rustfmt
- name: Check code formatting
run: cargo fmt --all --check
- name: Check unsafe code allowances
run: ./scripts/check_unsafe_code_allowances.sh
- name: Check layered dependencies
run: ./scripts/check_layer_dependencies.sh
- name: Check architecture migration rules
run: ./scripts/check_architecture_migration_rules.sh
- name: Check logging guardrails
run: ./scripts/check_logging_guardrails.sh
- name: Check error other(format!) ratchet
run: ./scripts/check_error_other_format_ratchet.sh
- name: Check tokio io-uring feature guard
run: ./scripts/check_no_tokio_io_uring.sh
- name: Check extension schema boundaries
run: ./scripts/check_extension_schema_boundaries.sh
- name: Check body-cache whitelist guard
run: ./scripts/check_body_cache_whitelist.sh
- name: Check s3s footprint ratchet
run: ./scripts/check_s3s_footprint.sh
- name: Check cryptographic capability wording
run: ./scripts/check_fips_wording.sh
- name: Check no embedded secret material
run: ./scripts/check_embedded_secrets.sh
- name: Check test wiring
run: |
python3 ./scripts/check_test_wiring.py --self-test
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
python3 ./scripts/check_test_wiring.py
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition
run: ./scripts/check_uring_lane_lib_only.sh
test-and-lint: test-and-lint:
name: Test and Lint name: Test and Lint
@@ -269,6 +206,7 @@ jobs:
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }} CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
run: | run: |
mkdir -p artifacts/test-and-lint mkdir -p artifacts/test-and-lint
rm -f target/nextest/ci/junit.xml
./scripts/ci/resource_sampler.sh start nextest ./scripts/ci/resource_sampler.sh start nextest
trap './scripts/ci/resource_sampler.sh stop' EXIT trap './scripts/ci/resource_sampler.sh stop' EXIT
set +e set +e
@@ -277,6 +215,12 @@ jobs:
--status-level all --final-status-level all \ --status-level all --final-status-level all \
2>&1 | tee artifacts/test-and-lint/nextest.log 2>&1 | tee artifacts/test-and-lint/nextest.log
status=${PIPESTATUS[0]} status=${PIPESTATUS[0]}
if [[ "${status}" -eq 0 ]]; then
cargo nextest list --profile ci --all --exclude e2e_test --message-format json \
> artifacts/test-and-lint/core-test-listing.json \
&& python3 scripts/check_test_wiring.py --check-core artifacts/test-and-lint/core-test-listing.json \
&& test -s target/nextest/ci/junit.xml || status=$?
fi
{ {
echo "command=cargo nextest run --profile ci --all --exclude e2e_test" echo "command=cargo nextest run --profile ci --all --exclude e2e_test"
echo "exit_status=${status}" echo "exit_status=${status}"
@@ -906,6 +850,17 @@ jobs:
cache-save-if: 'false' cache-save-if: 'false'
install-build-packaging-tools: 'false' install-build-packaging-tools: 'false'
- name: Install network fault-injection tools
run: |
sudo apt-get install -y iptables
sudo -n iptables --version
# The endpoint-blackhole heal scenario needs CAP_NET_ADMIN. Containerised
# runners can run iptables but not touch the rule set; the test then logs
# a skip instead of failing, so surface that here where it is visible.
if ! sudo -n iptables -w 5 -S OUTPUT >/dev/null 2>&1; then
echo "::warning::iptables cannot read the OUTPUT chain on this runner (no CAP_NET_ADMIN); the endpoint-blackhole heal scenario will be skipped"
fi
- name: Set up Python - name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with: with:
+26 -8
View File
@@ -121,6 +121,22 @@ jobs:
create_latest=false create_latest=false
source_ref="$GITHUB_SHA" source_ref="$GITHUB_SHA"
# Pre-GA policy: until the first stable (vX.Y.Z) tag exists, every
# prerelease (alpha/beta/rc) also moves `latest`, so users pulling
# `latest` get the newest test build. Once a stable tag is published
# this returns false and `latest` follows stable releases only.
prerelease_moves_latest() {
local stable_tags
stable_tags=$(git ls-remote --tags --refs origin 2>/dev/null \
| awk '{print $2}' \
| grep -E '^refs/tags/v?[0-9]+\.[0-9]+\.[0-9]+$' || true)
if [[ -z "$stable_tags" ]]; then
return 0
fi
echo "️ Stable release tag(s) already exist; prereleases no longer update latest"
return 1
}
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
# Triggered by build workflow completion # Triggered by build workflow completion
echo "🔗 Triggered by build workflow completion" echo "🔗 Triggered by build workflow completion"
@@ -184,8 +200,8 @@ jobs:
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
build_type="prerelease" build_type="prerelease"
is_prerelease=true is_prerelease=true
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta). # Pre-GA policy: prereleases update latest until the first stable tag exists.
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then if prerelease_moves_latest; then
create_latest=true create_latest=true
echo "🧪 Building Docker image for prerelease: $version (creating latest tag)" echo "🧪 Building Docker image for prerelease: $version (creating latest tag)"
else else
@@ -243,8 +259,8 @@ jobs:
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*) v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
build_type="prerelease" build_type="prerelease"
is_prerelease=true is_prerelease=true
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta). # Pre-GA policy: prereleases update latest until the first stable tag exists.
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then if prerelease_moves_latest; then
create_latest=true create_latest=true
echo "🧪 Building with prerelease version: $input_version (creating latest tag)" echo "🧪 Building with prerelease version: $input_version (creating latest tag)"
else else
@@ -394,11 +410,13 @@ jobs:
TAG_BASE="${VERSION}${VARIANT_SUFFIX}" TAG_BASE="${VERSION}${VARIANT_SUFFIX}"
TAGS="${{ env.REGISTRY_DOCKERHUB }}:$TAG_BASE,${{ env.REGISTRY_GHCR }}:$TAG_BASE,${{ env.REGISTRY_QUAY }}:$TAG_BASE" TAGS="${{ env.REGISTRY_DOCKERHUB }}:$TAG_BASE,${{ env.REGISTRY_GHCR }}:$TAG_BASE,${{ env.REGISTRY_QUAY }}:$TAG_BASE"
# Add channel tags for prereleases and latest for stable # Add latest when requested (stable releases, and prereleases before GA)
if [[ "$CREATE_LATEST" == "true" ]]; then if [[ "$CREATE_LATEST" == "true" ]]; then
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true.
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:latest${VARIANT_SUFFIX}" TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:latest${VARIANT_SUFFIX}"
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then fi
# Always add the channel tag for prereleases, independent of latest
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
# Prerelease channel tags (alpha, beta, rc) # Prerelease channel tags (alpha, beta, rc)
if [[ "$VERSION" == *"alpha"* ]]; then if [[ "$VERSION" == *"alpha"* ]]; then
CHANNEL="alpha" CHANNEL="alpha"
@@ -555,7 +573,7 @@ jobs:
"prerelease") "prerelease")
echo "🧪 Prerelease Docker image has been built with ${VERSION} tags" echo "🧪 Prerelease Docker image has been built with ${VERSION} tags"
echo "⚠️ This is a prerelease image - use with caution" echo "⚠️ This is a prerelease image - use with caution"
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true. # Prereleases move latest until the first stable tag exists (pre-GA policy).
if [[ "$CREATE_LATEST" == "true" ]]; then if [[ "$CREATE_LATEST" == "true" ]]; then
echo "🏷️ Latest tag has been created for prerelease: $VERSION" echo "🏷️ Latest tag has been created for prerelease: $VERSION"
else else
+216
View File
@@ -0,0 +1,216 @@
# 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/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.
# 4-node 4-disk distributed e2e lane.
#
# Each selected test starts a real localhost cluster via
# `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
# nightly runs, and manual dispatches all execute the same fail-closed suite.
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
#
# Isolated pool filesystems: expand/decommission/rebalance cases require
# independent `statfs` capacity. This job runs on GitHub-hosted
# `ubuntu-latest` because the self-hosted `sm-standard-4` ARC pods cannot
# create filesystems: `mount -o loop` fails with ENOENT (no
# `/dev/loop-control`), and `mount -t tmpfs` fails with "cannot mount tmpfs
# read-only" (no `CAP_SYS_ADMIN` in the initial namespace). The same reason
# `uring-integration` and `e2e-s3tests.yml` left that label. The prepare
# step mounts four 1 GiB tmpfs instances and exports `RUSTFS_E2E_POOL_ROOTS`.
name: e2e-distributed
on:
pull_request:
paths:
- "Cargo.lock"
- "Cargo.toml"
- ".config/nextest.toml"
- ".github/workflows/e2e-distributed.yml"
- "crates/audit/**"
- "crates/common/**"
- "crates/config/**"
- "crates/e2e_test/**"
- "crates/ecstore/**"
- "crates/filemeta/**"
- "crates/heal/**"
- "crates/iam/**"
- "crates/lock/**"
- "crates/madmin/**"
- "crates/notify/**"
- "crates/replication/**"
- "crates/s3-client/**"
- "crates/s3-ops/**"
- "crates/s3-types/**"
- "crates/scanner/**"
- "crates/storage-api/**"
- "crates/utils/**"
- "rustfs/**"
workflow_dispatch:
inputs:
filter:
description: "Optional nextest -E filter (default: the whole e2e-distributed profile)"
required: false
default: ""
schedule:
# 05:53 UTC nightly — clear of e2e-nightly (04:29) and ODM interop (05:23).
- cron: "53 5 * * *"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' }}
jobs:
distributed:
name: Distributed 4-node 4-disk e2e
# GitHub-hosted VM: loop and tmpfs mounts work here. sm-standard-4 is an
# ARC pod and rejects both (`mount -o loop` ENOENT, tmpfs "read-only").
runs-on: ubuntu-latest
timeout-minutes: 180
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NO_PROXY: 127.0.0.1,localhost
HTTP_PROXY: ""
HTTPS_PROXY: ""
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
# Dedicated key: ubuntu-latest and sm-standard-4 share runner.os, so
# a shared key would mix VM and ARC pod target/ artifacts.
cache-shared-key: ci-e2e-distributed-hosted
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: 'false'
- name: Prepare isolated filesystems for pool movement
run: |
set -euo pipefail
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
mkdir -p "${mount_base}"
roots=()
for pool in 0 1 2 3; do
mountpoint="${mount_base}/pool-${pool}"
mkdir -p "${mountpoint}"
# Sized tmpfs reports a distinct st_dev and independent 1G
# statfs capacity. Requires a VM runner (ubuntu-latest).
if ! sudo mount -t tmpfs -o size=1G,nosuid,nodev,mode=1777 tmpfs "${mountpoint}"; then
echo "tmpfs mount failed on $(uname -a)" >&2
findmnt || true
grep Cap /proc/self/status || true
exit 1
fi
sudo chmod 1777 "${mountpoint}"
roots+=("${mountpoint}")
done
printf -v joined_roots '%s:' "${roots[@]}"
echo "RUSTFS_E2E_POOL_ROOTS=${joined_roots%:}" >> "${GITHUB_ENV}"
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[0]}"
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[1]}"
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[2]}"
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[3]}"
- name: Download pinned previous release
env:
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
run: |
set -euo pipefail
mkdir -p "$SOURCE_DIR"
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
unzip -q "$archive" -d "$SOURCE_DIR"
chmod +x "$SOURCE_DIR/rustfs"
test -x "$SOURCE_DIR/rustfs"
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
- name: Build rustfs binary
run: |
cargo build -p rustfs --bins
: > target/debug/rustfs.features
- name: Verify distributed e2e membership
env:
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-distributed-list.json
run: |
cargo nextest list --profile e2e-distributed -p e2e_test --message-format json > "${NEXTEST_LISTING}"
python3 ./scripts/check_test_wiring.py --check-profile e2e-distributed "${NEXTEST_LISTING}"
- name: Run distributed 4-node e2e suite
env:
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
FILTER: ${{ inputs.filter }}
run: |
set -euo pipefail
if [ -n "${FILTER}" ]; then
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
else
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
fi
- name: Upload distributed e2e diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-distributed-${{ github.run_number }}
path: |
target/nextest/e2e-distributed/junit.xml
${{ runner.temp }}/rustfs-e2e-distributed-list.json
${{ runner.temp }}/rustfs-e2e-distributed-logs/
retention-days: 7
if-no-files-found: warn
- name: Unmount isolated pool filesystems
if: always()
run: |
set -euo pipefail
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
for pool in 0 1 2 3; do
mountpoint="${mount_base}/pool-${pool}"
if mountpoint --quiet "${mountpoint}"; then
sudo umount "${mountpoint}"
fi
done
alert-on-failure:
name: Alert on scheduled failure
needs: [distributed]
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+24 -5
View File
@@ -19,7 +19,9 @@ on:
paths: paths:
- ".github/workflows/e2e-upgrade.yml" - ".github/workflows/e2e-upgrade.yml"
- "crates/e2e_test/src/common.rs" - "crates/e2e_test/src/common.rs"
- "crates/e2e_test/src/fake_s3_target/**"
- "crates/e2e_test/src/lib.rs" - "crates/e2e_test/src/lib.rs"
- "crates/e2e_test/src/replication_extension_test.rs"
- "crates/e2e_test/src/upgrade_compatibility_test.rs" - "crates/e2e_test/src/upgrade_compatibility_test.rs"
- "crates/ecstore/**" - "crates/ecstore/**"
- "crates/filemeta/**" - "crates/filemeta/**"
@@ -44,9 +46,9 @@ concurrency:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1 RUST_BACKTRACE: 1
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2 UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7 UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
jobs: jobs:
upgrade: upgrade:
@@ -55,14 +57,31 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- name: Direct upgrade from rc.2 # The two `_from_rc2_` tests keep their names: they assert
# release-independent object contracts and pass unchanged against the
# newer pinned source, so renaming them would only churn history and
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
# single source of truth for which release they actually run against.
- name: Direct upgrade from the previous release
cache_key: e2e-direct-upgrade cache_key: e2e-direct-upgrade
test: direct_upgrade_from_rc2_preserves_object_contracts test: direct_upgrade_from_rc2_preserves_object_contracts
artifact: direct-upgrade artifact: direct-upgrade
- name: Mixed-version rolling upgrade from rc.2 - name: Mixed-version rolling upgrade from the previous release
cache_key: e2e-mixed-version-upgrade cache_key: e2e-mixed-version-upgrade
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
artifact: mixed-version-upgrade artifact: mixed-version-upgrade
- name: Bucket configuration survives the upgrade
cache_key: e2e-bucket-config-upgrade
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
artifact: bucket-config-upgrade
- name: Rollback reads current bucket metadata
cache_key: e2e-bucket-config-rollback
test: rollback_to_previous_release_reads_current_bucket_metadata
artifact: bucket-config-rollback
- name: ODM configuration recovery after rc.5 rollback
cache_key: e2e-odm-config-rollback
test: rc5_rollback_requires_restoring_odm_configuration
artifact: odm-config-rollback
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 60
env: env:
+51 -8
View File
@@ -166,8 +166,9 @@ jobs:
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... . # e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
# Skipped when the R2 secrets are not configured (artifact-only mode). # Skipped when the R2 secrets are not configured (artifact-only mode).
- name: Upload DEB to Cloudflare R2 - name: Upload DEB to Cloudflare R2
if: env.R2_ACCESS_KEY_ID != '' id: publish
env: env:
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }} R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
@@ -182,28 +183,70 @@ jobs:
exit 0 exit 0
fi fi
if ! command -v aws >/dev/null 2>&1; then
sudo apt-get update && sudo apt-get install -y -qq awscli
fi
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID" export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY" export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="auto" export AWS_DEFAULT_REGION="auto"
DEB_FILE="${{ steps.deb.outputs.deb_file }}" SOURCE_SHA="$(git rev-parse HEAD)"
if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then
echo "Checkout SHA does not match the nightly build run" >&2
exit 1
fi
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
# Old AWS CLI models lack conditional PutObject support. Never fall
# back to an overwriting upload for a candidate.
AWS_CLI=aws
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
sudo apt-get update
sudo apt-get install -y -qq python3-venv
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/nightly-awscli.XXXXXX")"
trap 'rm -rf "${AWS_CLI_DIR}"' EXIT
python3 -m venv "${AWS_CLI_DIR}"
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
fi
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
"${AWS_CLI}" --version
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${CANDIDATE_KEY}" \
--body "${DEB_FILE}" --if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
PUBLISHED_SHA256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${CANDIDATE_URL}" | sha256sum | cut -d ' ' -f 1)"
if [[ "${PUBLISHED_SHA256}" != "${DEB_SHA256}" ]]; then
echo "Published candidate checksum does not match the built package" >&2
exit 1
fi
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/" R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}" echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors "${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
# Stable "latest" alias so tests can fetch the newest nightly # Stable "latest" alias so tests can fetch the newest nightly
# without knowing today's date. # without knowing today's date.
echo "📤 Uploading latest alias" echo "📤 Uploading latest alias"
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \ "${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
--endpoint-url "$R2_ENDPOINT" --only-show-errors --endpoint-url "$R2_ENDPOINT" --only-show-errors
echo "✅ R2 upload complete" echo "✅ R2 upload complete"
CANDIDATE_FILE="${RUNNER_TEMP}/nightly-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --arg source_sha "${SOURCE_SHA}" \
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
--arg package_url "${CANDIDATE_URL}" --arg package_sha256 "${DEB_SHA256}" \
'{schema: 1, source_sha: $source_sha, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' \
> "${CANDIDATE_FILE}"
echo "candidate_file=${CANDIDATE_FILE}" >> "${GITHUB_OUTPUT}"
- name: Upload nightly candidate manifest
if: ${{ steps.publish.outputs.candidate_file != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: nightly-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774). # Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
# #
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and # RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
+2 -15
View File
@@ -14,8 +14,8 @@
# Functional chain driver: runs the ten functional suites in a fixed order # Functional chain driver: runs the ten functional suites in a fixed order
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security -> # (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
# replication, with performance on its own runner in parallel) and guarantees # replication -> performance). Each suite attempts the next handoff even
# the chain keeps moving even when individual suites fail. # when its tests fail.
# #
# Each suite workflow can still be dispatched standalone (workflow_dispatch); # Each suite workflow can still be dispatched standalone (workflow_dispatch);
# only chain-triggered runs forward to the next suite via repository_dispatch, # only chain-triggered runs forward to the next suite via repository_dispatch,
@@ -59,16 +59,3 @@ jobs:
gh api --method POST repos/rustfs/rustfs/dispatches \ gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-upgrade' \ -f event_type='rustfs-chain-upgrade' \
-F 'client_payload[from_suite]=nightly-build' -F 'client_payload[from_suite]=nightly-build'
- name: Dispatch performance suite (parallel, own runner)
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch performance" >&2
exit 1
fi
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-performance' \
-F 'client_payload[from_suite]=nightly-build'
+66 -33
View File
@@ -54,14 +54,26 @@ env:
jobs: jobs:
heal-test: heal-test:
runs-on: smoke-testing runs-on: smoke-testing
# Requirement: a failing suite must not fail the workflow; failures
# are filed to rustfs/backlog and the chain continues.
continue-on-error: true
timeout-minutes: 480 timeout-minutes: 480
# Standalone manual run, or one link of the nightly functional chain # Standalone manual run, or one link of the nightly functional chain
# (storage -> heal -> pool). Pool expansion no longer re-runs heal. # (storage -> heal -> pool). Pool expansion no longer re-runs heal.
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps: steps:
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-heal-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'RUSTFS_WARP_LOG_FILE=%s/warp.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not # auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures. # GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry) - name: Checkout auto-testing scripts (with retry)
@@ -117,7 +129,7 @@ jobs:
else else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}") ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi fi
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" ./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
- name: Preflight checks - name: Preflight checks
run: | run: |
@@ -127,7 +139,7 @@ jobs:
else else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}") ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi fi
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" ./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
- name: Run heal test (write -> outage -> heal -> verify) - name: Run heal test (write -> outage -> heal -> verify)
id: test id: test
@@ -137,13 +149,10 @@ jobs:
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \ --endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \ --stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \ --warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
--log-file /tmp/rustfs-heal-test.log --log-file "${LOG_FILE}"
- name: Generate report - name: Generate report
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
env:
LOG_FILE: /tmp/rustfs-heal-test.log
REPORT_FILE: /tmp/rustfs-heal-report.md
run: | run: |
set -euo pipefail set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}' PACKAGE_URL='${{ inputs.package_url }}'
@@ -152,8 +161,9 @@ jobs:
else else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}" PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi fi
STEPS_TABLE="/tmp/rustfs-heal-steps.md" STEPS_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/steps.md"
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY' CASE_RESULT=success
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY' || CASE_RESULT=failure
import re import re
import sys import sys
@@ -165,6 +175,7 @@ jobs:
steps = {} steps = {}
order = [] order = []
status_rank = {'SKIP': 0, 'PASS': 1, 'FAIL': 2}
version = None version = None
version_node = None version_node = None
verdict = None verdict = None
@@ -178,14 +189,15 @@ jobs:
n, desc, status = m.group(1), m.group(2), m.group(3) n, desc, status = m.group(1), m.group(2), m.group(3)
if n not in steps: if n not in steps:
order.append(n) order.append(n)
steps[n] = (desc, status) # later lines win (fail after pass) if n not in steps or status_rank[status] > status_rank[steps[n][1]]:
steps[n] = (desc, status)
continue continue
m = ver_re.match(line) m = ver_re.match(line)
if m: if m:
version, version_node = m.group(1), m.group(2) version, version_node = m.group(1), m.group(2)
continue continue
m = result_re.match(line) m = result_re.match(line)
if m: if m and verdict != 'FAIL':
verdict, verdict_detail = m.group(1), m.group(2) verdict, verdict_detail = m.group(1), m.group(2)
except FileNotFoundError: except FileNotFoundError:
pass pass
@@ -205,30 +217,43 @@ jobs:
out.write(f'| {n} | {desc} | {status} |\n') out.write(f'| {n} | {desc} | {status} |\n')
if not order: if not order:
out.write('| - | - | NOT RUN (no step result lines found) |\n') out.write('| - | - | NOT RUN (no step result lines found) |\n')
complete = set(steps) == {str(n) for n in range(1, 8)}
sys.exit(0 if complete and verdict != 'FAIL' and all(status == 'PASS' for _, status in steps.values()) else 1)
PY PY
RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
RESULT=success
fi
{ {
echo "# RustFS heal test report" echo "# RustFS heal test report"
echo "" echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}" echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}" echo "- Package: ${PACKAGE_SOURCE}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}" echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo "" echo ""
cat "${STEPS_TABLE}" || true if [ "${RESULT}" = "success" ]; then
cat "${STEPS_TABLE}"
echo "" echo ""
echo "## Log tail" echo "## Log tail"
echo '```text' echo '```text'
tail -n 200 "${LOG_FILE}" || true tail -n 200 "${LOG_FILE}"
echo '```' echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial step results and suite.log."
fi
} | tee "${REPORT_FILE}" } | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}" cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard - name: Upload functional report to dashboard
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-heal-report.md
SUITE: heal SUITE: heal
run: | run: |
set -euo pipefail set -euo pipefail
@@ -238,28 +263,32 @@ jobs:
fi fi
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md" REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")" # Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \ '{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:$content}' \ '{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi fi
rm -f "${B64_FILE}"
- name: File failure issue in rustfs/backlog - name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'heal' SUITE: 'heal'
SUITE_LABEL: 'Heal' SUITE_LABEL: 'Heal'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-heal-report.md'
LOG_FILE: '/tmp/rustfs-heal-test.log'
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then if [ -z "${GH_TOKEN:-}" ]; then
@@ -287,14 +316,16 @@ jobs:
echo "" echo ""
echo "- Suite: \`${SUITE}\`" echo "- Suite: \`${SUITE}\`"
echo "- Run: ${RUN_URL}" echo "- Run: ${RUN_URL}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}" echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)" echo "- Date: $(date -u +%Y-%m-%d)"
echo "" echo ""
echo "## Report (errors and symptoms)" echo "## Report (errors and symptoms)"
echo "" echo ""
if [ -s "${REPORT_FILE}" ]; then if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}" redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)" echo "(report file missing; log tail below)"
echo "" echo ""
tail -n 200 "${LOG_FILE}" | redact tail -n 200 "${LOG_FILE}" | redact
@@ -310,14 +341,16 @@ jobs:
echo "filed backlog issue for suite ${SUITE}" echo "filed backlog issue for suite ${SUITE}"
- name: Upload test logs - name: Upload test logs
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: rustfs-heal-test-${{ github.run_id }} name: rustfs-heal-test-${{ github.run_id }}-${{ github.run_attempt }}
path: | path: |
/tmp/rustfs-heal-test*.log ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
/tmp/rustfs-warp.*.log ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
if-no-files-found: warn ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/warp.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/steps.md
if-no-files-found: error
- name: Cleanup environment (after) - name: Cleanup environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }} if: ${{ always() && inputs.cleanup_after != 'false' }}
+60 -77
View File
@@ -49,10 +49,28 @@ env:
jobs: jobs:
kms-test: kms-test:
runs-on: smoke-testing runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 420 timeout-minutes: 420
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps: steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-kms-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not # auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures. # GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry) - name: Checkout auto-testing scripts (with retry)
@@ -109,9 +127,6 @@ jobs:
- name: Run KMS suite - name: Run KMS suite
id: test id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-kms.log
run: | run: |
set -euo pipefail set -euo pipefail
chmod +x auto-testing/rustfs-kms-test.sh chmod +x auto-testing/rustfs-kms-test.sh
@@ -141,10 +156,7 @@ jobs:
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}" ./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
- name: Generate report - name: Generate report
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
env:
LOG_FILE: /tmp/rustfs-kms.log
REPORT_FILE: /tmp/rustfs-kms-report.md
run: | run: |
set -euo pipefail set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}' PACKAGE_URL='${{ inputs.package_url }}'
@@ -156,79 +168,43 @@ jobs:
else else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}" PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi fi
CASE_TABLE="/tmp/rustfs-kms-cases.md" CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY' CASE_RESULT=success
import re python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
import sys RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
log_file, out_file = sys.argv[1], sys.argv[2] RESULT=success
ansi = re.compile(r'\x1b\[[0-9;]*m') fi
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
rows = []
index = {}
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{ {
echo "# RustFS KMS test report" echo "# RustFS KMS test report"
echo "" echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}" echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}" echo "- Package: ${PACKAGE_SOURCE}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}" echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo "" echo ""
cat "${CASE_TABLE}" || true if [ "${RESULT}" = "success" ]; then
cat "${CASE_TABLE}"
echo "" echo ""
echo "## Log tail" echo "## Log tail"
echo '```text' echo '```text'
tail -n 200 "${LOG_FILE}" || true tail -n 200 "${LOG_FILE}"
echo '```' echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}" } | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}" cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard - name: Upload functional report to dashboard
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-kms-report.md
SUITE: kms SUITE: kms
run: | run: |
set -euo pipefail set -euo pipefail
@@ -238,28 +214,32 @@ jobs:
fi fi
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md" REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")" # Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \ '{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:$content}' \ '{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi fi
rm -f "${B64_FILE}"
- name: File failure issue in rustfs/backlog - name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'kms' SUITE: 'kms'
SUITE_LABEL: 'KMS' SUITE_LABEL: 'KMS'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-kms-report.md'
LOG_FILE: '/tmp/rustfs-kms.log'
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then if [ -z "${GH_TOKEN:-}" ]; then
@@ -287,14 +267,16 @@ jobs:
echo "" echo ""
echo "- Suite: \`${SUITE}\`" echo "- Suite: \`${SUITE}\`"
echo "- Run: ${RUN_URL}" echo "- Run: ${RUN_URL}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}" echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)" echo "- Date: $(date -u +%Y-%m-%d)"
echo "" echo ""
echo "## Report (errors and symptoms)" echo "## Report (errors and symptoms)"
echo "" echo ""
if [ -s "${REPORT_FILE}" ]; then if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}" redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)" echo "(report file missing; log tail below)"
echo "" echo ""
tail -n 200 "${LOG_FILE}" | redact tail -n 200 "${LOG_FILE}" | redact
@@ -310,14 +292,15 @@ jobs:
echo "filed backlog issue for suite ${SUITE}" echo "filed backlog issue for suite ${SUITE}"
- name: Upload report and logs - name: Upload report and logs
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: rustfs-kms-test-${{ github.run_id }} name: rustfs-kms-test-${{ github.run_id }}-${{ github.run_attempt }}
path: | path: |
/tmp/rustfs-kms.log ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
/tmp/rustfs-kms-report.md ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
if-no-files-found: warn ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
if-no-files-found: error
- name: Cleanup environment (after) - name: Cleanup environment (after)
if: always() if: always()
+48 -32
View File
@@ -49,17 +49,16 @@ on:
type: boolean type: boolean
default: true default: true
repository_dispatch: repository_dispatch:
# Chain entry: dispatched by rustfs-functional-chain.yml (runs on its own # Chain handoff: dispatched when the replication suite finishes.
# pf-testing runner, in parallel with the shared-VM chain).
types: [rustfs-chain-performance] types: [rustfs-chain-performance]
permissions: permissions:
contents: read contents: read
# Dedicated pf-testing runner/environment: own concurrency group so perf runs # The default performance nodes overlap the other suites' remote VMs, even
# never block (or are blocked by) the pool-expansion / heal tests. # though the runner differs. Hold the shared lock through cleanup as well.
concurrency: concurrency:
group: rustfs-performance-test group: rustfs-shared-functional-tests
cancel-in-progress: false cancel-in-progress: false
defaults: defaults:
@@ -76,22 +75,33 @@ env:
# Package used by the nightly run (workflow_dispatch inputs are empty for # Package used by the nightly run (workflow_dispatch inputs are empty for
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml. # workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }} RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
# Fixed benchmark result directory so later steps can read summary.md
RUSTFS_RESULT_DIR: /tmp/rustfs-perf-results
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings) # Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
jobs: jobs:
performance-test: performance-test:
runs-on: pf-testing runs-on: pf-testing
# Requirement: a failing benchmark must not fail the workflow;
# failures are filed to rustfs/backlog.
continue-on-error: true
timeout-minutes: 900 timeout-minutes: 900
# Run on manual dispatch, or when the nightly build completed successfully. # Run on manual dispatch, or when the nightly build completed successfully.
# Skipped when nightly failed. # Skipped when nightly failed.
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps: steps:
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-performance-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'RUSTFS_RESULT_DIR=%s/results\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'VERSION_FILE=%s/version.txt\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not # auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures. # GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry) - name: Checkout auto-testing scripts (with retry)
@@ -123,7 +133,7 @@ jobs:
if: ${{ inputs.cleanup_before != 'false' }} if: ${{ inputs.cleanup_before != 'false' }}
run: | run: |
chmod +x auto-testing/rustfs_performance_test.sh chmod +x auto-testing/rustfs_performance_test.sh
./auto-testing/rustfs_performance_test.sh --step 1 -y ./auto-testing/rustfs_performance_test.sh --step 1 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Install RustFS package & start cluster (4x4) - name: Install RustFS package & start cluster (4x4)
run: | run: |
@@ -133,7 +143,7 @@ jobs:
else else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}") ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi fi
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" ./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
- name: Preflight checks - name: Preflight checks
run: | run: |
@@ -143,7 +153,7 @@ jobs:
else else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}") ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi fi
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" ./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
- name: Run benchmark (GET/PUT/MIXED) - name: Run benchmark (GET/PUT/MIXED)
id: benchmark id: benchmark
@@ -156,17 +166,15 @@ jobs:
--step 5 -y \ --step 5 -y \
--warp-duration "${{ inputs.warp_duration || '5m' }}" \ --warp-duration "${{ inputs.warp_duration || '5m' }}" \
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \ --warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
--log-file /tmp/rustfs-perf-test.log --log-file "${LOG_FILE}"
- name: Analyze results - name: Analyze results
if: ${{ steps.benchmark.conclusion == 'success' }} if: ${{ steps.benchmark.conclusion == 'success' }}
run: | run: |
./auto-testing/rustfs_performance_test.sh --step 6 -y ./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Collect RustFS version info - name: Collect RustFS version info
if: ${{ steps.benchmark.conclusion == 'success' }} if: ${{ steps.benchmark.conclusion == 'success' }}
env:
VERSION_FILE: /tmp/rustfs-version.txt
run: | run: |
set -euo pipefail set -euo pipefail
read -r -a NODES <<< "${RUSTFS_NODES}" read -r -a NODES <<< "${RUSTFS_NODES}"
@@ -186,7 +194,6 @@ jobs:
env: env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }} RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }}
VERSION_FILE: /tmp/rustfs-version.txt
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then if [ -z "${GH_TOKEN:-}" ]; then
@@ -194,7 +201,7 @@ jobs:
exit 0 exit 0
fi fi
SUMMARY="${RESULT_DIR}/summary.md" SUMMARY="${RESULT_DIR}/summary.md"
[ -f "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; } [ -s "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="reports/${DATE}.md" REPORT_PATH="reports/${DATE}.md"
{ {
@@ -202,6 +209,8 @@ jobs:
echo "" echo ""
echo "- **Date**: ${DATE}" echo "- **Date**: ${DATE}"
echo "- **Run**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" echo "- **Run**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- **Attempt**: ${GITHUB_RUN_ATTEMPT}"
echo "- **Workflow Commit**: ${GITHUB_SHA}"
echo "- **Trigger**: ${{ github.event_name }}" echo "- **Trigger**: ${{ github.event_name }}"
echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}" echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
echo "" echo ""
@@ -211,8 +220,8 @@ jobs:
echo '```text' echo '```text'
cat "${VERSION_FILE}" cat "${VERSION_FILE}"
echo '```' echo '```'
} > /tmp/rustfs-perf-report.md } > "${REPORT_FILE}"
CONTENT="$(python3 -c 'import base64; print(base64.b64encode(open("/tmp/rustfs-perf-report.md","rb").read()).decode())')" CONTENT="$(python3 -c 'import base64,sys; print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
@@ -231,11 +240,10 @@ jobs:
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'performance' SUITE: 'performance'
SUITE_LABEL: 'Performance' SUITE_LABEL: 'Performance'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-perf-report.md'
LOG_FILE: '/tmp/rustfs-perf-test.log'
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then if [ -z "${GH_TOKEN:-}" ]; then
@@ -263,14 +271,16 @@ jobs:
echo "" echo ""
echo "- Suite: \`${SUITE}\`" echo "- Suite: \`${SUITE}\`"
echo "- Run: ${RUN_URL}" echo "- Run: ${RUN_URL}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}" echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)" echo "- Date: $(date -u +%Y-%m-%d)"
echo "" echo ""
echo "## Report (errors and symptoms)" echo "## Report (errors and symptoms)"
echo "" echo ""
if [ -s "${REPORT_FILE}" ]; then if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}" redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)" echo "(report file missing; log tail below)"
echo "" echo ""
tail -n 200 "${LOG_FILE}" | redact tail -n 200 "${LOG_FILE}" | redact
@@ -286,20 +296,26 @@ jobs:
echo "filed backlog issue for suite ${SUITE}" echo "filed backlog issue for suite ${SUITE}"
- name: Upload test logs & results - name: Upload test logs & results
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: rustfs-perf-test-${{ github.run_id }} name: rustfs-perf-test-${{ github.run_id }}-${{ github.run_attempt }}
path: | path: |
/tmp/rustfs-perf-test*.log ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
/tmp/rustfs-perf-results/** ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
/tmp/rustfs-version.txt ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/version.txt
if-no-files-found: warn ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/master.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.tsv
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/get_*.txt
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/put_*.txt
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/mixed_*.txt
if-no-files-found: error
- name: Reset test environment (after) - name: Reset test environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }} if: ${{ always() && inputs.cleanup_after != 'false' }}
run: | run: |
./auto-testing/rustfs_performance_test.sh --step 7 -y ./auto-testing/rustfs_performance_test.sh --step 7 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
+10 -8
View File
@@ -76,9 +76,6 @@ jobs:
pool-expansion-test: pool-expansion-test:
name: Pool expansion / decommission test name: Pool expansion / decommission test
runs-on: smoke-testing runs-on: smoke-testing
# Requirement: a failing suite must not fail the workflow; failures
# are filed to rustfs/backlog and the chain continues.
continue-on-error: true
timeout-minutes: 360 timeout-minutes: 360
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
env: env:
@@ -542,17 +539,22 @@ jobs:
fi fi
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md" REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")" # Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \ '{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:$content}' \ '{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi fi
rm -f "${B64_FILE}"
- name: File failure issue in rustfs/backlog - name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }} if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
+103 -86
View File
@@ -34,8 +34,7 @@ on:
- site - site
default: all default: all
repository_dispatch: repository_dispatch:
# Chain handoff: dispatched when the security suite finishes. This is the # Chain handoff: dispatched when the security suite finishes.
# last link of the functional chain.
types: [rustfs-chain-replication] types: [rustfs-chain-replication]
permissions: permissions:
@@ -62,12 +61,28 @@ env:
jobs: jobs:
replication-test: replication-test:
runs-on: smoke-testing runs-on: smoke-testing
# A failed replication run must not break the chain or the workflow: the
# failure is reported to rustfs/backlog instead (see the issue step).
continue-on-error: true
timeout-minutes: 360 timeout-minutes: 360
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps: steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-replication-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not # auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures. # GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry) - name: Checkout auto-testing scripts (with retry)
@@ -116,9 +131,6 @@ jobs:
- name: Run replication suite - name: Run replication suite
id: test id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-replication.log
run: | run: |
set -euo pipefail set -euo pipefail
chmod +x auto-testing/rustfs-replication-test.sh chmod +x auto-testing/rustfs-replication-test.sh
@@ -141,10 +153,7 @@ jobs:
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}" ./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
- name: Generate report - name: Generate report
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
env:
LOG_FILE: /tmp/rustfs-replication.log
REPORT_FILE: /tmp/rustfs-replication-report.md
run: | run: |
set -euo pipefail set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}' PACKAGE_URL='${{ inputs.package_url }}'
@@ -166,80 +175,44 @@ jobs:
RUSTFS_VERSION_INFO="${DETECTED_VERSION}" RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
fi fi
fi fi
CASE_TABLE="/tmp/rustfs-replication-cases.md" CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY' CASE_RESULT=success
import re python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
import sys RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
log_file, out_file = sys.argv[1], sys.argv[2] RESULT=success
ansi = re.compile(r'\x1b\[[0-9;]*m') fi
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
rows = []
index = {}
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{ {
echo "# RustFS replication test report" echo "# RustFS replication test report"
echo "" echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}" echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}" echo "- Package: ${PACKAGE_SOURCE}"
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}" echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}" echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo "" echo ""
cat "${CASE_TABLE}" || true if [ "${RESULT}" = "success" ]; then
cat "${CASE_TABLE}"
echo "" echo ""
echo "## Log tail" echo "## Log tail"
echo '```text' echo '```text'
tail -n 200 "${LOG_FILE}" || true tail -n 200 "${LOG_FILE}"
echo '```' echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}" } | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}" cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard - name: Upload functional report to dashboard
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-replication-report.md
SUITE: replication SUITE: replication
run: | run: |
set -euo pipefail set -euo pipefail
@@ -249,28 +222,32 @@ jobs:
fi fi
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md" REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")" # Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \ '{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:$content}' \ '{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi fi
rm -f "${B64_FILE}"
- name: File failure issue in rustfs/backlog - name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'replication' SUITE: 'replication'
SUITE_LABEL: 'Replication (bucket + site)' SUITE_LABEL: 'Replication (bucket + site)'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-replication-report.md'
LOG_FILE: '/tmp/rustfs-replication.log'
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then if [ -z "${GH_TOKEN:-}" ]; then
@@ -298,14 +275,16 @@ jobs:
echo "" echo ""
echo "- Suite: \`${SUITE}\`" echo "- Suite: \`${SUITE}\`"
echo "- Run: ${RUN_URL}" echo "- Run: ${RUN_URL}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}" echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)" echo "- Date: $(date -u +%Y-%m-%d)"
echo "" echo ""
echo "## Report (errors and symptoms)" echo "## Report (errors and symptoms)"
echo "" echo ""
if [ -s "${REPORT_FILE}" ]; then if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}" redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)" echo "(report file missing; log tail below)"
echo "" echo ""
tail -n 200 "${LOG_FILE}" | redact tail -n 200 "${LOG_FILE}" | redact
@@ -321,14 +300,15 @@ jobs:
echo "filed backlog issue for suite ${SUITE}" echo "filed backlog issue for suite ${SUITE}"
- name: Upload report and logs - name: Upload report and logs
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: rustfs-replication-${{ github.run_id }} name: rustfs-replication-${{ github.run_id }}-${{ github.run_attempt }}
path: | path: |
/tmp/rustfs-replication.log ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
/tmp/rustfs-replication-report.md ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
if-no-files-found: warn ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
if-no-files-found: error
- name: Cleanup environment (after) - name: Cleanup environment (after)
if: always() if: always()
@@ -349,13 +329,50 @@ jobs:
' '
done done
- name: Chain complete - name: "Continue functional chain (next: Performance)"
# Replication is the last link of the functional chain: nothing to
# dispatch after it. This step just records that the chain finished.
if: ${{ always() && github.event_name == 'repository_dispatch' }} if: ${{ always() && github.event_name == 'repository_dispatch' }}
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: | run: |
echo "Functional chain complete: replication (final suite) finished." set -uo pipefail
echo "from_suite=security trigger=${{ github.event_name }} outcome=${{ steps.test.outcome }}" if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-performance' \
-F 'client_payload[from_suite]=replication'; then
echo "dispatched next suite Performance (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch Performance after 3 attempts" >&2
TITLE="[functional][chain] stalled after replication (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
trap 'rm -f "${BODY_FILE}"' EXIT
{
echo "The functional chain could not hand off from **replication** to **Performance** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-performance'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-performance'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
+60 -80
View File
@@ -37,10 +37,28 @@ env:
jobs: jobs:
s3-compat-test: s3-compat-test:
runs-on: smoke-testing runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 360 timeout-minutes: 360
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps: steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-s3-compat-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not # auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures. # GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry) - name: Checkout auto-testing scripts (with retry)
@@ -88,9 +106,6 @@ jobs:
- name: Run S3 compatibility suite - name: Run S3 compatibility suite
id: test id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-s3-compat.log
run: | run: |
set -euo pipefail set -euo pipefail
chmod +x auto-testing/rustfs-s3-compat-test.sh chmod +x auto-testing/rustfs-s3-compat-test.sh
@@ -107,10 +122,7 @@ jobs:
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}" ./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
- name: Generate report - name: Generate report
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
env:
LOG_FILE: /tmp/rustfs-s3-compat.log
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
run: | run: |
set -euo pipefail set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}' PACKAGE_URL='${{ inputs.package_url }}'
@@ -132,83 +144,44 @@ jobs:
RUSTFS_VERSION_INFO="${DETECTED_VERSION}" RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
fi fi
fi fi
CASE_TABLE="/tmp/rustfs-s3-compat-cases.md" CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY' CASE_RESULT=success
import re python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
import sys RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
log_file, out_file = sys.argv[1], sys.argv[2] RESULT=success
ansi = re.compile(r'\x1b\[[0-9;]*m') fi
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
rows = []
index = {}
current = None
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
current = case_id
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
current = None
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{ {
echo "# RustFS S3 compatibility test report" echo "# RustFS S3 compatibility test report"
echo "" echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}" echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}" echo "- Package: ${PACKAGE_SOURCE}"
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}" echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}" echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo "" echo ""
cat "${CASE_TABLE}" || true if [ "${RESULT}" = "success" ]; then
cat "${CASE_TABLE}"
echo "" echo ""
echo "## Log tail" echo "## Log tail"
echo '```text' echo '```text'
tail -n 200 "${LOG_FILE}" || true tail -n 200 "${LOG_FILE}"
echo '```' echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}" } | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}" cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard - name: Upload functional report to dashboard
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
SUITE: s3 SUITE: s3
run: | run: |
set -euo pipefail set -euo pipefail
@@ -218,28 +191,32 @@ jobs:
fi fi
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md" REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")" # Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \ '{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:$content}' \ '{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi fi
rm -f "${B64_FILE}"
- name: File failure issue in rustfs/backlog - name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 's3' SUITE: 's3'
SUITE_LABEL: 'S3 compatibility' SUITE_LABEL: 'S3 compatibility'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-s3-compat-report.md'
LOG_FILE: '/tmp/rustfs-s3-compat.log'
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then if [ -z "${GH_TOKEN:-}" ]; then
@@ -267,14 +244,16 @@ jobs:
echo "" echo ""
echo "- Suite: \`${SUITE}\`" echo "- Suite: \`${SUITE}\`"
echo "- Run: ${RUN_URL}" echo "- Run: ${RUN_URL}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}" echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)" echo "- Date: $(date -u +%Y-%m-%d)"
echo "" echo ""
echo "## Report (errors and symptoms)" echo "## Report (errors and symptoms)"
echo "" echo ""
if [ -s "${REPORT_FILE}" ]; then if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}" redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)" echo "(report file missing; log tail below)"
echo "" echo ""
tail -n 200 "${LOG_FILE}" | redact tail -n 200 "${LOG_FILE}" | redact
@@ -290,14 +269,15 @@ jobs:
echo "filed backlog issue for suite ${SUITE}" echo "filed backlog issue for suite ${SUITE}"
- name: Upload report and logs - name: Upload report and logs
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: rustfs-s3-compat-${{ github.run_id }} name: rustfs-s3-compat-${{ github.run_id }}-${{ github.run_attempt }}
path: | path: |
/tmp/rustfs-s3-compat.log ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
/tmp/rustfs-s3-compat-report.md ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
if-no-files-found: warn ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
if-no-files-found: error
- name: Cleanup environment (after) - name: Cleanup environment (after)
if: always() if: always()
+69 -30
View File
@@ -74,10 +74,27 @@ env:
jobs: jobs:
security-test: security-test:
runs-on: smoke-testing runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 360 timeout-minutes: 360
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps: steps:
# Checkout the repository into its own subdirectory. Checking out at
# the workspace root would wipe the auto-testing clone above (that is
# exactly how run 33934141181 lost rustfs-security-test.sh).
- name: Checkout repository (for the OIDC live gate script)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
path: rustfs-repo
- name: Initialize security evidence
id: evidence
run: |
set -euo pipefail
umask 077
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${SECURITY_ARTIFACTS_DIR}" "${SECURITY_ARTIFACTS_DIR}-scratch"
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not # auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures. # GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry) - name: Checkout auto-testing scripts (with retry)
@@ -98,11 +115,6 @@ jobs:
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2 echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
exit 1 exit 1
- name: Checkout repository (for the OIDC live gate script)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Show environment - name: Show environment
run: | run: |
uname -a uname -a
@@ -135,8 +147,9 @@ jobs:
id: test id: test
continue-on-error: true continue-on-error: true
env: env:
REPORT_FILE: /tmp/rustfs-security-report.md REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/scripts/test/oidc_keycloak_live.sh TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}-scratch
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/rustfs-repo/scripts/test/oidc_keycloak_live.sh
run: | run: |
set -euo pipefail set -euo pipefail
chmod +x auto-testing/rustfs-security-test.sh chmod +x auto-testing/rustfs-security-test.sh
@@ -159,29 +172,48 @@ jobs:
else else
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}") ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi fi
./auto-testing/rustfs-security-test.sh "${ARGS[@]}" GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}" 2>&1 | tee "${SECURITY_ARTIFACTS_DIR}/suite.log"
- name: Generate report - name: Generate report
if: always() id: report
if: ${{ always() && steps.evidence.outcome == 'success' }}
env:
TEST_OUTCOME: ${{ steps.test.outcome }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ ! -f /tmp/rustfs-security-report.md ]; then RESULT=failure
if [ "${TEST_OUTCOME}" = "success" ] && [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
RESULT=success
fi
{ {
echo "# RustFS security test report" echo "# RustFS security test report"
echo "" echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" echo "- Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Trigger: ${{ github.event_name }}" echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Test Step Outcome: failure (suite did not produce a report)" echo "- Workflow Commit: ${GITHUB_SHA}"
} > /tmp/rustfs-security-report.md echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${TEST_OUTCOME}"
echo ""
# The dashboard prioritizes case rows over the step outcome.
# Keep partial case results in the artifact when the suite fails.
if [ "${RESULT}" = "success" ]; then
cat "${SECURITY_ARTIFACTS_DIR}/suite-report.md"
elif [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
echo "The suite did not complete successfully. See suite-report.md in this run's artifact for diagnostics."
else
echo "The suite did not produce a non-empty report."
fi fi
cat /tmp/rustfs-security-report.md >> "${GITHUB_STEP_SUMMARY}" } > "${SECURITY_ARTIFACTS_DIR}/report.md"
cat "${SECURITY_ARTIFACTS_DIR}/report.md" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard - name: Upload functional report to dashboard
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-security-report.md REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
SUITE: security SUITE: security
run: | run: |
set -euo pipefail set -euo pipefail
@@ -191,17 +223,22 @@ jobs:
fi fi
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md" REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")" # Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \ '{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:$content}' \ '{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi fi
rm -f "${B64_FILE}"
- name: File failure issue in rustfs/backlog - name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
@@ -210,8 +247,9 @@ jobs:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
SUITE: 'security' SUITE: 'security'
SUITE_LABEL: 'Security' SUITE_LABEL: 'Security'
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-security-report.md' REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
LOG_FILE: '' LOG_FILE: ''
run: | run: |
set -euo pipefail set -euo pipefail
@@ -245,7 +283,7 @@ jobs:
echo "" echo ""
echo "## Report (errors and symptoms)" echo "## Report (errors and symptoms)"
echo "" echo ""
if [ -s "${REPORT_FILE}" ]; then if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}" redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then elif [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)" echo "(report file missing; log tail below)"
@@ -263,14 +301,15 @@ jobs:
echo "filed backlog issue for suite ${SUITE}" echo "filed backlog issue for suite ${SUITE}"
- name: Upload report and logs - name: Upload report and logs
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: rustfs-security-test-${{ github.run_id }} name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
path: | path: |
/tmp/rustfs-security-report.md ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
/tmp/rustfs-security.*/* ${{ env.SECURITY_ARTIFACTS_DIR }}/suite.log
if-no-files-found: ignore ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
if-no-files-found: error
retention-days: 3 retention-days: 3
- name: Cleanup environment (after) - name: Cleanup environment (after)
+60 -80
View File
@@ -46,10 +46,28 @@ env:
jobs: jobs:
storage-test: storage-test:
runs-on: smoke-testing runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 360 timeout-minutes: 360
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps: steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-storage-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not # auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures. # GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry) - name: Checkout auto-testing scripts (with retry)
@@ -97,9 +115,6 @@ jobs:
- name: Run storage engine suite - name: Run storage engine suite
id: test id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-storage.log
run: | run: |
set -euo pipefail set -euo pipefail
chmod +x auto-testing/rustfs-storage-test.sh chmod +x auto-testing/rustfs-storage-test.sh
@@ -122,10 +137,7 @@ jobs:
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}" ./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
- name: Generate report - name: Generate report
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
env:
LOG_FILE: /tmp/rustfs-storage.log
REPORT_FILE: /tmp/rustfs-storage-report.md
run: | run: |
set -euo pipefail set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}' PACKAGE_URL='${{ inputs.package_url }}'
@@ -147,83 +159,44 @@ jobs:
RUSTFS_VERSION_INFO="${DETECTED_VERSION}" RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
fi fi
fi fi
CASE_TABLE="/tmp/rustfs-storage-cases.md" CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY' CASE_RESULT=success
import re python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
import sys RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
log_file, out_file = sys.argv[1], sys.argv[2] RESULT=success
ansi = re.compile(r'\x1b\[[0-9;]*m') fi
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
rows = []
index = {}
current = None
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
current = case_id
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
current = None
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{ {
echo "# RustFS storage engine test report" echo "# RustFS storage engine test report"
echo "" echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}" echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}" echo "- Package: ${PACKAGE_SOURCE}"
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}" echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}" echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo "" echo ""
cat "${CASE_TABLE}" || true if [ "${RESULT}" = "success" ]; then
cat "${CASE_TABLE}"
echo "" echo ""
echo "## Log tail" echo "## Log tail"
echo '```text' echo '```text'
tail -n 200 "${LOG_FILE}" || true tail -n 200 "${LOG_FILE}"
echo '```' echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}" } | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}" cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard - name: Upload functional report to dashboard
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-storage-report.md
SUITE: storage SUITE: storage
run: | run: |
set -euo pipefail set -euo pipefail
@@ -233,28 +206,32 @@ jobs:
fi fi
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md" REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")" # Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \ '{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:$content}' \ '{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi fi
rm -f "${B64_FILE}"
- name: File failure issue in rustfs/backlog - name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'storage' SUITE: 'storage'
SUITE_LABEL: 'Storage engine' SUITE_LABEL: 'Storage engine'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-storage-report.md'
LOG_FILE: '/tmp/rustfs-storage.log'
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then if [ -z "${GH_TOKEN:-}" ]; then
@@ -282,14 +259,16 @@ jobs:
echo "" echo ""
echo "- Suite: \`${SUITE}\`" echo "- Suite: \`${SUITE}\`"
echo "- Run: ${RUN_URL}" echo "- Run: ${RUN_URL}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}" echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)" echo "- Date: $(date -u +%Y-%m-%d)"
echo "" echo ""
echo "## Report (errors and symptoms)" echo "## Report (errors and symptoms)"
echo "" echo ""
if [ -s "${REPORT_FILE}" ]; then if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}" redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)" echo "(report file missing; log tail below)"
echo "" echo ""
tail -n 200 "${LOG_FILE}" | redact tail -n 200 "${LOG_FILE}" | redact
@@ -305,14 +284,15 @@ jobs:
echo "filed backlog issue for suite ${SUITE}" echo "filed backlog issue for suite ${SUITE}"
- name: Upload report and logs - name: Upload report and logs
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: rustfs-storage-${{ github.run_id }} name: rustfs-storage-${{ github.run_id }}-${{ github.run_attempt }}
path: | path: |
/tmp/rustfs-storage.log ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
/tmp/rustfs-storage-report.md ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
if-no-files-found: warn ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
if-no-files-found: error
- name: Cleanup environment (after) - name: Cleanup environment (after)
if: always() if: always()
+10 -8
View File
@@ -61,9 +61,6 @@ env:
jobs: jobs:
tier-test: tier-test:
runs-on: smoke-testing runs-on: smoke-testing
# Requirement: a failing suite must not fail the workflow; failures
# are filed to rustfs/backlog and the chain continues.
continue-on-error: true
timeout-minutes: 420 timeout-minutes: 420
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps: steps:
@@ -380,17 +377,22 @@ jobs:
fi fi
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md" REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")" # Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \ '{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:$content}' \ '{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi fi
rm -f "${B64_FILE}"
- name: Verify required tier evidence - name: Verify required tier evidence
id: evidence_verify id: evidence_verify
+89 -100
View File
@@ -18,7 +18,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
from_version: from_version:
description: 'OLD RustFS release tag (must ship a .deb asset, e.g. 1.0.0-rc.3)' description: 'OLD RustFS release tag, e.g. 1.0.0-rc.3 (its release must ship a .deb asset). Leave empty for the default.'
required: false required: false
default: '1.0.0-rc.3' default: '1.0.0-rc.3'
from_url: from_url:
@@ -26,7 +26,7 @@ on:
required: false required: false
type: string type: string
to_version: to_version:
description: 'NEW RustFS release tag (leave empty for latest nightly)' description: 'NEW RustFS release tag, e.g. 1.0.0-rc.5 (any version with a .deb asset). Leave empty for latest nightly.'
required: false required: false
to_url: to_url:
description: 'NEW .deb URL. Overrides to_version / nightly default.' description: 'NEW .deb URL. Overrides to_version / nightly default.'
@@ -79,10 +79,28 @@ env:
jobs: jobs:
upgrade-test: upgrade-test:
runs-on: smoke-testing runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 420 timeout-minutes: 420
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }} if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps: steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-upgrade-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not # auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures. # GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry) - name: Checkout auto-testing scripts (with retry)
@@ -142,9 +160,8 @@ jobs:
- name: Run upgrade compatibility suite - name: Run upgrade compatibility suite
id: test id: test
continue-on-error: true
env: env:
LOG_FILE: /tmp/rustfs-upgrade.log GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
chmod +x auto-testing/rustfs-upgrade-test.sh chmod +x auto-testing/rustfs-upgrade-test.sh
@@ -175,13 +192,33 @@ jobs:
else else
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}") ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi fi
# Fail fast with a clear message when a requested release tag has
# no .deb asset (e.g. 1.0.0-rc.4 ships only zips), instead of
# letting the suite die mid-run on a 404.
check_release_asset() {
local version="$1" tag asset url
[ -n "${version}" ] && [ "${version}" != "null" ] || return 0
tag="${version#v}"
asset="rustfs_${tag//-/.}_amd64.deb"
url="https://github.com/rustfs/rustfs/releases/download/${tag}/${asset}"
if ! gh api "repos/rustfs/rustfs/releases/tags/${tag}" --jq '.assets[].name' 2>/dev/null | grep -qxF "${asset}"; then
echo "ERROR: release ${tag} has no downloadable asset ${asset}:" >&2
echo " ${url}" >&2
echo "Pick a tag whose release ships a .deb (check its release assets)." >&2
exit 1
fi
echo "resolved ${tag} -> ${url}"
}
if [ -z "${FROM_URL}" ]; then
check_release_asset "${FROM_VERSION}"
fi
if [ -z "${TO_URL}" ]; then
check_release_asset "${TO_VERSION}"
fi
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}" ./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
- name: Generate report - name: Generate report
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
env:
LOG_FILE: /tmp/rustfs-upgrade.log
REPORT_FILE: /tmp/rustfs-upgrade-report.md
run: | run: |
set -euo pipefail set -euo pipefail
FROM_URL='${{ inputs.from_url }}' FROM_URL='${{ inputs.from_url }}'
@@ -202,103 +239,47 @@ jobs:
else else
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}" TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi fi
CASE_TABLE="/tmp/rustfs-upgrade-cases.md" CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
MATRIX_TABLE="/tmp/rustfs-upgrade-matrix.md" MATRIX_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/matrix.md"
python3 - "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" <<'PY' CASE_RESULT=success
import re python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" || CASE_RESULT=failure
import sys RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
log_file, out_file, matrix_file = sys.argv[1], sys.argv[2], sys.argv[3] RESULT=success
ansi = re.compile(r'\x1b\[[0-9;]*m') fi
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
topo_re = re.compile(
r'^\[UPG-TOPO\]\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+PASS=(\d+)\s+FAIL=(\d+)\s*$')
rows = []
index = {}
topo_rows = []
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = topo_re.match(line)
if m:
topo_rows.append(m.groups())
continue
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
# Upgrade matrix: one row per topology/backend with the versions
# captured on the nodes (rustfs --version) and the aggregated
# result. The dashboard renders this table directly.
with open(matrix_file, 'w', encoding='utf-8') as out:
out.write('## Upgrade Matrix\n\n')
out.write('| Topology | KMS Backend | From Version | To Version | Result |\n')
out.write('| --- | --- | --- | --- | --- |\n')
for topo, backend, old_v, new_v, npass, nfail in topo_rows:
result = 'PASS' if nfail == '0' else 'FAIL'
out.write(f'| {topo} | {backend} | {old_v} | {new_v} | {result} (PASS={npass} FAIL={nfail}) |\n')
if not topo_rows:
out.write('| - | - | - | - | NOT RUN (suite failed before upgrade) |\n')
PY
{ {
echo "# RustFS upgrade compatibility report" echo "# RustFS upgrade compatibility report"
echo "" echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}" echo "- Trigger: ${{ github.event_name }}"
echo "- From: ${FROM_SOURCE}" echo "- From: ${FROM_SOURCE}"
echo "- To: ${TO_SOURCE}" echo "- To: ${TO_SOURCE}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}" echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo "" echo ""
cat "${MATRIX_TABLE}" || true if [ "${RESULT}" = "success" ]; then
cat "${MATRIX_TABLE}"
echo "" echo ""
cat "${CASE_TABLE}" || true cat "${CASE_TABLE}"
echo "" echo ""
echo "## Log tail" echo "## Log tail"
echo '```text' echo '```text'
tail -n 200 "${LOG_FILE}" || true tail -n 200 "${LOG_FILE}"
echo '```' echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}" } | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}" cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard - name: Upload functional report to dashboard
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-upgrade-report.md
SUITE: upgrade SUITE: upgrade
run: | run: |
set -euo pipefail set -euo pipefail
@@ -308,28 +289,32 @@ jobs:
fi fi
DATE="$(date -u +%Y-%m-%d)" DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md" REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")" # Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)" SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \ '{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \ jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:$content}' \ '{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null | gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi fi
rm -f "${B64_FILE}"
- name: File failure issue in rustfs/backlog - name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }} if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
continue-on-error: true continue-on-error: true
env: env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }} GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'upgrade' SUITE: 'upgrade'
SUITE_LABEL: 'Upgrade compatibility' SUITE_LABEL: 'Upgrade compatibility'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-upgrade-report.md'
LOG_FILE: '/tmp/rustfs-upgrade.log'
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then if [ -z "${GH_TOKEN:-}" ]; then
@@ -357,14 +342,16 @@ jobs:
echo "" echo ""
echo "- Suite: \`${SUITE}\`" echo "- Suite: \`${SUITE}\`"
echo "- Run: ${RUN_URL}" echo "- Run: ${RUN_URL}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}" echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)" echo "- Date: $(date -u +%Y-%m-%d)"
echo "" echo ""
echo "## Report (errors and symptoms)" echo "## Report (errors and symptoms)"
echo "" echo ""
if [ -s "${REPORT_FILE}" ]; then if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}" redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)" echo "(report file missing; log tail below)"
echo "" echo ""
tail -n 200 "${LOG_FILE}" | redact tail -n 200 "${LOG_FILE}" | redact
@@ -380,14 +367,16 @@ jobs:
echo "filed backlog issue for suite ${SUITE}" echo "filed backlog issue for suite ${SUITE}"
- name: Upload report and logs - name: Upload report and logs
if: always() if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with: with:
name: rustfs-upgrade-test-${{ github.run_id }} name: rustfs-upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
path: | path: |
/tmp/rustfs-upgrade-report.md ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
/tmp/rustfs-upgrade.*/* ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
if-no-files-found: ignore ${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/matrix.md
if-no-files-found: error
retention-days: 3 retention-days: 3
- name: Cleanup environment (after) - name: Cleanup environment (after)
@@ -42,6 +42,7 @@ jobs:
- name: Check latest scheduled runs - name: Check latest scheduled runs
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUSTFS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: | run: |
set +e set +e
python3 scripts/check_scheduled_validation_freshness.py \ python3 scripts/check_scheduled_validation_freshness.py \
@@ -22,6 +22,7 @@ on:
- "Continuous Integration" - "Continuous Integration"
- "coverage" - "coverage"
- "e2e-nightly" - "e2e-nightly"
- "e2e-distributed"
- "e2e-s3tests" - "e2e-s3tests"
- "Fuzz" - "Fuzz"
- "mint" - "mint"
+1
View File
@@ -33,6 +33,7 @@ profile.json
*.zst *.zst
.secrets .secrets
*.go *.go
!crates/zip/tests/fixtures/snowball/**/generate/*.go
*.pb *.pb
*.svg *.svg
deploy/logs/*.log.* deploy/logs/*.log.*
+3 -3
View File
@@ -3,9 +3,9 @@
repos: repos:
- repo: local - repo: local
hooks: hooks:
- id: rustfs-dev-check - id: rustfs-fmt-check
name: rustfs dev-check name: Rust formatting
entry: make dev-check entry: cargo fmt --all --check
language: system language: system
types: [rust] types: [rust]
pass_filenames: false pass_filenames: false
+4 -1
View File
@@ -18,7 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source` - Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy - Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route - Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
- Limitations: listings show only local objects (the source is not merged into `ListObjectsV2`); PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata - Listings: `ListObjects` v1 remains local with ordinary key markers. `ListObjectsV2` can merge source objects when `policy.list_through = true`; this is off by default
- Upgrade and rollback: finish upgrading every node before enabling ODM. An rc.5 node that writes bucket configuration drops the ODM fields from metadata; neither a later restart nor moving the service out of ECStore recovers them. Before rollback, disable ODM and securely retain the original full configuration and credentials. After every node returns to a compatible version, restore and validate that configuration. Redacted exports cannot replace the credential backup; source-only objects are unavailable through RustFS while ODM is disabled. See the upgrade and rollback section of `docs/operations/on-demand-migration.md`
- Optional Google dependencies: default and `full` server builds retain native GCS support. `cargo build -p rustfs --no-default-features --features ftps,webdav` excludes Google SDKs while preserving configuration decoding and redaction; native GCS ODM and tier operations require the `gcs` feature. Do not use that build with existing GCS-tiered data
- Limitations: PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled. - **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes - Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window - Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
+11 -37
View File
@@ -109,24 +109,17 @@ affected boundaries and risks. CI still runs its configured repository gates.
### 🔒 Git Pre-commit Hooks (optional) ### 🔒 Git Pre-commit Hooks (optional)
Git hooks are **not** versioned in this repository, so a fresh clone has no The optional hook uses the checked-in `.pre-commit-config.yaml`. Install [pre-commit](https://pre-commit.com/#installation), then run this from the checkout or a linked worktree:
active pre-commit hook. If you add your own `.git/hooks/pre-commit` (a good
choice is a one-liner that runs `make pre-commit`), you can mark it executable
with:
```bash ```bash
make setup-hooks make setup-hooks
``` ```
Or manually: The hook runs `cargo fmt --all --check` when staged files include Rust source. It does not compile the workspace or run tests. Fix formatting with `cargo fmt --all`, inspect and stage the result, then commit again.
```bash `pre-commit install` resolves Git's hook directory for linked worktrees and preserves an existing hook in migration mode. If you use `core.hooksPath`, keep that hook manager and integrate `pre-commit run` there; the installer refuses to silently replace that configuration.
chmod +x .git/hooks/pre-commit
```
With or without a hook, follow the verification tiers in `AGENTS.md`. Run the A local hook provides early formatting feedback. With or without it, follow the verification tiers in `AGENTS.md`, run relevant behavioral tests, and satisfy the CI merge gates. `make pre-commit` and `make dev-check` remain explicit broader commands.
applicable scoped checks, and reserve `make pre-pr` for broad cross-module
changes whose impact cannot be bounded by those checks.
### 📝 Formatting Configuration ### 📝 Formatting Configuration
@@ -138,31 +131,11 @@ fn_call_width = 90
single_line_let_else_max_width = 100 single_line_let_else_max_width = 100
``` ```
### 🚫 Commit Prevention
If you set up a pre-commit hook and your code doesn't meet the formatting requirements, the hook will:
1. **Block the commit** and show clear error messages
2. **Provide exact commands** to fix the issues
3. **Guide you through** the resolution process
Example output when formatting fails:
```
❌ Code formatting check failed!
💡 Please run 'cargo fmt --all' to format your code before committing.
🔧 Quick fix:
cargo fmt --all
git add .
git commit
```
### 🔄 Development Workflow ### 🔄 Development Workflow
1. **Make your changes** 1. **Make your changes**
2. **Format your code**: `make fmt` or `cargo fmt --all` 2. **Format your code**: `make fmt` or `cargo fmt --all`
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests) 3. **Select relevant checks** using the validation tier in `AGENTS.md`; use `make pre-commit` when its broader fast gate adds useful coverage
4. **Commit your changes**: `git commit -m "your message"` 4. **Commit your changes**: `git commit -m "your message"`
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`) 5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
6. **Run applicable scoped checks before opening/updating a PR**; consider 6. **Run applicable scoped checks before opening/updating a PR**; consider
@@ -206,11 +179,12 @@ Configure your IDE to:
#### Pre-commit hook not running? #### Pre-commit hook not running?
```bash ```bash
# Check if hook is executable pre-commit validate-config
ls -la .git/hooks/pre-commit pre-commit run --all-files
# Inspect any configured hook manager; do not overwrite it.
# Make it executable if needed git config --get core.hooksPath
chmod +x .git/hooks/pre-commit # Install if no separate hook manager is configured.
make setup-hooks
``` ```
#### Formatting issues? #### Formatting issues?
Generated
+385 -138
View File
File diff suppressed because it is too large Load Diff
+23 -17
View File
@@ -168,7 +168,7 @@ reqwest = "0.13.4"
rustfs-kafka-async = { version = "1.3.1" } rustfs-kafka-async = { version = "1.3.1" }
socket2 = { version = "0.6.5" } socket2 = { version = "0.6.5" }
tokio = { version = "1.53.1" } tokio = { version = "1.53.1" }
tokio-rustls = { default-features = false, version = "0.26.4" } tokio-rustls = { default-features = false, version = "0.26.5" }
tokio-stream = { version = "0.1.19" } tokio-stream = { version = "0.1.19" }
tokio-test = "0.4.5" tokio-test = "0.4.5"
tokio-util = { version = "0.7.19" } tokio-util = { version = "0.7.19" }
@@ -191,6 +191,7 @@ rmp = { version = "0.8.15" }
rmp-serde = { version = "1.3.1" } rmp-serde = { version = "1.3.1" }
serde = { version = "1.0.229" } serde = { version = "1.0.229" }
serde_ignored = { version = "0.1" } serde_ignored = { version = "0.1" }
serde_with = { version = "3", default-features = false, features = ["macros", "std"] }
serde_json = { version = "1.0.151" } serde_json = { version = "1.0.151" }
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
@@ -199,10 +200,10 @@ serde_urlencoded = "0.7.1"
# matching stable releases are not available yet, while previous stable lines # matching stable releases are not available yet, while previous stable lines
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable # have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
# releases. # releases.
aes-gcm = { version = "=0.11.1" } aes-gcm = { version = "0.11.1" }
argon2 = { version = "=0.6.0" } argon2 = { version = "0.6.0" }
blake2 = "=0.11.0" blake2 = "0.11.0"
chacha20poly1305 = { version = "=0.11.0" } chacha20poly1305 = { version = "0.11.0" }
crc-fast = "1.10.0" crc-fast = "1.10.0"
hmac = { version = "0.13.0" } hmac = { version = "0.13.0" }
jsonwebtoken = { version = "11.0.0" } jsonwebtoken = { version = "11.0.0" }
@@ -234,15 +235,19 @@ tokio-postgres-rustls = "0.14.0"
# Utilities and Tools # Utilities and Tools
anyhow = "1.0.104" anyhow = "1.0.104"
arc-swap = "1.9.2" arc-swap = "1.9.2"
# RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin until every parser hardening used by Snowball is released upstream. Remove after astral-sh/tokio-tar#118 is merged and a published release includes extension, physical-entry, and sparse limits, cancellation-safe sparse parsing, and error-fused entry streams. # RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin while Snowball and Swift still depend on it. Remove after Snowball uses a released tar-codec/tar-framing API that exposes precedence-resolved MinIO vendor records, RustFS preserves cancellation-safe ownership of large streamed members, footerless minio-go input is accepted only at an authenticated complete request boundary, the existing resource-limit, cancellation, and error-fuse regressions pass, and Swift no longer needs this fork.
astral-tokio-tar = { git = "https://github.com/cxymds/tokio-tar.git", rev = "603756478b7668436e464519c77ccac22a99ba96" } astral-tokio-tar = { git = "https://github.com/cxymds/tokio-tar.git", rev = "603756478b7668436e464519c77ccac22a99ba96" }
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
tar-codec = "0.0.14"
tar-framing = "0.0.14"
atoi = "3.1.0" atoi = "3.1.0"
atomic_enum = "0.3.0" atomic_enum = "0.3.0"
aws-config = { version = "1.11.0" } aws-config = { version = "1.12.0" }
aws-credential-types = { version = "1.3.0" } aws-credential-types = { version = "1.3.0" }
aws-sdk-kms = { default-features = false, version = "1.117.0" } aws-sdk-kms = { default-features = false, version = "1.118.0" }
aws-sdk-s3 = { default-features = false, version = "1.144.0" } aws-sdk-s3 = { default-features = false, version = "1.145.0" }
aws-sdk-sts = { default-features = false, version = "1.113.0" } aws-sdk-sts = { default-features = false, version = "1.114.0" }
aws-smithy-async = { version = "1.3.0" }
aws-smithy-http-client = { default-features = false, version = "1.4.0" } aws-smithy-http-client = { default-features = false, version = "1.4.0" }
aws-smithy-runtime-api = { version = "1.16.0" } aws-smithy-runtime-api = { version = "1.16.0" }
aws-smithy-types = { version = "1.6.3" } aws-smithy-types = { version = "1.6.3" }
@@ -252,10 +257,10 @@ clap = { version = "4.6.6" }
const-str = { version = "1.1.0" } const-str = { version = "1.1.0" }
convert_case = "0.12.0" convert_case = "0.12.0"
criterion = { version = "0.8" } criterion = { version = "0.8" }
crossbeam-queue = "0.3.13" crossbeam-queue = "0.3.14"
crossbeam-channel = "0.5.16" crossbeam-channel = "0.5.17"
crossbeam-deque = "0.8.7" crossbeam-deque = "0.8.8"
crossbeam-utils = "0.8.22" crossbeam-utils = "0.8.23"
datafusion = { default-features = false, version = "55.0.0" } datafusion = { default-features = false, version = "55.0.0" }
derive_builder = "0.20.2" derive_builder = "0.20.2"
enumset = "1.1.14" enumset = "1.1.14"
@@ -302,7 +307,7 @@ rustfs-erasure-codec = { version = "8.0.2" }
reed-solomon-simd = "3.1.0" reed-solomon-simd = "3.1.0"
regex = { version = "1.13.1" } regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" } rumqttc = { package = "rumqttc-next", version = "0.34.0" }
redis = { version = "1.6.0" } redis = { version = "1.7.0" }
rustify = { version = "0.7", default-features = false } rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.4" } rustix = { version = "1.1.4" }
rust-embed = { version = "8.12.0" } rust-embed = { version = "8.12.0" }
@@ -339,7 +344,7 @@ windows = { version = "0.62.2" }
windows-sys = "0.61.2" windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.18" } xxhash-rust = { version = "0.8.18" }
zip = "8.6.0" zip = "8.6.0"
zstd = "0.13.3" zstd = "0.14.0"
# Observability and Metrics # Observability and Metrics
metrics = "0.24.6" metrics = "0.24.6"
@@ -367,7 +372,8 @@ dav-server = "0.11.0"
# Performance Analysis and Memory Profiling # Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.3" } rustfs-mimalloc = { version = "0.5.3" }
hotpath = { version = "0.25.0", default-features = false } # Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "=0.25.0", default-features = false }
# Snapshot testing for output format regression detection # Snapshot testing for output format regression detection
insta = { version = "1.48" } insta = { version = "1.48" }
+8 -5
View File
@@ -422,9 +422,9 @@ fn unix_now_ms() -> u64 {
.unwrap_or(0) .unwrap_or(0)
} }
/// A repair the MRF consumer landed, fanned out so retry ledgers can drop /// Legacy, unverified repair notice. Its identity lacks kind, set scope,
/// entries the journal no longer tracks (backlog#1894 axis B). The payload /// bucket incarnation and responsibility generation. Consumers must not use
/// mirrors the intent identity so consumers match without re-parsing. /// it to discharge persisted repair responsibility.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct MrfRepairedEvent { pub struct MrfRepairedEvent {
pub bucket: Arc<str>, pub bucket: Arc<str>,
@@ -439,8 +439,8 @@ const MRF_REPAIRED_EVENT_CAP: usize = 4096;
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new(); static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
/// Record that the MRF consumer landed a repair. Never blocks: the critical /// Record a legacy notification for compatibility. This is not an
/// section is a deque push under a std mutex. /// acknowledgement of storage verification or durable repair completion.
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) { pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new())); let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
let Ok(mut events) = registry.lock() else { let Ok(mut events) = registry.lock() else {
@@ -515,6 +515,9 @@ mod tests {
} }
coalescer_release(&key, Some(lease)); coalescer_release(&key, Some(lease));
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry"); let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
assert_ne!(lease, retry_lease);
coalescer_release(&key, Some(lease));
assert_eq!(coalescer_admit(key.clone()), Err(MrfIngressResult::Coalesced));
coalescer_release(&key, Some(retry_lease)); coalescer_release(&key, Some(retry_lease));
} }
+54
View File
@@ -130,6 +130,52 @@ Scanner cycle budget controls:
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling. - timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`. - this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
## Foreground write admission environment variables
Large direct `PutObject` requests and multipart `UploadPart` requests share one
per-process permit pool that bounds how many bodies are ingested and written
concurrently. Small direct PUTs stay on the legacy path.
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE`
- enables the default-on pool; `false` keeps only the soft request counter.
- default is `true`.
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT`
- permits in the pool; `0` derives half of `RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS`, clamped to `32`.
- default is `0` (32 permits at stock settings).
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES`
- smallest direct `PutObject` that takes a permit; unknown-size requests always do.
- default is `33554432` (32 MiB).
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
- how long a direct `PutObject` waits for a permit before returning S3 `SlowDown`.
- default is `250`.
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES`
- smallest `UploadPart` that takes a permit; `0` gates every part.
- default is `0`.
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
- how long an `UploadPart` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
- default is `30000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing.
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING`
- maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
- default is `0`, which derives 16 times the permit limit (512 at stock settings).
- `RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE`, `RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT`, `RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
- experimental strict gate that applies to every foreground write regardless of size and replaces the pool above when enabled.
- default is disabled; enabling it with limit `0` disables foreground write admission entirely.
## Remote tier timeout environment variables
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
- remote tier TCP connect timeout.
- default is `10`.
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
- `RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS`
- remote tier request timeout through response headers.
- default is `86400` so large transition uploads keep a production-safe budget.
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default. Very large values are accepted and act as a correspondingly long budget.
- `RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS`
- maximum idle time between remote tier response-body chunks.
- default is `60`; the timer resets only when non-empty body data keeps progressing.
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
## Drive timeout environment variables ## Drive timeout environment variables
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS` - `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
@@ -157,6 +203,14 @@ Drive timeout profile preset:
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback. - Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
- Then the profile-derived default (`default` or `high_latency`). - Then the profile-derived default (`default` or `high_latency`).
## Admin peer probe timeout
- `RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS`
- total per-peer budget for the `server_info`/`storage_info` admin probe round; `server_info` may reconnect once and `storage_info` remains a single attempt.
- default is `10` seconds, preserving the previous two-attempt worst-case budget.
- values must be positive; `0` or an invalid value falls back to the default, and values above `60` are clamped to `60`.
- the setting is read by the aggregating node only; it does not change the internode RPC wire contract. Any retry shares one round deadline rather than receiving a fresh timeout.
## Startup filesystem boundary policy ## Startup filesystem boundary policy
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary. - `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
+11
View File
@@ -39,6 +39,15 @@ pub const DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS: u64 = 20;
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS"; pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 30; pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 30;
/// Total budget for one admin peer probe round, including any reconnect retry.
///
/// This is intentionally separate from the transport-level RPC timeout: admin
/// probes may retry once, but the retry must consume the same round budget.
pub const ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS: &str = "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS";
pub const DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 10;
pub const MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 60;
const _: () = assert!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS <= MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS);
// ── Client-side internode gRPC channel tuning (P0) ── // ── Client-side internode gRPC channel tuning (P0) ──
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs` // These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to // on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
@@ -312,6 +321,7 @@ mod tests {
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5); assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5);
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 20); assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 20);
assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 30); assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 30);
assert_eq!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS, 10);
assert_eq!(DEFAULT_INTERNODE_HTTP_TUNING_PROFILE, "legacy"); assert_eq!(DEFAULT_INTERNODE_HTTP_TUNING_PROFILE, "legacy");
} }
@@ -412,6 +422,7 @@ mod tests {
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS" "RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
); );
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS"); assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
assert_eq!(ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS, "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS");
assert_eq!(ENV_INTERNODE_HTTP_TUNING_PROFILE, "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE"); assert_eq!(ENV_INTERNODE_HTTP_TUNING_PROFILE, "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE");
assert_eq!(ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST, "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST"); assert_eq!(ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST, "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST");
assert_eq!(ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS, "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS"); assert_eq!(ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS, "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS");
+56 -1
View File
@@ -137,6 +137,28 @@ pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE); const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED); const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
/// Environment variable for remote tier TCP connect timeout in seconds.
pub const ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS";
/// Default remote tier TCP connect timeout in seconds.
pub const DEFAULT_TIER_REMOTE_CONNECT_TIMEOUT_SECS: u64 = 10;
/// Environment variable for the remote tier request timeout in seconds.
///
/// This bounds upload/download request progress through response headers. The
/// default is intentionally large so multi-TiB transition uploads keep their
/// previous production budget while black-hole remotes no longer wait forever.
pub const ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS";
/// Default remote tier request timeout in seconds.
pub const DEFAULT_TIER_REMOTE_REQUEST_TIMEOUT_SECS: u64 = 24 * 60 * 60;
/// Environment variable for remote tier response-body idle timeout in seconds.
///
/// The timer is re-armed on every non-empty response-body chunk, so slow but
/// progressing remotes can continue while silent response bodies are cancelled.
pub const ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS";
/// Default remote tier response-body idle timeout in seconds.
pub const DEFAULT_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: u64 = 60;
/// Request the object-transaction fencing contract used by storage-owned /// Request the object-transaction fencing contract used by storage-owned
/// cleanup receipts and lock-window optimizations. /// cleanup receipts and lock-window optimizations.
/// ///
@@ -343,13 +365,36 @@ pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: &str =
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES"; "RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 0; pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 0;
/// Time in milliseconds an automatic foreground write waits for a permit. /// Time in milliseconds an automatic foreground direct PutObject waits for a permit.
/// ///
/// A short wait smooths transient bursts while still returning S3 /// A short wait smooths transient bursts while still returning S3
/// `SlowDown`/503 before body ingest when the node is already saturated. /// `SlowDown`/503 before body ingest when the node is already saturated.
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS"; pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250; pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
/// Time in milliseconds a multipart UploadPart waits for a foreground write permit.
///
/// SDK-default multipart clients send every part of an upload concurrently, so
/// a single node routinely sees several times more parts in flight than the
/// permit pool allows. Those parts have not ingested a body yet, so queueing
/// them costs a connection rather than memory or internode streams; the pool
/// still bounds the number of parts being written. The wait is long enough for
/// an ordinary queue to drain on modest hardware, and a part that cannot get a
/// permit within it fails with S3 `SlowDown`/503 for the client to retry.
/// `0` rejects immediately when the pool is full.
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str =
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 30_000;
/// Maximum multipart UploadPart requests waiting for a foreground write permit per process.
///
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
/// waiting, so a genuinely saturated node still fails fast instead of holding
/// an unbounded set of connections open for the whole wait timeout.
/// `0` derives the depth from the permit limit.
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: &str = "RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING";
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: usize = 0;
const _: () = assert!(DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE); const _: () = assert!(DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE);
/// Environment variable for minimum GetObject timeout in seconds. /// Environment variable for minimum GetObject timeout in seconds.
@@ -812,6 +857,16 @@ mod remote_version_state_tests {
); );
} }
#[test]
fn remote_tier_timeout_env_names_are_stable() {
assert_eq!(super::ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS");
assert_eq!(super::ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS");
assert_eq!(
super::ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS,
"RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS"
);
}
#[test] #[test]
fn data_movement_part_checksum_gate_uses_stable_environment_names() { fn data_movement_part_checksum_gate_uses_stable_environment_names() {
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE"); assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
+7
View File
@@ -26,6 +26,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/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) | | **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` | | **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` |
| **distributed 4×4** | [`src/distributed/`](src/distributed) | Storage-sensitive PR and nightly `e2e-distributed` lane: S3, object lock/WORM, versioning, bucket/site replication, quota, expand/decommission/rebalance, concurrency, chaos, 4-node upgrade of historical data and IAM AK/SK. Map: [`docs/testing/distributed-e2e.md`](../../docs/testing/distributed-e2e.md) |
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup | | **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory | | **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
@@ -171,6 +172,7 @@ the same profile for membership and execution with one nightly worker.
| KMS suite | `e2e-full` job, merge queue + main | **Active** | | KMS suite | `e2e-full` job, merge queue + main | **Active** |
| Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** | | Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) | | Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
| Distributed 4-node 4-disk (`e2e-distributed` profile) | `.github/workflows/e2e-distributed.yml` | **Active** (storage-sensitive PR / nightly / dispatch) |
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) | | Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) | | Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) | | Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
@@ -182,6 +184,8 @@ the wiring source of truth. Committed test-ID digests under
## Troubleshooting ## Troubleshooting
**Endpoint blackhole scenario skipped** — `heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_after_target_endpoint_blackhole` installs a loopback `iptables` DROP rule and therefore needs `CAP_NET_ADMIN` (root or passwordless `sudo -n iptables`). A host where `iptables` is missing or cannot read the OUTPUT chain (typical inside an unprivileged container, where the nf_tables backend reports "Permission denied" even under `sudo`) logs a `heal_interruption_skipped` warning and returns without exercising heal. Set `RUSTFS_E2E_REQUIRE_NET_FAULT_INJECTION=1` on lanes that do provision the capability so a broken runner fails instead of skipping.
**Reproduce a CI failure locally** — run the exact profile/lane: **Reproduce a CI failure locally** — run the exact profile/lane:
```bash ```bash
@@ -191,6 +195,9 @@ cargo nextest run --profile e2e-smoke -p e2e_test
cargo nextest run --profile e2e-full -p e2e_test cargo nextest run --profile e2e-full -p e2e_test
# Cluster fault nightly lane # Cluster fault nightly lane
cargo nextest run --profile e2e-nightly -p e2e_test cargo nextest run --profile e2e-nightly -p e2e_test
# 4-node 4-disk distributed lane (S3 / lock / versioning / replication / decommission / chaos / upgrade)
# Upgrade cases need RUSTFS_UPGRADE_SOURCE_BINARY; without it they fail closed.
cargo nextest run --profile e2e-distributed -p e2e_test
# Replication nightly lane; awscurl is required for STS paths # Replication nightly lane; awscurl is required for STS paths
cargo nextest run --profile e2e-repl-nightly -p e2e_test cargo nextest run --profile e2e-repl-nightly -p e2e_test
# Fixed-port protocol nightly lane # Fixed-port protocol nightly lane
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2024 RustFS Team
// Licensed under the Apache License, Version 2.0.
use std::path::Path;
use std::process::Command;
fn git(root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git").args(args).current_dir(root).output().ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
fn emit(name: &str, value: &str) {
let value = if value.contains(['\n', '\r']) { "unknown" } else { value };
println!("cargo:rustc-env=RUSTFS_E2E_BUILD_{name}={value}");
}
fn main() {
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default();
let root = Path::new(&manifest).join("../..");
// Cover dependency/common sources as well as this crate. HEAD/ref/index
// changes must refresh identity even when no Rust source mtime changes.
for path in [
"crates",
"rustfs",
"Cargo.toml",
"Cargo.lock",
"rust-toolchain.toml",
".cargo",
".config",
] {
println!("cargo:rerun-if-changed={}", root.join(path).display());
}
let mut git_paths = vec!["HEAD".to_owned(), "index".to_owned(), "packed-refs".to_owned()];
if let Some(reference) = git(&root, &["symbolic-ref", "-q", "HEAD"]) {
git_paths.push(reference);
}
for path in git_paths {
if let Some(path) = git(&root, &["rev-parse", "--git-path", &path]) {
let path = Path::new(&path);
let path = if path.is_absolute() {
path.to_owned()
} else {
root.join(path)
};
if path.exists() {
println!("cargo:rerun-if-changed={}", path.display());
}
}
}
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=normal"]).is_none_or(|status| !status.is_empty());
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
let mut features = std::env::vars()
.filter_map(|(key, _)| {
key.strip_prefix("CARGO_FEATURE_")
.map(|name| name.to_ascii_lowercase().replace('_', "-"))
})
.collect::<Vec<_>>();
features.sort();
emit("COMMIT", &revision);
emit("DIRTY", if dirty { "true" } else { "false" });
emit("LOCK", &lock);
emit("FEATURES", &features.join(","));
for name in ["TARGET", "PROFILE"] {
emit(name, &std::env::var(name).unwrap_or_else(|_| "unknown".to_owned()));
}
println!("cargo:rerun-if-env-changed=CARGO_ENCODED_RUSTFLAGS");
let flags = std::env::var("CARGO_ENCODED_RUSTFLAGS").unwrap_or_default();
let flags: String = flags.as_bytes().iter().map(|byte| format!("{byte:02x}")).collect();
emit("RUSTFLAGS_HEX", &flags);
}
+13 -3
View File
@@ -55,18 +55,20 @@ type ChaosResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
/// A successful S3 GET only proves that a quorum can serve an object. Replacement /// A successful S3 GET only proves that a quorum can serve an object. Replacement
/// tests need this lower-level record to prove that the rebuilt target holds the /// tests need this lower-level record to prove that the rebuilt target holds the
/// `xl.meta` selected for a specific version and every `part.N` it declares. /// `xl.meta` selected for a specific version and every `part.N` it declares.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub(crate) struct VersionShardCensus { pub(crate) struct VersionShardCensus {
pub version_id: Option<String>, pub version_id: Option<String>,
pub has_xl_meta: bool, pub has_xl_meta: bool,
pub data_dir: Option<String>, pub data_dir: Option<String>,
pub erasure_index: Option<usize>, pub erasure_index: Option<usize>,
pub data_blocks: Option<usize>,
pub parity_blocks: Option<usize>,
pub expected_part_numbers: BTreeSet<usize>, pub expected_part_numbers: BTreeSet<usize>,
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>, pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>,
pub inline_data_fingerprint: Option<PartShardFingerprint>, pub inline_data_fingerprint: Option<PartShardFingerprint>,
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
pub(crate) struct PartShardFingerprint { pub(crate) struct PartShardFingerprint {
pub size: u64, pub size: u64,
pub sha256: String, pub sha256: String,
@@ -88,13 +90,15 @@ impl VersionShardCensus {
&& manifest.is_complete() && manifest.is_complete()
&& self.data_dir == manifest.data_dir && self.data_dir == manifest.data_dir
&& self.erasure_index == manifest.erasure_index && self.erasure_index == manifest.erasure_index
&& self.data_blocks == manifest.data_blocks
&& self.parity_blocks == manifest.parity_blocks
&& self.expected_part_numbers == manifest.expected_part_numbers && self.expected_part_numbers == manifest.expected_part_numbers
&& self.present_part_fingerprints == manifest.present_part_fingerprints && self.present_part_fingerprints == manifest.present_part_fingerprints
&& self.inline_data_fingerprint == manifest.inline_data_fingerprint && self.inline_data_fingerprint == manifest.inline_data_fingerprint
} }
} }
fn sha256_hex(data: &[u8]) -> String { pub(crate) fn sha256_hex(data: &[u8]) -> String {
let digest = Sha256::digest(data); let digest = Sha256::digest(data);
digest.iter().map(|byte| format!("{byte:02x}")).collect() digest.iter().map(|byte| format!("{byte:02x}")).collect()
} }
@@ -313,6 +317,8 @@ pub(crate) fn census_object_version_on_disk(
has_xl_meta: false, has_xl_meta: false,
data_dir: None, data_dir: None,
erasure_index: None, erasure_index: None,
data_blocks: None,
parity_blocks: None,
expected_part_numbers: BTreeSet::new(), expected_part_numbers: BTreeSet::new(),
present_part_fingerprints: BTreeMap::new(), present_part_fingerprints: BTreeMap::new(),
inline_data_fingerprint: None, inline_data_fingerprint: None,
@@ -360,6 +366,8 @@ pub(crate) fn census_object_version_on_disk(
has_xl_meta: true, has_xl_meta: true,
data_dir, data_dir,
erasure_index, erasure_index,
data_blocks: Some(file_info.erasure.data_blocks),
parity_blocks: Some(file_info.erasure.parity_blocks),
expected_part_numbers, expected_part_numbers,
present_part_fingerprints, present_part_fingerprints,
inline_data_fingerprint, inline_data_fingerprint,
@@ -413,6 +421,8 @@ mod tests {
has_xl_meta: true, has_xl_meta: true,
data_dir: Some("data-dir".to_string()), data_dir: Some("data-dir".to_string()),
erasure_index: Some(3), erasure_index: Some(3),
data_blocks: Some(2),
parity_blocks: Some(2),
expected_part_numbers: BTreeSet::from([1]), expected_part_numbers: BTreeSet::from([1]),
present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]), present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]),
inline_data_fingerprint: None, inline_data_fingerprint: None,
+63
View File
@@ -1700,6 +1700,69 @@ impl RustFSTestClusterEnvironment {
Ok(()) Ok(())
} }
/// Append a new single-node erasure pool to a stopped multi-pool cluster.
///
/// Used to simulate pool expansion on localhost: every pool already owns
/// exactly one node with `drives_per_node >= 2` (the only multi-pool layout
/// the single-host `RUSTFS_VOLUMES` syntax can express). The new node is
/// allocated a fresh port and empty drive directories; callers must
/// [`Self::start`] afterwards so every process picks up the extended
/// volumes argument. Existing data directories are left untouched.
pub async fn append_single_node_pool(&mut self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
if self.nodes.iter().any(|node| node.process.is_some()) {
return Err("stop the cluster before appending a pool".into());
}
if self.topology.drives_per_node < 2 {
return Err(
"append_single_node_pool requires drives_per_node >= 2 (the server parser rejects a single-drive ellipses pool)"
.into(),
);
}
let mut pools = self.topology.normalized_pools();
for (pool_idx, nodes) in pools.iter().enumerate() {
if nodes.len() != 1 {
return Err(format!(
"pool {pool_idx} spans {} nodes; append_single_node_pool requires one node per pool",
nodes.len()
)
.into());
}
}
let new_idx = self.nodes.len();
let port = RustFSTestEnvironment::find_available_port().await?;
let address = format!("127.0.0.1:{port}");
let data_dirs: Vec<String> = (0..self.topology.drives_per_node)
.map(|drive| format!("{}/node{}/drive{}", self.temp_dir, new_idx, drive))
.collect();
for dir in &data_dirs {
fs::create_dir_all(dir).await?;
}
self.nodes.push(ClusterNode {
url: format!("http://{address}"),
address,
data_dir: data_dirs[0].clone(),
data_dirs,
pool_idx: pools.len(),
process: None,
});
pools.push(vec![new_idx]);
self.topology.node_count = self.nodes.len();
self.topology.pools = pools;
self.node_extra_env.push(Vec::new());
self.node_capture_log_paths.push(None);
self.volume_proxy_addresses.push(None);
if !self.extra_env.iter().any(|(key, _)| key == "RUSTFS_UNSAFE_BYPASS_DISK_CHECK") {
self.extra_env
.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
}
Ok(new_idx)
}
/// Gracefully stop one cluster node and wait for its process to exit. /// Gracefully stop one cluster node and wait for its process to exit.
/// ///
/// This is intentionally separate from [`Self::stop_node`]: the latter is /// This is intentionally separate from [`Self::stop_node`]: the latter is
+6 -2
View File
@@ -35,11 +35,15 @@ where
{ {
let mut last_usage = DataUsageInfo::default(); let mut last_usage = DataUsageInfo::default();
let mut last_query_error = None; let mut last_query_error = None;
for _ in 0..45 { for _ in 0..90 {
match get_data_usage_info(env).await { match get_data_usage_info(env).await {
Ok(usage) => { Ok(usage) => {
last_query_error = None; last_query_error = None;
if usage.buckets_usage.contains_key(bucket) && predicate(&usage) { if usage.is_complete_bucket_usage_snapshot()
&& usage.usage_snapshot_converged != Some(false)
&& usage.buckets_usage.contains_key(bucket)
&& predicate(&usage)
{
return Ok(usage); return Ok(usage);
} }
last_usage = usage; last_usage = usage;
@@ -0,0 +1,222 @@
// 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/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 super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
wait_for_ready, wait_until,
};
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
use crate::common::{build_test_s3_config, init_logging};
use crate::fault_proxy::FaultMode;
use aws_sdk_s3::Client;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Barrier, mpsc};
use tokio::time::timeout;
#[tokio::test]
async fn kill_and_restart_node_preserves_objects() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("killnode");
dist.create_bucket(&bucket).await?;
let body = vec![0x11u8; 128 * 1024];
put_object(&dist.client(0)?, &bucket, "keep.bin", body.clone()).await?;
dist.cluster.stop_node(3)?;
retrying_get_equals(&dist.client(0)?, &bucket, "keep.bin", &body, Duration::from_secs(20)).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
assert_object_bytes(&dist.client(3)?, &bucket, "keep.bin", &body).await?;
Ok(())
}
#[tokio::test]
async fn full_cluster_restart_preserves_objects() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("pwr");
dist.create_bucket(&bucket).await?;
let body = vec![0x44u8; 64 * 1024];
put_object(&dist.client(1)?, &bucket, "survive.bin", body.clone()).await?;
dist.cluster.stop();
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
for node_idx in 0..dist.cluster.nodes.len() {
assert_object_bytes(&dist.client(node_idx)?, &bucket, "survive.bin", &body).await?;
}
Ok(())
}
#[tokio::test]
async fn fresh_drive_replacement_is_physically_healed_without_data_change() -> TestResult {
init_logging();
let mut dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_HEAL_ENABLED", "true")]).await?;
let bucket = unique_bucket("baddrive");
dist.create_bucket(&bucket).await?;
let body = payload_for("fresh-drive/durable.bin", 8 * 1024 * 1024);
put_object(&dist.client(1)?, &bucket, "durable.bin", body.clone()).await?;
let replaced_drive = PathBuf::from(&dist.cluster.nodes[0].data_dirs[0]);
let baseline = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
assert!(
baseline.is_complete(),
"replacement target did not hold a complete baseline shard: {baseline:?}"
);
assert!(
!baseline.expected_part_numbers.is_empty(),
"replacement witness must use physical part shards: {baseline:?}"
);
dist.cluster.stop_node(0)?;
let format_path = replaced_drive.join(".rustfs.sys/format.json");
let format = std::fs::read(&format_path)?;
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
std::fs::rename(&replaced_drive, &retired_drive)?;
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path omitted parent")?)?;
std::fs::write(&format_path, format)?;
let empty = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
assert!(!empty.has_xl_meta, "fresh replacement unexpectedly retained object metadata: {empty:?}");
dist.cluster.start_node(0).await?;
wait_for_ready(&dist.cluster).await?;
let heal_body =
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[1].url);
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
wait_until(
Duration::from_secs(90),
|| async {
let healed = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
Ok(healed.matches_manifest(&baseline))
},
"fresh replacement contains the original complete shard manifest",
)
.await?;
for node_idx in 0..dist.cluster.nodes.len() {
assert_object_bytes(&dist.client(node_idx)?, &bucket, "durable.bin", &body).await?;
}
Ok(())
}
#[tokio::test]
async fn concurrent_gets_survive_peer_node_kill() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("getkill");
dist.create_bucket(&bucket).await?;
let body = payload_for("inflight/steady.bin", 8 * 1024 * 1024);
put_object(&dist.client(0)?, &bucket, "steady.bin", body.clone()).await?;
let live: Vec<_> = (0..3).map(|idx| dist.client(idx)).collect::<Result<Vec<_>, _>>()?;
let worker_count = 12;
let release = Arc::new(Barrier::new(worker_count + 1));
let (started_tx, mut started_rx) = mpsc::unbounded_channel();
let mut handles = Vec::new();
for idx in 0..worker_count {
let client = live[idx % live.len()].clone();
let bucket = bucket.clone();
let body = body.clone();
let release = release.clone();
let started_tx = started_tx.clone();
handles.push(tokio::spawn(async move {
let response = client.get_object().bucket(&bucket).key("steady.bin").send().await?;
if response.content_length() != Some(body.len() as i64) {
return Err::<(), Box<dyn std::error::Error + Send + Sync>>(
format!("worker {idx} received a wrong content length").into(),
);
}
started_tx.send(idx)?;
release.wait().await;
let actual = response.body.collect().await?.into_bytes();
if actual.as_ref() != body.as_slice() {
return Err(format!("worker {idx} received corrupted bytes after peer kill").into());
}
Ok(())
}));
}
drop(started_tx);
for _ in 0..worker_count {
timeout(Duration::from_secs(30), started_rx.recv())
.await?
.ok_or("a streaming GET exited before reaching the kill barrier")?;
}
dist.cluster.stop_node(3)?;
release.wait().await;
for handle in handles {
handle.await??;
}
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
assert_object_bytes(&dist.client(3)?, &bucket, "steady.bin", &body).await?;
Ok(())
}
#[tokio::test]
async fn blackholed_node_client_network_preserves_cluster_availability_and_recovers() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let proxy = crate::fault_proxy::FaultProxy::start(dist.cluster.nodes[3].address.parse()?).await?;
let proxied_url = format!("http://{}", proxy.local_addr());
let proxied_client = Client::from_conf(build_test_s3_config(
&proxied_url,
&dist.cluster.access_key,
&dist.cluster.secret_key,
None,
"distributed-network-chaos",
));
let result: TestResult = async {
let bucket = unique_bucket("netfault");
dist.create_bucket(&bucket).await?;
let baseline = payload_for("network/baseline.bin", 1024 * 1024);
put_object(&dist.client(0)?, &bucket, "baseline.bin", baseline.clone()).await?;
assert_object_bytes(&proxied_client, &bucket, "baseline.bin", &baseline).await?;
proxy.set_mode(FaultMode::Blackhole);
assert_eq!(proxy.mode(), FaultMode::Blackhole);
if let Ok(Ok(_)) = timeout(
Duration::from_secs(5),
proxied_client.get_object().bucket(&bucket).key("baseline.bin").send(),
)
.await
{
return Err("blackholed node endpoint unexpectedly completed a GET".into());
}
let during = payload_for("network/during.bin", 1024 * 1024);
timeout(Duration::from_secs(30), async {
put_object(&dist.client(1)?, &bucket, "during-blackhole.bin", during.clone()).await?;
assert_object_bytes(&dist.client(2)?, &bucket, "baseline.bin", &baseline).await?;
assert_object_bytes(&dist.client(0)?, &bucket, "during-blackhole.bin", &during).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
})
.await??;
proxy.set_mode(FaultMode::Pass);
retrying_get_equals(&proxied_client, &bucket, "during-blackhole.bin", &during, Duration::from_secs(30)).await?;
Ok(())
}
.await;
proxy.set_mode(FaultMode::Pass);
proxy.shutdown().await;
result
}
@@ -0,0 +1,98 @@
// 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/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 super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, unique_bucket};
use crate::common::init_logging;
use std::collections::BTreeSet;
use std::sync::Arc;
use tokio::sync::Barrier;
#[tokio::test]
async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("conc");
dist.create_bucket(&bucket).await?;
let clients = Arc::new(dist.clients()?);
let worker_count = 24;
let rounds = 4;
let barrier = Arc::new(Barrier::new(worker_count));
let mut handles = Vec::new();
for idx in 0..worker_count {
let clients = clients.clone();
let barrier = barrier.clone();
let bucket = bucket.clone();
handles.push(tokio::spawn(async move {
barrier.wait().await;
let writer = &clients[idx % clients.len()];
let reader = &clients[(idx + 1) % clients.len()];
let copier = &clients[(idx + 2) % clients.len()];
let mut retained = Vec::with_capacity(rounds);
for round in 0..rounds {
let key = format!("source/worker-{idx:02}-round-{round}.bin");
let copy_key = format!("retained/worker-{idx:02}-round-{round}.bin");
let body = payload_for(&key, 64 * 1024);
put_object(writer, &bucket, &key, body.clone()).await?;
let head = reader.head_object().bucket(&bucket).key(&key).send().await?;
if head.content_length() != Some(body.len() as i64) {
return Err(format!("HEAD returned the wrong size for {key}: {head:?}").into());
}
assert_object_bytes(reader, &bucket, &key, &body).await?;
copier
.copy_object()
.bucket(&bucket)
.key(&copy_key)
.copy_source(format!("{bucket}/{key}"))
.send()
.await?;
assert_object_bytes(writer, &bucket, &copy_key, &body).await?;
writer.delete_object().bucket(&bucket).key(&key).send().await?;
let missing = reader
.head_object()
.bucket(&bucket)
.key(&key)
.send()
.await
.expect_err("deleted source key must not remain visible");
if missing.raw_response().map(|response| response.status().as_u16()) != Some(404) {
return Err(format!("deleted source {key} returned an unexpected result: {missing:?}").into());
}
retained.push((copy_key, body));
}
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(retained)
}));
}
let mut inventory = Vec::new();
for handle in handles {
inventory.extend(handle.await??);
}
let expected_keys: BTreeSet<_> = inventory.iter().map(|(key, _)| key.as_str()).collect();
for (node_idx, client) in clients.iter().enumerate() {
let listed = client.list_objects_v2().bucket(&bucket).prefix("retained/").send().await?;
let listed_keys: BTreeSet<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(listed_keys, expected_keys, "node {node_idx} returned a divergent retained-key listing");
for (key, body) in &inventory {
assert_object_bytes(client, &bucket, key, body)
.await
.map_err(|error| format!("node {node_idx} failed to read {key}: {error}"))?;
}
}
Ok(())
}
@@ -0,0 +1,74 @@
// 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/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 super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
decommission_status_json, payload_for, put_inventory_retrying, retrying_get_equals, retrying_put, start_decommission,
unique_bucket, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
};
use crate::common::init_logging;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Barrier;
#[tokio::test]
async fn concurrent_puts_during_decommission_do_not_lose_baseline_or_new_objects() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("concdecom");
dist.create_bucket(&bucket).await?;
let baseline_client = dist.client(0)?;
let inventory = put_inventory_retrying(&baseline_client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
dist.expand_to_four_pools().await?;
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
let clients = Arc::new(dist.clients()?);
let barrier = Arc::new(Barrier::new(17));
let mut handles = Vec::new();
for idx in 0..16 {
let clients = clients.clone();
let barrier = barrier.clone();
let bucket = bucket.clone();
handles.push(tokio::spawn(async move {
barrier.wait().await;
let client = &clients[idx % clients.len()];
let key = format!("live/{idx:02}.bin");
let body = payload_for(&key, 8 * 1024);
retrying_put(client, &bucket, &key, body.clone(), Duration::from_secs(45)).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((key, body))
}));
}
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
barrier.wait().await;
let mut live_objects = Vec::new();
for handle in handles {
live_objects.push(handle.await??);
}
let status = decommission_status_json(&dist.cluster).await?;
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
return Err(format!("decommission did not remain active across concurrent PUTs: {status}").into());
}
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
let checker = dist.client(2)?;
assert_inventory(&checker, &bucket, &inventory).await?;
for (key, body) in live_objects {
retrying_get_equals(&checker, &bucket, &key, &body, Duration::from_secs(30)).await?;
}
Ok(())
}
@@ -0,0 +1,156 @@
// 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/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 super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, enable_versioning, put_inventory_retrying,
sha256_hex, start_decommission, unique_bucket, wait_for_decommission_active, wait_for_decommission_complete,
};
use crate::common::init_logging;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use std::time::Duration;
#[tokio::test]
async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("integrity");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
enable_versioning(&client, &bucket).await?;
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
let before: Vec<(String, String)> = inventory.iter().map(|(key, body)| (key.clone(), sha256_hex(body))).collect();
let versioned_key = "history/versioned.bin";
let version_one = b"historical bytes before data movement".to_vec();
let version_two = b"current bytes before data movement".to_vec();
let version_one_id = client
.put_object()
.bucket(&bucket)
.key(versioned_key)
.body(ByteStream::from(version_one.clone()))
.send()
.await?
.version_id()
.ok_or("historical PUT omitted version ID")?
.to_string();
let version_two_id = client
.put_object()
.bucket(&bucket)
.key(versioned_key)
.body(ByteStream::from(version_two.clone()))
.send()
.await?
.version_id()
.ok_or("current PUT omitted version ID")?
.to_string();
let multipart_key = "multipart/moved.bin";
let first_part = vec![0x31; 5 * 1024 * 1024];
let second_part = vec![0x72; 1024 * 1024];
let upload = client
.create_multipart_upload()
.bucket(&bucket)
.key(multipart_key)
.send()
.await?;
let upload_id = upload.upload_id().ok_or("movement multipart upload omitted upload ID")?;
let uploaded_one = client
.upload_part()
.bucket(&bucket)
.key(multipart_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(first_part.clone()))
.send()
.await?;
let uploaded_two = client
.upload_part()
.bucket(&bucket)
.key(multipart_key)
.upload_id(upload_id)
.part_number(2)
.body(ByteStream::from(second_part.clone()))
.send()
.await?;
client
.complete_multipart_upload()
.bucket(&bucket)
.key(multipart_key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(uploaded_one.e_tag().ok_or("movement part 1 omitted ETag")?)
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(uploaded_two.e_tag().ok_or("movement part 2 omitted ETag")?)
.build(),
)
.build(),
)
.send()
.await?;
dist.expand_to_four_pools().await?;
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
let after_client = dist.client(2)?;
assert_inventory(&after_client, &bucket, &inventory).await?;
for (key, expected_hash) in before {
let got = after_client.get_object().bucket(&bucket).key(&key).send().await?;
let body = got.body.collect().await?.into_bytes();
assert_eq!(sha256_hex(body.as_ref()), expected_hash, "checksum changed for {key} after decommission");
}
for (version_id, expected) in [(&version_one_id, &version_one), (&version_two_id, &version_two)] {
let got = after_client
.get_object()
.bucket(&bucket)
.key(versioned_key)
.version_id(version_id)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(got.as_ref(), expected.as_slice(), "version {version_id} changed after decommission");
}
let mut expected_multipart = first_part;
expected_multipart.extend_from_slice(&second_part);
let got_multipart = after_client
.get_object()
.bucket(&bucket)
.key(multipart_key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(
sha256_hex(got_multipart.as_ref()),
sha256_hex(&expected_multipart),
"multipart checksum changed after decommission"
);
Ok(())
}
@@ -0,0 +1,81 @@
// 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/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 super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, list_pools_json, put_inventory,
put_inventory_retrying, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_active,
wait_for_decommission_complete, wait_for_rebalance_active, wait_for_rebalance_complete,
};
use crate::common::init_logging;
use std::time::Duration;
#[tokio::test]
async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("expand");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let inventory = put_inventory(&client, &bucket, 64, 256 * 1024).await?;
assert_inventory(&client, &bucket, &inventory).await?;
for expected_nodes in 2..=4 {
let new_node = dist.append_pool_and_restart().await?;
assert_eq!(new_node + 1, expected_nodes);
assert_inventory(&dist.client(new_node)?, &bucket, &inventory).await?;
}
assert_eq!(dist.cluster.nodes.len(), 4);
// Prove that the expanded pool map is durable, and clear any recovery
// latch raised while the newly-added pool replicas converged.
dist.restart_current_binary_gracefully().await?;
let after_expand = dist.client(0)?;
assert_inventory(&after_expand, &bucket, &inventory).await?;
let peer = dist.client(3)?;
assert_inventory(&peer, &bucket, &inventory).await?;
let rebalance_id = start_rebalance(&dist.cluster).await?;
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
assert_inventory(&peer, &bucket, &inventory).await?;
Ok(())
}
#[tokio::test]
async fn four_pool_decommission_moves_objects_without_loss() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("decom");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let inventory = put_inventory_retrying(&client, &bucket, 96, 128 * 1024, Duration::from_secs(30)).await?;
dist.expand_to_four_pools().await?;
let pools_before = list_pools_json(&dist.cluster).await?;
let pool_count = pools_before
.as_array()
.map(Vec::len)
.or_else(|| pools_before.get("pools").and_then(serde_json::Value::as_array).map(Vec::len))
.ok_or_else(|| format!("pool list omitted an array: {pools_before}"))?;
assert_eq!(pool_count, 4, "expected exactly four pools before decommission: {pools_before}");
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
let after = dist.client(2)?;
assert_inventory(&after, &bucket, &inventory).await?;
Ok(())
}
@@ -0,0 +1,149 @@
// 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/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 super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket, wait_until,
};
use crate::common::init_logging;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use std::time::Duration;
#[tokio::test]
async fn four_node_four_drive_multipart_and_cross_node_listing_agree() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("extra");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let key = "multipart.bin";
let part1 = vec![0x41u8; 5 * 1024 * 1024];
let part2 = vec![0x42u8; 5 * 1024 * 1024];
let upload = client.create_multipart_upload().bucket(&bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload id")?.to_string();
let uploaded1 = client
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(&upload_id)
.part_number(1)
.body(ByteStream::from(part1.clone()))
.send()
.await?;
let uploaded2 = client
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(&upload_id)
.part_number(2)
.body(ByteStream::from(part2.clone()))
.send()
.await?;
client
.complete_multipart_upload()
.bucket(&bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(uploaded1.e_tag().unwrap_or_default())
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(uploaded2.e_tag().unwrap_or_default())
.build(),
)
.build(),
)
.send()
.await?;
let mut expected = part1;
expected.extend_from_slice(&part2);
for node_idx in 0..dist.cluster.nodes.len() {
assert_object_bytes(&dist.client(node_idx)?, &bucket, key, &expected).await?;
}
put_object(&client, &bucket, "list/a", b"a".to_vec()).await?;
put_object(&dist.client(2)?, &bucket, "list/b", b"b".to_vec()).await?;
let mut seen = Vec::new();
for node_idx in 0..dist.cluster.nodes.len() {
let listed = dist
.client(node_idx)?
.list_objects_v2()
.bucket(&bucket)
.prefix("list/")
.send()
.await?;
let keys: Vec<String> = listed
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_string))
.collect();
seen.push(keys);
}
for keys in &seen[1..] {
assert_eq!(&seen[0], keys, "list results diverged across nodes: {seen:?}");
}
let got = get_object_bytes(&dist.client(3)?, &bucket, "list/a").await?;
assert_eq!(got, b"a");
Ok(())
}
#[tokio::test]
async fn four_node_list_buckets_agree_across_all_nodes() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("listed");
dist.create_bucket(&bucket).await?;
put_object(&dist.client(0)?, &bucket, "seed.bin", b"seed".to_vec()).await?;
for node_idx in 0..dist.cluster.nodes.len() {
let client = dist.client(node_idx)?;
let name = bucket.clone();
wait_until(
Duration::from_secs(20),
|| {
let client = client.clone();
let name = name.clone();
async move {
let listed = client.list_buckets().send().await?;
Ok(listed.buckets().iter().any(|entry| entry.name() == Some(name.as_str())))
}
},
&format!("node {node_idx} lists {bucket}"),
)
.await?;
wait_until(
Duration::from_secs(20),
|| {
let client = dist.client(node_idx).expect("client");
let name = bucket.clone();
async move { Ok(get_object_bytes(&client, &name, "seed.bin").await.ok() == Some(b"seed".to_vec())) }
},
&format!("node {node_idx} reads seed.bin"),
)
.await?;
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
// 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.
//! 4-node 4-drive distributed e2e coverage.
//!
//! Selected by `[profile.e2e-distributed]` and run from
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
//! each case starts four real `rustfs` processes.
mod chaos_test;
mod concurrency_stability_test;
mod concurrent_data_movement_test;
mod data_integrity_movement_test;
mod expand_decommission_rebalance_test;
mod extra_test;
mod harness;
mod object_lock_test;
mod observability_test;
mod replication_quota_test;
mod s3_basic_test;
mod s3_during_data_movement_test;
mod site_replication_test;
mod upgrade_test;
mod versioning_test;
@@ -0,0 +1,219 @@
// 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 super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
use crate::common::init_logging;
use crate::object_lock::common::{
delete_object_with_bypass, put_object_lock_configuration, put_object_with_legal_hold, put_object_with_retention,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
ObjectLockRule,
};
use chrono::{Duration as ChronoDuration, Utc};
fn delete_denied(error: &SdkError<DeleteObjectError>, context: &str) -> TestResult {
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
if code == Some("AccessDenied") {
Ok(())
} else {
Err(format!("{context}: expected AccessDenied, got {error:?}").into())
}
}
async fn expect_versioned_delete_denied(
client: &Client,
bucket: &str,
key: &str,
version_id: &str,
bypass: bool,
context: &str,
) -> TestResult {
match delete_object_with_bypass(client, bucket, key, Some(version_id), bypass).await {
Ok(_) => Err(format!("{context}: DeleteObject of retained version must be denied").into()),
Err(error) => delete_denied(error.as_ref(), context),
}
}
#[tokio::test]
async fn four_node_four_drive_object_lock_worm_blocks_delete() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let client = dist.client(0)?;
let peer = dist.client(2)?;
let bucket = unique_bucket("objlock");
client
.create_bucket()
.bucket(&bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
let retain_until = Utc::now() + ChronoDuration::days(1);
let compliance_key = "compliance.bin";
let compliance_version = put_object_with_retention(
&client,
&bucket,
compliance_key,
b"locked-compliance",
ObjectLockRetentionMode::Compliance,
retain_until,
)
.await?;
// Unversioned DELETE is allowed: it only creates a delete marker. WORM
// applies to a specific version id.
let marker = peer.delete_object().bucket(&bucket).key(compliance_key).send().await?;
assert_eq!(
marker.delete_marker(),
Some(true),
"unversioned DELETE on a locked object must create a delete marker"
);
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, false, "COMPLIANCE without bypass")
.await?;
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, true, "COMPLIANCE with bypass").await?;
let governance_key = "governance.bin";
let governance_version = put_object_with_retention(
&client,
&bucket,
governance_key,
b"locked-governance",
ObjectLockRetentionMode::Governance,
retain_until,
)
.await?;
expect_versioned_delete_denied(&peer, &bucket, governance_key, &governance_version, false, "GOVERNANCE without bypass")
.await?;
delete_object_with_bypass(&peer, &bucket, governance_key, Some(&governance_version), true).await?;
let deleted_governance = peer
.head_object()
.bucket(&bucket)
.key(governance_key)
.version_id(&governance_version)
.send()
.await
.expect_err("GOVERNANCE bypass must remove the retained version");
assert_eq!(
deleted_governance.raw_response().map(|response| response.status().as_u16()),
Some(404),
"deleted GOVERNANCE version returned an unexpected HEAD result: {deleted_governance:?}"
);
let hold_key = "legal-hold.bin";
let hold_version =
put_object_with_legal_hold(&client, &bucket, hold_key, b"legal-hold", ObjectLockLegalHoldStatus::On).await?;
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, false, "legal hold without bypass").await?;
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, true, "legal hold with bypass").await?;
Ok(())
}
#[tokio::test]
async fn four_node_default_retention_is_visible_and_non_lock_bucket_rejects_configuration() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
let bucket = unique_bucket("default-lock");
writer
.create_bucket()
.bucket(&bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
put_object_lock_configuration(&writer, &bucket, ObjectLockRetentionMode::Governance, Some(1), None).await?;
let key = "default-governance.bin";
let put = writer
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"default retention payload"))
.send()
.await?;
let version_id = put.version_id().ok_or("default-retained PUT omitted version ID")?;
let config = reader.get_object_lock_configuration().bucket(&bucket).send().await?;
let default_retention = config
.object_lock_configuration()
.and_then(|configuration| configuration.rule())
.and_then(|rule| rule.default_retention())
.ok_or("GetObjectLockConfiguration omitted default retention")?;
assert_eq!(default_retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
assert_eq!(default_retention.days(), Some(1));
let retention = reader
.get_object_retention()
.bucket(&bucket)
.key(key)
.version_id(version_id)
.send()
.await?;
let retention = retention.retention().ok_or("GetObjectRetention omitted applied retention")?;
assert_eq!(retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
let retain_until = retention
.retain_until_date()
.ok_or("default retention omitted retain-until date")?;
assert!(retain_until.secs() > Utc::now().timestamp(), "default retention is not in the future");
let versioning = reader.get_bucket_versioning().bucket(&bucket).send().await?;
assert_eq!(versioning.status().map(|status| status.as_str()), Some("Enabled"));
expect_versioned_delete_denied(&reader, &bucket, key, version_id, false, "default GOVERNANCE retention without bypass")
.await?;
let plain_bucket = unique_bucket("no-lock");
dist.create_bucket(&plain_bucket).await?;
let configuration = ObjectLockConfiguration::builder()
.object_lock_enabled(ObjectLockEnabled::Enabled)
.rule(
ObjectLockRule::builder()
.default_retention(
DefaultRetention::builder()
.mode(ObjectLockRetentionMode::Governance)
.days(1)
.build(),
)
.build(),
)
.build();
let error = writer
.put_object_lock_configuration()
.bucket(&plain_bucket)
.object_lock_configuration(configuration)
.send()
.await
.expect_err("an unversioned bucket must reject Object Lock enablement");
let service_error = error
.as_service_error()
.ok_or("non-lock bucket rejection was not an S3 service error")?;
assert_eq!(service_error.code(), Some("InvalidBucketState"), "unexpected error: {error:?}");
assert_eq!(
service_error.message(),
Some("Object Lock configuration cannot be enabled on existing buckets"),
"unexpected error: {error:?}"
);
Ok(())
}
@@ -0,0 +1,236 @@
// 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 super::harness::{DistCluster, DistLayout, TestResult, cluster_admin_ok, unique_bucket, wait_for_ready};
use crate::common::{admin_request, init_logging, local_http_client};
use aws_sdk_s3::operation::RequestId;
use aws_sdk_s3::primitives::ByteStream;
use bytes::Bytes;
use http::Method;
use http_body_util::{BodyExt, Empty};
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use local_ip_address::local_ip;
use rustfs_madmin::metrics::RealtimeMetrics;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use serde_json::Value;
use std::convert::Infallible;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::{Instant, timeout};
async fn spawn_audit_collector() -> TestResult<(String, mpsc::UnboundedReceiver<Value>, JoinHandle<()>)> {
let listener = TcpListener::bind("0.0.0.0:0").await?;
let endpoint = format!("http://{}/audit", std::net::SocketAddr::new(local_ip()?, listener.local_addr()?.port()));
let (tx, rx) = mpsc::unbounded_channel();
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let tx = tx.clone();
tokio::spawn(async move {
let service = service_fn(move |request: Request<Incoming>| {
let tx = tx.clone();
async move {
let method = request.method().clone();
if let Ok(body) = request.into_body().collect().await
&& method == Method::POST
&& let Ok(payload) = serde_json::from_slice::<Value>(&body.to_bytes())
{
if let Some(records) = payload["Records"].as_array() {
for entry in records {
let _ = tx.send(entry.clone());
}
} else {
let _ = tx.send(payload);
}
}
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.await;
});
}
});
Ok((endpoint, rx, handle))
}
async fn wait_for_audit_entry(
rx: &mut mpsc::UnboundedReceiver<Value>,
bucket: &str,
key: &str,
request_id: &str,
) -> TestResult<Value> {
let deadline = Instant::now() + Duration::from_secs(30);
let mut seen = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!(
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
seen.len()
)
.into());
}
let entry = match timeout(remaining, rx.recv()).await {
Ok(Some(entry)) => entry,
Ok(None) => return Err("audit collector stopped before the expected entry arrived".into()),
Err(_) => {
return Err(format!(
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
seen.len()
)
.into());
}
};
if entry["api"]["name"].as_str() == Some("s3:PutObject")
&& entry["api"]["bucket"].as_str() == Some(bucket)
&& entry["api"]["object"].as_str() == Some(key)
&& entry["requestID"].as_str() == Some(request_id)
{
return Ok(entry);
}
if seen.len() < 8 {
seen.push(format!(
"api={:?} bucket={:?} object={:?} requestID={:?}",
entry["api"]["name"].as_str(),
entry["api"]["bucket"].as_str(),
entry["api"]["object"].as_str(),
entry["requestID"].as_str()
));
}
}
}
#[tokio::test]
async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent() -> TestResult {
init_logging();
let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?;
let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization();
let audit_env = [
("RUSTFS_AUDIT_ENABLE", "true"),
("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"),
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()),
(ENV_OUTBOUND_ALLOW_ORIGINS, audit_origin.as_str()),
];
let mut dist = DistCluster::new_stopped_with_env(DistLayout::FourByFour, &audit_env).await?;
for node_idx in 0..dist.cluster.nodes.len() {
let queue_dir = format!("{}/audit-queue-node-{node_idx}", dist.cluster.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
dist.cluster
.set_node_env(node_idx, "RUSTFS_AUDIT_WEBHOOK_QUEUE_DIR_DISTRIBUTED", queue_dir)?;
}
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
let http = local_http_client();
for node in &dist.cluster.nodes {
for probe in ["ready", "live"] {
let response = http.get(format!("{}/health/{probe}", node.url)).send().await?;
assert!(
response.status().is_success(),
"node {} {probe} probe failed: {}",
node.address,
response.status()
);
}
}
let info_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/info", None).await?;
let info: Value = serde_json::from_str(&info_body)?;
let servers = info["info"]["servers"]
.as_array()
.ok_or_else(|| format!("admin info omitted servers: {info}"))?;
assert_eq!(servers.len(), 4, "admin info did not report all four nodes: {info}");
let storage_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
let storage: Value = serde_json::from_str(&storage_body)?;
let disks = storage["info"]["disks"]
.as_array()
.ok_or_else(|| format!("storageinfo omitted disks: {storage}"))?;
assert_eq!(disks.len(), 16, "storageinfo did not report all sixteen drives: {storage}");
assert!(
disks.iter().all(|disk| {
disk["state"].as_str().is_some_and(|state| state.eq_ignore_ascii_case("ok"))
&& disk["runtimeState"]
.as_str()
.is_some_and(|state| state.eq_ignore_ascii_case("online"))
}),
"storageinfo reported a drive that was not healthy and online: {storage}"
);
for (node_idx, node) in dist.cluster.nodes.iter().enumerate() {
let (status, metrics_body) = admin_request(
&node.url,
Method::GET,
"/rustfs/admin/v3/metrics?n=1&by-host=true&by-disk=true",
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert!(status.is_success(), "node {node_idx} metrics failed: {status} {metrics_body}");
let sample: RealtimeMetrics = serde_json::from_str(
metrics_body
.lines()
.next()
.ok_or_else(|| format!("node {node_idx} returned empty metrics"))?,
)?;
assert!(sample.finally, "node {node_idx} metrics sample was not terminal");
assert!(sample.errors.is_empty(), "node {node_idx} metrics reported errors: {:?}", sample.errors);
assert!(!sample.hosts.is_empty(), "node {node_idx} metrics omitted hosts");
}
let targets_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/audit/target/list", None).await?;
let targets: Value = serde_json::from_str(&targets_body)?;
let configured = targets["audit_endpoints"]
.as_array()
.ok_or_else(|| format!("audit target list omitted audit_endpoints: {targets}"))?
.iter()
.any(|target| target["account_id"].as_str() == Some("distributed") && target["service"].as_str() == Some("webhook"));
assert!(configured, "configured audit webhook was missing: {targets}");
let bucket = unique_bucket("audit");
dist.create_bucket(&bucket).await?;
let key = "correlated/audit-object.bin";
let put = dist
.client(2)?
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"distributed audit payload"))
.send()
.await?;
let request_id = put.request_id().ok_or("PutObject response omitted request ID")?;
let audit = wait_for_audit_entry(&mut audit_entries, &bucket, key, request_id).await?;
assert_eq!(
audit["api"]["status_code"].as_i64(),
Some(200),
"audit entry did not report success: {audit}"
);
assert!(
!audit.to_string().contains(&dist.cluster.secret_key),
"audit entry leaked the root secret key"
);
collector.abort();
Ok(())
}
@@ -0,0 +1,191 @@
// 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/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 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,
};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use std::time::Duration;
async fn wait_for_replication_status(
client: &aws_sdk_s3::Client,
bucket: &str,
key: &str,
expected: &[&str],
timeout: Duration,
) -> TestResult {
wait_until(
timeout,
|| async {
let head = client.head_object().bucket(bucket).key(key).send().await?;
Ok(head
.replication_status()
.is_some_and(|status| expected.contains(&status.as_str())))
},
&format!("replication status for {bucket}/{key} in {expected:?}"),
)
.await
}
#[tokio::test]
async fn four_node_bucket_replication_converges_to_peer_cluster() -> TestResult {
init_logging();
let (source, mut target) = DistCluster::start_replication_pair().await?;
let source_bucket = unique_bucket("replsrc");
let target_bucket = unique_bucket("repldst");
source.create_bucket(&source_bucket).await?;
target.create_bucket(&target_bucket).await?;
let source_client = source.client(0)?;
let target_client = target.client(0)?;
enable_versioning(&source_client, &source_bucket).await?;
enable_versioning(&target_client, &target_bucket).await?;
let arn = set_remote_target(&source.cluster, &source_bucket, &target.cluster, &target_bucket).await?;
put_bucket_replication(&source.cluster, &source_bucket, &arn).await?;
let key = "replicated/metadata-and-tags.bin";
let body = b"distributed-bucket-replication".to_vec();
source_client
.put_object()
.bucket(&source_bucket)
.key(key)
.metadata("origin", "four-node-source")
.tagging("suite=distributed&shape=metadata")
.body(ByteStream::from(body.clone()))
.send()
.await?;
wait_for_replicated_bytes(&target_client, &target_bucket, key, &body, Duration::from_secs(45)).await?;
wait_for_replication_status(&source_client, &source_bucket, key, &["COMPLETED"], Duration::from_secs(30)).await?;
let peer_read = target.client(3)?;
wait_for_replicated_bytes(&peer_read, &target_bucket, key, &body, Duration::from_secs(15)).await?;
let replica_head = peer_read.head_object().bucket(&target_bucket).key(key).send().await?;
assert_eq!(
replica_head
.metadata()
.and_then(|metadata| metadata.get("origin"))
.map(String::as_str),
Some("four-node-source")
);
assert_eq!(replica_head.replication_status().map(|status| status.as_str()), Some("REPLICA"));
let replica_tags = peer_read.get_object_tagging().bucket(&target_bucket).key(key).send().await?;
let tags: std::collections::BTreeMap<_, _> = replica_tags.tag_set().iter().map(|tag| (tag.key(), tag.value())).collect();
assert_eq!(tags.get("suite"), Some(&"distributed"));
assert_eq!(tags.get("shape"), Some(&"metadata"));
target.cluster.stop();
let outage_key = "replicated/queued-during-target-outage.bin";
let outage_body = b"retry-after-target-restart".to_vec();
put_object(&source_client, &source_bucket, outage_key, outage_body.clone()).await?;
wait_for_replication_status(
&source_client,
&source_bucket,
outage_key,
&["PENDING", "FAILED"],
Duration::from_secs(30),
)
.await?;
target.cluster.start().await?;
wait_for_ready(&target.cluster).await?;
wait_for_replicated_bytes(&target.client(2)?, &target_bucket, outage_key, &outage_body, Duration::from_secs(90)).await?;
wait_for_replication_status(&source_client, &source_bucket, outage_key, &["COMPLETED"], Duration::from_secs(45)).await?;
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?;
set_bucket_quota(&dist.cluster, &bucket, 8 * 1024).await?;
let client = dist.client(1)?;
retrying_put(&client, &bucket, "small.bin", vec![0u8; 1024], Duration::from_secs(30)).await?;
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);
}
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)
},
"quota stats observe small object",
)
.await?;
let oversized_key = "too-big.bin";
let error = client
.put_object()
.bucket(&bucket)
.key(oversized_key)
.body(vec![0u8; 16 * 1024].into())
.send()
.await
.expect_err("hard quota must reject the oversized PUT");
let service_error = error
.as_service_error()
.ok_or("quota rejection was not an S3 service error")?;
assert_eq!(
error.raw_response().map(|response| response.status().as_u16()),
Some(400),
"quota rejection must be HTTP 400: {error:?}"
);
assert_eq!(service_error.code(), Some("InvalidRequest"), "unexpected quota error: {error:?}");
assert!(
service_error
.message()
.is_some_and(|message| message.starts_with("Bucket quota exceeded")),
"PUT must fail specifically at quota admission: {error:?}"
);
let missing = client
.head_object()
.bucket(&bucket)
.key(oversized_key)
.send()
.await
.expect_err("an object rejected by quota must not become visible");
assert_eq!(
missing.raw_response().map(|response| response.status().as_u16()),
Some(404),
"quota-rejected object returned an unexpected HEAD result: {missing:?}"
);
let listed = client.list_objects_v2().bucket(&bucket).send().await?;
assert!(
listed.contents().iter().all(|object| object.key() != Some(oversized_key)),
"quota-rejected key leaked into ListObjectsV2"
);
Ok(())
}
@@ -0,0 +1,258 @@
// 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 super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket};
use crate::common::{init_logging, local_http_client};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{Delete, MetadataDirective, ObjectIdentifier};
use std::time::Duration;
#[tokio::test]
async fn four_node_four_drive_s3_put_get_head_list_copy_rename_delete_and_presign() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("s3basic");
dist.create_bucket(&bucket).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
let key = "dir/object.bin";
let body = vec![0xA5u8; 256 * 1024];
put_object(&writer, &bucket, key, body.clone()).await?;
let head = reader.head_object().bucket(&bucket).key(key).send().await?;
assert_eq!(head.content_length(), Some(body.len() as i64));
assert_object_bytes(&reader, &bucket, key, &body).await?;
let ranged = reader
.get_object()
.bucket(&bucket)
.key(key)
.range("bytes=0-15")
.send()
.await?;
let ranged_body = ranged.body.collect().await?.into_bytes();
assert_eq!(ranged_body.as_ref(), &body[..16]);
let listed = reader.list_objects_v2().bucket(&bucket).prefix("dir/").send().await?;
let keys: Vec<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(keys, vec![key]);
let copy_key = "dir/object-copy.bin";
reader
.copy_object()
.bucket(&bucket)
.key(copy_key)
.copy_source(format!("{bucket}/{key}"))
.metadata_directive(MetadataDirective::Copy)
.send()
.await?;
assert_object_bytes(&writer, &bucket, copy_key, &body).await?;
let moved_key = "dir/object-moved.bin";
writer
.copy_object()
.bucket(&bucket)
.key(moved_key)
.copy_source(format!("{bucket}/{copy_key}"))
.send()
.await?;
writer.delete_object().bucket(&bucket).key(copy_key).send().await?;
match writer.head_object().bucket(&bucket).key(copy_key).send().await {
Ok(_) => return Err("copied source still present after rename delete".into()),
Err(error) if error.as_service_error().is_some_and(|err| err.is_not_found()) => {}
Err(error) => return Err(error.into()),
}
assert_object_bytes(&reader, &bucket, moved_key, &body).await?;
let presigned = writer
.get_object()
.bucket(&bucket)
.key(key)
.presigned(PresigningConfig::expires_in(Duration::from_secs(120))?)
.await?;
let response = local_http_client().get(presigned.uri().to_string()).send().await?;
assert!(response.status().is_success(), "presigned GET failed: {}", response.status());
let presigned_body = response.bytes().await?;
assert_eq!(presigned_body.as_ref(), body.as_slice());
let empty_key = "empty";
put_object(&writer, &bucket, empty_key, Vec::new()).await?;
let empty = get_object_bytes(&reader, &bucket, empty_key).await?;
assert!(empty.is_empty());
let deleted = writer
.delete_objects()
.bucket(&bucket)
.delete(
Delete::builder()
.objects(ObjectIdentifier::builder().key(key).build()?)
.objects(ObjectIdentifier::builder().key(moved_key).build()?)
.objects(ObjectIdentifier::builder().key(empty_key).build()?)
.build()?,
)
.send()
.await?;
assert!(deleted.errors().is_empty(), "DeleteObjects reported failures: {deleted:?}");
assert_eq!(deleted.deleted().len(), 3, "DeleteObjects did not acknowledge every key");
let remaining = reader.list_objects_v2().bucket(&bucket).send().await?;
assert!(remaining.contents().is_empty(), "bucket still has objects after delete");
Ok(())
}
#[tokio::test]
async fn four_node_s3_metadata_tags_special_keys_pagination_and_multipart_abort() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("s3matrix");
dist.create_bucket(&bucket).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
let special_key = "unicode/测试 space+percent%25.txt";
let special_body = b"metadata and tagging survive distributed routing".to_vec();
let put = writer
.put_object()
.bucket(&bucket)
.key(special_key)
.metadata("test-meta", "distributed")
.tagging("purpose=compatibility&scope=four-by-four")
.body(ByteStream::from(special_body.clone()))
.send()
.await?;
let etag = put.e_tag().ok_or("PutObject omitted ETag")?.to_string();
let head = reader.head_object().bucket(&bucket).key(special_key).send().await?;
assert_eq!(
head.metadata()
.and_then(|metadata| metadata.get("test-meta"))
.map(String::as_str),
Some("distributed")
);
assert_eq!(head.e_tag(), Some(etag.as_str()));
let tags = reader.get_object_tagging().bucket(&bucket).key(special_key).send().await?;
let actual_tags: std::collections::BTreeMap<_, _> = tags
.tag_set()
.iter()
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
.collect();
assert_eq!(actual_tags.get("purpose").map(String::as_str), Some("compatibility"));
assert_eq!(actual_tags.get("scope").map(String::as_str), Some("four-by-four"));
let conditional = reader
.get_object()
.bucket(&bucket)
.key(special_key)
.if_match(&etag)
.send()
.await?;
assert_eq!(conditional.body.collect().await?.into_bytes().as_ref(), special_body.as_slice());
let invalid_range = reader
.get_object()
.bucket(&bucket)
.key(special_key)
.range("bytes=999999-1000000")
.send()
.await
.expect_err("an unsatisfiable range must fail");
assert_eq!(
invalid_range.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidRange"),
"unexpected invalid-range error: {invalid_range:?}"
);
let upload_key = "multipart/aborted.bin";
let upload = writer
.create_multipart_upload()
.bucket(&bucket)
.key(upload_key)
.send()
.await?;
let upload_id = upload.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
writer
.upload_part()
.bucket(&bucket)
.key(upload_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(vec![0x5Au8; 5 * 1024 * 1024]))
.send()
.await?;
let pending = reader
.list_multipart_uploads()
.bucket(&bucket)
.prefix("multipart/")
.send()
.await?;
assert!(pending.uploads().iter().any(|entry| entry.upload_id() == Some(upload_id)));
writer
.abort_multipart_upload()
.bucket(&bucket)
.key(upload_key)
.upload_id(upload_id)
.send()
.await?;
let after_abort = reader
.list_multipart_uploads()
.bucket(&bucket)
.prefix("multipart/")
.send()
.await?;
assert!(after_abort.uploads().iter().all(|entry| entry.upload_id() != Some(upload_id)));
let aborted_head = reader
.head_object()
.bucket(&bucket)
.key(upload_key)
.send()
.await
.expect_err("aborted multipart upload must not create an object");
assert_eq!(
aborted_head.raw_response().map(|response| response.status().as_u16()),
Some(404),
"aborted multipart object returned an unexpected HEAD result: {aborted_head:?}"
);
for index in 0..113 {
let key = format!("page/{index:04}.txt");
put_object(&writer, &bucket, &key, format!("page-{index}").into_bytes()).await?;
}
let mut token = None;
let mut paged_keys = Vec::new();
loop {
let page = reader
.list_objects_v2()
.bucket(&bucket)
.prefix("page/")
.max_keys(37)
.set_continuation_token(token.take())
.send()
.await?;
paged_keys.extend(page.contents().iter().filter_map(|object| object.key().map(str::to_string)));
if page.is_truncated() != Some(true) {
break;
}
token = Some(
page.next_continuation_token()
.ok_or("truncated ListObjectsV2 page omitted next continuation token")?
.to_string(),
);
}
assert_eq!(paged_keys.len(), 113);
let expected: Vec<_> = (0..113).map(|index| format!("page/{index:04}.txt")).collect();
assert_eq!(paged_keys, expected, "pagination lost, duplicated, or reordered keys");
Ok(())
}
@@ -0,0 +1,94 @@
// 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/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 super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
};
use crate::common::init_logging;
use std::time::Duration;
#[tokio::test]
async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("s3move");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
dist.expand_to_four_pools().await?;
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
let live = dist.client(2)?;
retrying_put(
&live,
&bucket,
"during-decommission.bin",
b"written-while-decommissioning".to_vec(),
Duration::from_secs(30),
)
.await?;
retrying_get_equals(
&live,
&bucket,
"during-decommission.bin",
b"written-while-decommissioning",
Duration::from_secs(30),
)
.await?;
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
assert!(
listed
.contents()
.iter()
.any(|object| object.key() == Some("during-decommission.bin")),
"list during decommission missed the newly written key"
);
let status = decommission_status_json(&dist.cluster).await?;
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
return Err(format!("decommission did not remain active across the S3 operations: {status}").into());
}
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
assert_inventory(&live, &bucket, &inventory).await?;
let rebalance_id = start_rebalance(&dist.cluster).await?;
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
retrying_put(
&live,
&bucket,
"during-rebalance.bin",
b"written-while-rebalancing".to_vec(),
Duration::from_secs(30),
)
.await?;
retrying_get_equals(
&live,
&bucket,
"during-rebalance.bin",
b"written-while-rebalancing",
Duration::from_secs(30),
)
.await?;
let status = rebalance_status_json(&dist.cluster).await?;
if !rebalance_running_with_progress(&status, &rebalance_id)? {
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
}
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
Ok(())
}
@@ -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/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 super::harness::{
DistCluster, TestResult, cluster_admin_ok, enable_versioning, put_object, unique_bucket, wait_for_replicated_bytes,
wait_until,
};
use crate::common::{init_logging, signed_request};
use http::{Method, StatusCode};
use rustfs_madmin::{PeerSite, ReplicateAddStatus, SiteReplicationInfo, SyncStatus};
use std::time::Duration;
async fn site_replication_add(
cluster: &crate::common::RustFSTestClusterEnvironment,
sites: &[PeerSite],
) -> TestResult<ReplicateAddStatus> {
let url = format!("{}/rustfs/admin/v3/site-replication/add?replicateILMExpiry=false", cluster.nodes[0].url);
let response = signed_request(
Method::PUT,
&url,
&cluster.access_key,
&cluster.secret_key,
Some(serde_json::to_vec(sites)?),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("site replication add failed: {status} {body}").into());
}
Ok(serde_json::from_slice(&response.bytes().await?)?)
}
async fn site_replication_info(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult<SiteReplicationInfo> {
let body = cluster_admin_ok(cluster, Method::GET, "/rustfs/admin/v3/site-replication/info", None).await?;
Ok(serde_json::from_str(&body)?)
}
async fn wait_for_site_replication_enabled(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult {
wait_until(
Duration::from_secs(30),
|| async {
let info = site_replication_info(cluster).await?;
Ok(info.enabled && info.sites.len() == 2 && info.sites.iter().all(|site| site.sync_state == SyncStatus::Enable))
},
"site replication enabled with two synchronized sites",
)
.await
}
#[tokio::test]
async fn four_node_site_replication_replicates_object_to_peer_site() -> TestResult {
init_logging();
let (site_a, site_b) = DistCluster::start_replication_pair().await?;
let bucket = unique_bucket("siterepl");
site_a.create_bucket(&bucket).await?;
site_b.create_bucket(&bucket).await?;
let client_a = site_a.client(0)?;
let client_b = site_b.client(0)?;
enable_versioning(&client_a, &bucket).await?;
enable_versioning(&client_b, &bucket).await?;
let sites = vec![
PeerSite {
name: "site-a".to_string(),
endpoint: site_a.cluster.nodes[0].url.clone(),
access_key: site_a.cluster.access_key.clone(),
secret_key: site_a.cluster.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "site-b".to_string(),
endpoint: site_b.cluster.nodes[0].url.clone(),
access_key: site_b.cluster.access_key.clone(),
secret_key: site_b.cluster.secret_key.clone(),
..Default::default()
},
];
let add_status = site_replication_add(&site_a.cluster, &sites).await?;
assert!(
add_status.success && add_status.err_detail.is_empty() && add_status.initial_sync_error_message.is_empty(),
"site replication add reported failure: {add_status:?}"
);
wait_for_site_replication_enabled(&site_a.cluster).await?;
wait_for_site_replication_enabled(&site_b.cluster).await?;
let info_a = site_replication_info(&site_a.cluster).await?;
let remote = info_a
.sites
.iter()
.find(|site| site.name == "site-b")
.ok_or_else(|| format!("site A info omitted the configured site-b peer: {info_a:?}"))?;
assert_eq!(remote.endpoint, site_b.cluster.nodes[0].url);
let deployment_ids: std::collections::BTreeSet<_> = info_a.sites.iter().map(|site| site.deployment_id.as_str()).collect();
assert!(
deployment_ids.iter().all(|deployment_id| !deployment_id.is_empty()) && deployment_ids.len() == 2,
"site peers must have two distinct non-empty deployment IDs: {info_a:?}"
);
assert!(info_a.retry_stats.is_none(), "site A has pending replication retries: {info_a:?}");
assert!(info_a.pending_operation.is_none(), "site A has a pending operation: {info_a:?}");
let key = "site-object.bin";
let body = b"four-node-site-replication".to_vec();
put_object(&client_a, &bucket, key, body.clone()).await?;
wait_for_replicated_bytes(&client_b, &bucket, key, &body, Duration::from_secs(60)).await?;
let peer_b = site_b.client(3)?;
wait_for_replicated_bytes(&peer_b, &bucket, key, &body, Duration::from_secs(20)).await?;
let reverse_key = "reverse/site-object.bin";
let reverse_body = b"site-b-to-site-a".to_vec();
put_object(&site_b.client(2)?, &bucket, reverse_key, reverse_body.clone()).await?;
wait_for_replicated_bytes(&site_a.client(3)?, &bucket, reverse_key, &reverse_body, Duration::from_secs(60)).await?;
Ok(())
}
@@ -0,0 +1,345 @@
// 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/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.
//! 4-node upgrade coverage for historical objects and IAM AK/SK.
//!
//! Complements `upgrade_compatibility_test` (single-node SSE/multipart and
//! mixed-version listing). This module pins the distributed contract the
//! hardware upgrade chain is meant to catch: after a 4-node upgrade, objects
//! written on the previous release still read back, and IAM user credentials
//! created before the upgrade still authenticate.
//!
//! Requires `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous
//! release. The `e2e-distributed` workflow downloads that binary; a local run
//! without it fails closed rather than skipping.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, enable_versioning, get_object_bytes, put_object,
unique_bucket, wait_until,
};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use std::path::{Path, PathBuf};
use std::time::Duration;
use uuid::Uuid;
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
const IAM_SECRET: &str = "UpgradeTestSecretKey1";
const WRONG_SECRET: &str = "WrongSecretKey000000";
const CREDENTIAL_TIMEOUT: Duration = Duration::from_secs(30);
struct UpgradeSeed {
history_bucket: String,
history_key: &'static str,
history_body: Vec<u8>,
versioned_bucket: String,
versioned_key: &'static str,
version1: String,
version1_body: Vec<u8>,
version2: String,
version2_body: Vec<u8>,
iam_bucket: String,
iam_key: &'static str,
iam_body: Vec<u8>,
iam_user: String,
iam_secret: &'static str,
}
fn source_binary() -> TestResult<PathBuf> {
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
format!(
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
)
})?;
if !path.is_file() {
return Err(format!("upgrade source binary does not exist: {}", path.display()).into());
}
Ok(path)
}
fn capture_upgrade_logs(cluster: &mut DistCluster, label: &str) -> TestResult {
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
return Ok(());
};
std::fs::create_dir_all(&log_dir)?;
for node_idx in 0..cluster.cluster.nodes.len() {
let path = Path::new(&log_dir).join(format!("{label}-node-{node_idx}.log"));
cluster
.cluster
.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
}
Ok(())
}
fn iam_rw_policy(bucket: &str) -> String {
serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": [
format!("arn:aws:s3:::{bucket}"),
format!("arn:aws:s3:::{bucket}/*")
]
}]
})
.to_string()
}
async fn create_iam_user(dist: &DistCluster, user: &str, secret: &str, policy_name: &str, bucket: &str) -> TestResult {
let url = &dist.cluster.nodes[0].url;
let access = &dist.cluster.access_key;
let admin_secret = &dist.cluster.secret_key;
admin_create_user_via(AdminTransport::Signed, url, access, admin_secret, user, secret).await?;
admin_add_canned_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, &iam_rw_policy(bucket)).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, user).await?;
Ok(())
}
async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, label: &str) -> TestResult {
wait_until(
CREDENTIAL_TIMEOUT,
|| {
let client = client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
let body = body.clone();
async move {
put_object(&client, &bucket, &key, body).await?;
Ok(true)
}
},
label,
)
.await
}
async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8], label: &str) -> TestResult {
wait_until(
CREDENTIAL_TIMEOUT,
|| {
let client = client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
let expected = expected.to_vec();
async move {
let got = get_object_bytes(&client, &bucket, &key).await?;
Ok(got == expected)
}
},
label,
)
.await
}
async fn seed_history_and_iam(dist: &DistCluster) -> TestResult<UpgradeSeed> {
let history_bucket = unique_bucket("upg-hist");
let versioned_bucket = unique_bucket("upg-ver");
let iam_bucket = unique_bucket("upg-iam");
dist.create_bucket(&history_bucket).await?;
dist.create_bucket(&versioned_bucket).await?;
dist.create_bucket(&iam_bucket).await?;
let root = dist.client(0)?;
enable_versioning(&root, &versioned_bucket).await?;
let history_key = "plain-history.bin";
let history_body = b"written by the previous 4-node release".to_vec();
put_object(&root, &history_bucket, history_key, history_body.clone()).await?;
let versioned_key = "versioned-history.txt";
let version1_body = b"version-one-before-upgrade".to_vec();
let version1 = root
.put_object()
.bucket(&versioned_bucket)
.key(versioned_key)
.body(aws_sdk_s3::primitives::ByteStream::from(version1_body.clone()))
.send()
.await?
.version_id()
.ok_or("first versioned PUT omitted version ID")?
.to_string();
let version2_body = b"version-two-before-upgrade".to_vec();
let version2 = root
.put_object()
.bucket(&versioned_bucket)
.key(versioned_key)
.body(aws_sdk_s3::primitives::ByteStream::from(version2_body.clone()))
.send()
.await?
.version_id()
.ok_or("second versioned PUT omitted version ID")?
.to_string();
let iam_user = format!("upg{}", &Uuid::new_v4().simple().to_string()[..8]);
let policy_name = format!("upgpol{}", &Uuid::new_v4().simple().to_string()[..8]);
create_iam_user(dist, &iam_user, IAM_SECRET, &policy_name, &iam_bucket).await?;
let iam_key = "iam-history.bin";
let iam_body = b"written with pre-upgrade IAM AK/SK".to_vec();
let iam_client = dist.client_with_credentials(1, &iam_user, IAM_SECRET)?;
wait_for_put(&iam_client, &iam_bucket, iam_key, iam_body.clone(), "IAM user PUT before upgrade").await?;
Ok(UpgradeSeed {
history_bucket,
history_key,
history_body,
versioned_bucket,
versioned_key,
version1,
version1_body,
version2,
version2_body,
iam_bucket,
iam_key,
iam_body,
iam_user,
iam_secret: IAM_SECRET,
})
}
async fn assert_history_and_iam(dist: &DistCluster, seed: &UpgradeSeed, context: &str) -> TestResult {
let root_a = dist.client(0)?;
let root_b = dist.client(3)?;
wait_for_bytes(
&root_b,
&seed.history_bucket,
seed.history_key,
&seed.history_body,
&format!("{context}: root GET historical object"),
)
.await?;
assert_object_bytes(&root_a, &seed.history_bucket, seed.history_key, &seed.history_body).await?;
let v1 = root_b
.get_object()
.bucket(&seed.versioned_bucket)
.key(seed.versioned_key)
.version_id(&seed.version1)
.send()
.await?;
let v1_body = v1.body.collect().await?.into_bytes();
if v1_body.as_ref() != seed.version1_body.as_slice() {
return Err(format!("{context}: version 1 bytes changed after upgrade").into());
}
let v2 = root_a
.get_object()
.bucket(&seed.versioned_bucket)
.key(seed.versioned_key)
.version_id(&seed.version2)
.send()
.await?;
let v2_body = v2.body.collect().await?.into_bytes();
if v2_body.as_ref() != seed.version2_body.as_slice() {
return Err(format!("{context}: version 2 bytes changed after upgrade").into());
}
let users = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/list-users", None).await?;
if !users.contains(&seed.iam_user) {
return Err(format!("{context}: list-users lost IAM user {}: {users}", seed.iam_user).into());
}
let iam_on_upgraded = dist.client_with_credentials(0, &seed.iam_user, seed.iam_secret)?;
let iam_on_peer = dist.client_with_credentials(3, &seed.iam_user, seed.iam_secret)?;
wait_for_bytes(
&iam_on_upgraded,
&seed.iam_bucket,
seed.iam_key,
&seed.iam_body,
&format!("{context}: IAM GET historical object on node 0"),
)
.await?;
wait_for_bytes(
&iam_on_peer,
&seed.iam_bucket,
seed.iam_key,
&seed.iam_body,
&format!("{context}: IAM GET historical object on node 3"),
)
.await?;
let post_key = format!("after-upgrade-{context}.txt");
let post_body = format!("{context}: written with the same IAM AK/SK after upgrade").into_bytes();
wait_for_put(
&iam_on_peer,
&seed.iam_bucket,
&post_key,
post_body.clone(),
&format!("{context}: IAM PUT after upgrade"),
)
.await?;
assert_object_bytes(&iam_on_upgraded, &seed.iam_bucket, &post_key, &post_body).await?;
let bad = dist.client_with_credentials(1, &seed.iam_user, WRONG_SECRET)?;
match bad.get_object().bucket(&seed.iam_bucket).key(seed.iam_key).send().await {
Ok(_) => return Err(format!("{context}: wrong secret must not read the IAM object").into()),
Err(error) => {
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
let rejected = code == Some("SignatureDoesNotMatch")
|| code == Some("InvalidAccessKeyId")
|| code == Some("AccessDenied")
|| code == Some("InvalidArgument")
|| error.raw_response().is_some_and(|response| response.status().as_u16() == 403);
if !rejected {
return Err(format!("{context}: wrong secret failed with unexpected error {error:?}").into());
}
}
}
let post_root_key = format!("root-after-{context}.bin");
let post_root_body = format!("{context}: root write after upgrade").into_bytes();
put_object(&root_a, &seed.history_bucket, &post_root_key, post_root_body.clone()).await?;
assert_object_bytes(&root_b, &seed.history_bucket, &post_root_key, &post_root_body).await?;
Ok(())
}
#[tokio::test]
async fn four_node_direct_upgrade_preserves_history_and_iam_credentials() -> TestResult {
init_logging();
let previous = source_binary()?;
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
capture_upgrade_logs(&mut dist, "direct-upgrade")?;
dist.start_from_binary(&previous).await?;
let seed = seed_history_and_iam(&dist).await?;
dist.restart_with_current_binary().await?;
assert_history_and_iam(&dist, &seed, "direct").await?;
Ok(())
}
#[tokio::test]
async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> TestResult {
init_logging();
let previous = source_binary()?;
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
capture_upgrade_logs(&mut dist, "rolling-upgrade")?;
dist.start_from_binary(&previous).await?;
let seed = seed_history_and_iam(&dist).await?;
dist.replace_node_with_current_binary(0).await?;
assert_history_and_iam(&dist, &seed, "one-current-node").await?;
for node_idx in [1, 2] {
dist.replace_node_with_current_binary(node_idx).await?;
}
assert_history_and_iam(&dist, &seed, "one-previous-node").await?;
dist.replace_node_with_current_binary(3).await?;
assert_history_and_iam(&dist, &seed, "homogeneous-current").await?;
Ok(())
}
@@ -0,0 +1,188 @@
// 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/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 super::harness::{DistCluster, DistLayout, TestResult, enable_versioning, get_object_bytes, put_object, unique_bucket};
use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
#[tokio::test]
async fn four_node_four_drive_versioning_put_list_get_delete_marker() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("version");
dist.create_bucket(&bucket).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
enable_versioning(&writer, &bucket).await?;
let key = "versioned.txt";
let v1_id = writer
.put_object()
.bucket(&bucket)
.key(key)
.body(b"v1".to_vec().into())
.send()
.await?
.version_id()
.ok_or("v1 PUT omitted version ID")?
.to_string();
let v2_id = writer
.put_object()
.bucket(&bucket)
.key(key)
.body(b"v2".to_vec().into())
.send()
.await?
.version_id()
.ok_or("v2 PUT omitted version ID")?
.to_string();
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
let matching_versions: Vec<_> = versions
.versions()
.iter()
.filter(|version| version.key() == Some(key))
.collect();
assert_eq!(matching_versions.len(), 2, "fresh key must have exactly two versions: {versions:?}");
assert!(versions.delete_markers().is_empty(), "fresh key unexpectedly has a delete marker");
assert!(
matching_versions
.iter()
.any(|version| version.version_id() == Some(v1_id.as_str()) && version.is_latest() != Some(true)),
"v1 was not the historical version: {versions:?}"
);
assert!(
matching_versions
.iter()
.any(|version| version.version_id() == Some(v2_id.as_str()) && version.is_latest() == Some(true)),
"v2 was not the latest version: {versions:?}"
);
let latest = get_object_bytes(&reader, &bucket, key).await?;
assert_eq!(latest, b"v2");
let older = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
let older_body = older.body.collect().await?.into_bytes();
assert_eq!(older_body.as_ref(), b"v1");
let deleted = writer.delete_object().bucket(&bucket).key(key).send().await?;
assert_eq!(deleted.delete_marker(), Some(true));
let marker_id = deleted.version_id().ok_or("DeleteObject omitted delete-marker version ID")?;
let after_delete = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
let matching_markers: Vec<_> = after_delete
.delete_markers()
.iter()
.filter(|marker| marker.key() == Some(key))
.collect();
assert_eq!(
matching_markers.len(),
1,
"delete marker missing or duplicated after current-version delete: {after_delete:?}"
);
assert!(
matching_markers[0].version_id() == Some(marker_id) && matching_markers[0].is_latest() == Some(true),
"DeleteObject response and ListObjectVersions disagree about the marker: {after_delete:?}"
);
let latest_after_delete = reader.get_object().bucket(&bucket).key(key).send().await;
match latest_after_delete {
Ok(_) => return Err("current version should be a delete marker".into()),
Err(error)
if error
.as_service_error()
.and_then(ProvideErrorMetadata::code)
.is_some_and(|code| code == "NoSuchKey" || code == "NotFound") => {}
Err(error) => return Err(error.into()),
}
let restored = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
let restored_body = restored.body.collect().await?.into_bytes();
assert_eq!(restored_body.as_ref(), b"v1");
writer
.delete_object()
.bucket(&bucket)
.key(key)
.version_id(marker_id)
.send()
.await?;
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"v2");
Ok(())
}
#[tokio::test]
async fn four_node_versioning_suspension_keeps_one_null_version_and_history() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("suspend");
dist.create_bucket(&bucket).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
enable_versioning(&writer, &bucket).await?;
let key = "suspended.txt";
let original = writer
.put_object()
.bucket(&bucket)
.key(key)
.body(b"enabled-history".to_vec().into())
.send()
.await?
.version_id()
.ok_or("enabled PUT omitted version ID")?
.to_string();
writer
.put_bucket_versioning()
.bucket(&bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await?;
put_object(&writer, &bucket, key, b"null-one".to_vec()).await?;
put_object(&writer, &bucket, key, b"null-two".to_vec()).await?;
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"null-two");
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
let matching: Vec<_> = versions
.versions()
.iter()
.filter(|version| version.key() == Some(key))
.collect();
assert!(matching.iter().any(|version| version.version_id() == Some(original.as_str())));
let null_version_count = matching
.iter()
.filter(|version| {
matches!(
version.version_id(),
None | Some("") | Some("null") | Some("00000000-0000-0000-0000-000000000000")
)
})
.count();
assert_eq!(null_version_count, 1, "suspended overwrites must keep one null version: {versions:?}");
let historical = reader
.get_object()
.bucket(&bucket)
.key(key)
.version_id(&original)
.send()
.await?;
assert_eq!(historical.body.collect().await?.into_bytes().as_ref(), b"enabled-history");
Ok(())
}
+347 -5
View File
@@ -34,12 +34,14 @@ use s3s::dto::{
AbortMultipartUploadInput, AbortMultipartUploadOutput, CommonPrefix, CompleteMultipartUploadInput, AbortMultipartUploadInput, AbortMultipartUploadOutput, CommonPrefix, CompleteMultipartUploadInput,
CompleteMultipartUploadOutput, CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteMarkerEntry, DeleteObjectInput, CompleteMultipartUploadOutput, CreateMultipartUploadInput, CreateMultipartUploadOutput, DeleteMarkerEntry, DeleteObjectInput,
DeleteObjectOutput, DeleteObjectTaggingInput, DeleteObjectTaggingOutput, ETag, GetBucketVersioningInput, DeleteObjectOutput, DeleteObjectTaggingInput, DeleteObjectTaggingOutput, ETag, GetBucketVersioningInput,
GetBucketVersioningOutput, GetObjectInput, GetObjectLockConfigurationInput, GetObjectLockConfigurationOutput, GetBucketVersioningOutput, GetObjectInput, GetObjectLegalHoldInput, GetObjectLegalHoldOutput,
GetObjectOutput, GetObjectTaggingInput, GetObjectTaggingOutput, HeadBucketInput, HeadBucketOutput, HeadObjectInput, GetObjectLockConfigurationInput, GetObjectLockConfigurationOutput, GetObjectOutput, GetObjectRetentionInput,
GetObjectRetentionOutput, GetObjectTaggingInput, GetObjectTaggingOutput, HeadBucketInput, HeadBucketOutput, HeadObjectInput,
HeadObjectOutput, ListObjectVersionsInput, ListObjectVersionsOutput, ListObjectsV2Input, ListObjectsV2Output, Object, HeadObjectOutput, ListObjectVersionsInput, ListObjectVersionsOutput, ListObjectsV2Input, ListObjectsV2Output, Object,
ObjectLockConfiguration, ObjectLockEnabled, ObjectStorageClass, ObjectVersionId, PutObjectInput, PutObjectOutput, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHold, ObjectLockLegalHoldStatus, ObjectLockMode,
PutObjectTaggingInput, PutObjectTaggingOutput, Range, StreamingBlob, Tag, TagSet, Timestamp, TimestampFormat, ObjectLockRetention, ObjectLockRetentionMode, ObjectStorageClass, ObjectVersionId, PutObjectInput, PutObjectLegalHoldInput,
UploadPartInput, UploadPartOutput, PutObjectLegalHoldOutput, PutObjectOutput, PutObjectRetentionInput, PutObjectRetentionOutput, PutObjectTaggingInput,
PutObjectTaggingOutput, Range, StreamingBlob, Tag, TagSet, Timestamp, TimestampFormat, UploadPartInput, UploadPartOutput,
}; };
use s3s::service::{S3Service, S3ServiceBuilder}; use s3s::service::{S3Service, S3ServiceBuilder};
use s3s::validation::{AwsNameValidation, NameValidation}; use s3s::validation::{AwsNameValidation, NameValidation};
@@ -127,6 +129,10 @@ pub enum Operation {
GetObjectTagging, GetObjectTagging,
PutObjectTagging, PutObjectTagging,
DeleteObjectTagging, DeleteObjectTagging,
GetObjectRetention,
PutObjectRetention,
GetObjectLegalHold,
PutObjectLegalHold,
ListObjectVersions, ListObjectVersions,
ListObjectsV2, ListObjectsV2,
CreateMultipartUpload, CreateMultipartUpload,
@@ -451,10 +457,40 @@ impl JournaledHeaders {
struct ControlState { struct ControlState {
scripts: HashMap<Operation, VecDeque<FaultAction>>, scripts: HashMap<Operation, VecDeque<FaultAction>>,
keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>, keyed_scripts: HashMap<(Operation, String), VecDeque<FaultAction>>,
held_get: Option<HeldGetObject>,
requests: VecDeque<RequestRecord>, requests: VecDeque<RequestRecord>,
next_sequence: u64, next_sequence: u64,
} }
#[derive(Clone)]
struct HeldGetObject {
bucket: String,
key: String,
entered: watch::Sender<usize>,
released: watch::Receiver<bool>,
}
/// Holds every GET of one object, including retries, until this guard is dropped.
#[must_use = "dropping the guard releases the held GET requests"]
pub struct GetObjectGate {
control: Arc<Mutex<ControlState>>,
entered: watch::Receiver<usize>,
released: watch::Sender<bool>,
}
impl GetObjectGate {
pub async fn wait_until_entered(&mut self) -> Result<(), watch::error::RecvError> {
self.entered.wait_for(|count| *count > 0).await.map(|_| ())
}
}
impl Drop for GetObjectGate {
fn drop(&mut self) {
lock(&self.control).held_get = None;
self.released.send_replace(true);
}
}
#[derive(Default)] #[derive(Default)]
struct StoreState { struct StoreState {
assign_own_version_ids: bool, assign_own_version_ids: bool,
@@ -471,6 +507,10 @@ struct StoreState {
/// PutObject carrying any `x-amz-object-lock-*` header must also carry /// PutObject carrying any `x-amz-object-lock-*` header must also carry
/// `Content-MD5` or an `x-amz-checksum-*` header. /// `Content-MD5` or an `x-amz-checksum-*` header.
require_checksum_for_object_lock: bool, require_checksum_for_object_lock: bool,
/// Models Wasabi (rustfs/backlog#2340): a version-addressed DELETE of a
/// version id the target never had answers 404 `NoSuchVersion` instead of
/// the idempotent 204 RustFS/MinIO give.
reject_unknown_version_deletes: bool,
limits: StoreLimits, limits: StoreLimits,
buckets: HashMap<String, BucketState>, buckets: HashMap<String, BucketState>,
uploads: HashMap<String, MultipartState>, uploads: HashMap<String, MultipartState>,
@@ -535,6 +575,41 @@ struct ObjectVersion {
/// SSE-C passthrough transport headers stored with the version (RustFS /// SSE-C passthrough transport headers stored with the version (RustFS
/// target behavior); empty when the drop mode discarded them. /// target behavior); empty when the drop mode discarded them.
replication_sse_headers: Vec<(String, String)>, replication_sse_headers: Vec<(String, String)>,
/// Object Lock state of the version: retention (mode, retain-until) from
/// the PUT / CreateMultipartUpload headers or PutObjectRetention, and the
/// legal hold flag; replayed on HEAD.
lock: VersionLock,
}
#[derive(Clone, Default)]
struct VersionLock {
retention: Option<(String, Timestamp)>,
/// `None` until a legal hold status was ever set; like S3, HEAD then
/// reports nothing, while an explicit OFF is reported as `OFF`.
legal_hold: Option<bool>,
}
impl VersionLock {
fn from_headers(
mode: Option<ObjectLockMode>,
retain_until: Option<Timestamp>,
legal_hold: Option<ObjectLockLegalHoldStatus>,
) -> Self {
Self {
retention: mode.zip(retain_until).map(|(mode, until)| (mode.as_str().to_string(), until)),
legal_hold: legal_hold.map(|status| status.as_str().eq_ignore_ascii_case("ON")),
}
}
fn legal_hold_status(&self) -> Option<ObjectLockLegalHoldStatus> {
self.legal_hold.map(|on| {
ObjectLockLegalHoldStatus::from_static(if on {
ObjectLockLegalHoldStatus::ON
} else {
ObjectLockLegalHoldStatus::OFF
})
})
}
} }
#[derive(Clone)] #[derive(Clone)]
@@ -546,6 +621,7 @@ struct MultipartState {
metadata: Option<HashMap<String, String>>, metadata: Option<HashMap<String, String>>,
standard_headers: StandardHeaders, standard_headers: StandardHeaders,
replication_sse_headers: Vec<(String, String)>, replication_sse_headers: Vec<(String, String)>,
lock: VersionLock,
parts: BTreeMap<i32, MultipartPart>, parts: BTreeMap<i32, MultipartPart>,
} }
@@ -815,6 +891,7 @@ impl FakeS3Target {
standard_headers: seed.standard_headers.clone(), standard_headers: seed.standard_headers.clone(),
tags: Vec::new(), tags: Vec::new(),
replication_sse_headers: Vec::new(), replication_sse_headers: Vec::new(),
lock: VersionLock::default(),
}; };
upsert_version(&mut state, bucket, key.into(), version).expect("seed object must fit the storage budget"); upsert_version(&mut state, bucket, key.into(), version).expect("seed object must fit the storage budget");
e_tag e_tag
@@ -888,6 +965,12 @@ impl FakeS3Target {
/// PutObject that carries Object Lock parameters (AWS S3 / MinIO rule, /// PutObject that carries Object Lock parameters (AWS S3 / MinIO rule,
/// rustfs#7082). `Content-MD5`, when present, is always verified against /// rustfs#7082). `Content-MD5`, when present, is always verified against
/// the body regardless of this mode. /// the body regardless of this mode.
/// Wasabi-like mode: DELETE of an unknown version id answers 404
/// `NoSuchVersion` (the default 204 models RustFS/MinIO).
pub fn reject_unknown_version_deletes(&self, enabled: bool) {
lock(&self.backend.store).reject_unknown_version_deletes = enabled;
}
pub fn require_checksum_for_object_lock(&self, enabled: bool) { pub fn require_checksum_for_object_lock(&self, enabled: bool) {
lock(&self.backend.store).require_checksum_for_object_lock = enabled; lock(&self.backend.store).require_checksum_for_object_lock = enabled;
} }
@@ -936,6 +1019,30 @@ impl FakeS3Target {
.extend(std::iter::repeat_n(action, times)); .extend(std::iter::repeat_n(action, times));
} }
/// Hold one exact bucket/key before any GET response can reach the client.
/// The fixture supports one live gate; request and connection deadlines still apply.
pub fn hold_get_object(&self, bucket: &str, key: &str) -> GetObjectGate {
assert!(
bucket.len() <= MAX_RETAINED_IDENTIFIER_BYTES && key.len() <= MAX_RETAINED_IDENTIFIER_BYTES,
"held GET identifiers exceed the fixture limit"
);
let mut state = lock(&self.control);
assert!(state.held_get.is_none(), "fake target already holds a GET gate");
let (entered, entered_rx) = watch::channel(0);
let (released, released_rx) = watch::channel(false);
state.held_get = Some(HeldGetObject {
bucket: bucket.to_string(),
key: key.to_string(),
entered,
released: released_rx,
});
GetObjectGate {
control: Arc::clone(&self.control),
entered: entered_rx,
released,
}
}
pub fn clear_faults(&self) { pub fn clear_faults(&self) {
let mut state = lock(&self.control); let mut state = lock(&self.control);
state.scripts.clear(); state.scripts.clear();
@@ -1098,6 +1205,10 @@ fn operation_from_s3_name(name: &str) -> Operation {
"GetObjectTagging" => Operation::GetObjectTagging, "GetObjectTagging" => Operation::GetObjectTagging,
"PutObjectTagging" => Operation::PutObjectTagging, "PutObjectTagging" => Operation::PutObjectTagging,
"DeleteObjectTagging" => Operation::DeleteObjectTagging, "DeleteObjectTagging" => Operation::DeleteObjectTagging,
"GetObjectRetention" => Operation::GetObjectRetention,
"PutObjectRetention" => Operation::PutObjectRetention,
"GetObjectLegalHold" => Operation::GetObjectLegalHold,
"PutObjectLegalHold" => Operation::PutObjectLegalHold,
"ListObjectsV2" => Operation::ListObjectsV2, "ListObjectsV2" => Operation::ListObjectsV2,
"CreateMultipartUpload" => Operation::CreateMultipartUpload, "CreateMultipartUpload" => Operation::CreateMultipartUpload,
"UploadPart" => Operation::UploadPart, "UploadPart" => Operation::UploadPart,
@@ -1235,6 +1346,18 @@ fn parse_request(method: &Method, uri: &Uri) -> ParsedRequest {
(&Method::DELETE, true) if query.contains_key("tagging") && only_query_keys(&["tagging", "versionId"]) => { (&Method::DELETE, true) if query.contains_key("tagging") && only_query_keys(&["tagging", "versionId"]) => {
Operation::DeleteObjectTagging Operation::DeleteObjectTagging
} }
(&Method::GET, true) if query.contains_key("retention") && only_query_keys(&["retention", "versionId"]) => {
Operation::GetObjectRetention
}
(&Method::PUT, true) if query.contains_key("retention") && only_query_keys(&["retention", "versionId"]) => {
Operation::PutObjectRetention
}
(&Method::GET, true) if query.contains_key("legal-hold") && only_query_keys(&["legal-hold", "versionId"]) => {
Operation::GetObjectLegalHold
}
(&Method::PUT, true) if query.contains_key("legal-hold") && only_query_keys(&["legal-hold", "versionId"]) => {
Operation::PutObjectLegalHold
}
// A replication PUT addresses the source version via `?versionId=`. // A replication PUT addresses the source version via `?versionId=`.
(&Method::PUT, true) if only_query_keys(&["versionId"]) => Operation::PutObject, (&Method::PUT, true) if only_query_keys(&["versionId"]) => Operation::PutObject,
(&Method::GET, true) if only_query_keys(&["versionId"]) => Operation::GetObject, (&Method::GET, true) if only_query_keys(&["versionId"]) => Operation::GetObject,
@@ -1788,6 +1911,28 @@ fn set_version_tags(
Ok(resolved) Ok(resolved)
} }
fn update_version_lock(
state: &mut StoreState,
bucket: &str,
key: &str,
version_id: Option<&str>,
update: impl FnOnce(&mut VersionLock),
) -> S3Result<String> {
let resolved = find_version(state, bucket, key, version_id)?.version_id;
let version = state
.buckets
.get_mut(bucket)
.expect("bucket existence checked by find_version")
.objects
.get_mut(key)
.expect("key existence checked by find_version")
.iter_mut()
.find(|version| version.version_id == resolved)
.expect("version existence checked by find_version");
update(&mut version.lock);
Ok(resolved)
}
/// Whether version ids are surfaced for this bucket. Unknown buckets report /// Whether version ids are surfaced for this bucket. Unknown buckets report
/// `true`; the caller's lookup raises `NoSuchBucket` first. /// `true`; the caller's lookup raises `NoSuchBucket` first.
fn bucket_versioned(state: &StoreState, bucket: &str) -> bool { fn bucket_versioned(state: &StoreState, bucket: &str) -> bool {
@@ -2227,6 +2372,11 @@ impl S3 for FakeBackend {
standard_headers, standard_headers,
tags: Vec::new(), tags: Vec::new(),
replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted), replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted),
lock: VersionLock::from_headers(
input.object_lock_mode,
input.object_lock_retain_until_date,
input.object_lock_legal_hold_status,
),
}; };
upsert_version(&mut lock(&self.store), &input.bucket, input.key, version)?; upsert_version(&mut lock(&self.store), &input.bucket, input.key, version)?;
Ok(apply_response_fault( Ok(apply_response_fault(
@@ -2243,6 +2393,17 @@ impl S3 for FakeBackend {
let fault = request_fault(&req); let fault = request_fault(&req);
apply_non_body_fault(fault.as_ref(), &self.control).await?; apply_non_body_fault(fault.as_ref(), &self.control).await?;
let input = req.input; let input = req.input;
let held_get = lock(&self.control)
.held_get
.as_ref()
.filter(|held| held.bucket == input.bucket && held.key == input.key)
.cloned();
if let Some(mut held) = held_get {
held.entered.send_modify(|count| *count += 1);
// Keep the gate installed when a request is cancelled or times out:
// a retry must cross the same boundary before returning any bytes.
let _ = held.released.wait_for(|released| *released).await;
}
let (version, versioned) = { let (version, versioned) = {
let state = lock(&self.store); let state = lock(&self.store);
( (
@@ -2274,6 +2435,13 @@ impl S3 for FakeBackend {
last_modified: Some(version.last_modified.clone()), last_modified: Some(version.last_modified.clone()),
version_id: versioned.then_some(version.version_id), version_id: versioned.then_some(version.version_id),
sse_customer_algorithm, sse_customer_algorithm,
object_lock_mode: version
.lock
.retention
.as_ref()
.map(|(mode, _)| ObjectLockMode::from(mode.clone())),
object_lock_retain_until_date: version.lock.retention.as_ref().map(|(_, until)| until.clone()),
object_lock_legal_hold_status: version.lock.legal_hold_status(),
..Default::default() ..Default::default()
}); });
response.status = served.status; response.status = served.status;
@@ -2308,6 +2476,13 @@ impl S3 for FakeBackend {
last_modified: Some(version.last_modified.clone()), last_modified: Some(version.last_modified.clone()),
version_id: versioned.then_some(version.version_id), version_id: versioned.then_some(version.version_id),
sse_customer_algorithm, sse_customer_algorithm,
object_lock_mode: version
.lock
.retention
.as_ref()
.map(|(mode, _)| ObjectLockMode::from(mode.clone())),
object_lock_retain_until_date: version.lock.retention.as_ref().map(|(_, until)| until.clone()),
object_lock_legal_hold_status: version.lock.legal_hold_status(),
..Default::default() ..Default::default()
}); });
response.status = served.status; response.status = served.status;
@@ -2367,6 +2542,82 @@ impl S3 for FakeBackend {
)) ))
} }
async fn get_object_retention(
&self,
req: S3Request<GetObjectRetentionInput>,
) -> S3Result<S3Response<GetObjectRetentionOutput>> {
let fault = request_fault(&req);
apply_non_body_fault(fault.as_ref(), &self.control).await?;
let input = req.input;
let version = find_version(&lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref())?;
Ok(apply_response_fault(
S3Response::new(GetObjectRetentionOutput {
retention: version.lock.retention.map(|(mode, until)| ObjectLockRetention {
mode: Some(ObjectLockRetentionMode::from(mode)),
retain_until_date: Some(until),
}),
}),
fault.as_ref(),
))
}
async fn put_object_retention(
&self,
req: S3Request<PutObjectRetentionInput>,
) -> S3Result<S3Response<PutObjectRetentionOutput>> {
let fault = request_fault(&req);
apply_non_body_fault(fault.as_ref(), &self.control).await?;
let input = req.input;
let retention = input
.retention
.and_then(|retention| retention.mode.zip(retention.retain_until_date))
.map(|(mode, until)| (mode.as_str().to_string(), until));
update_version_lock(&mut lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref(), |lock| {
lock.retention = retention;
})?;
Ok(apply_response_fault(S3Response::new(PutObjectRetentionOutput::default()), fault.as_ref()))
}
async fn get_object_legal_hold(
&self,
req: S3Request<GetObjectLegalHoldInput>,
) -> S3Result<S3Response<GetObjectLegalHoldOutput>> {
let fault = request_fault(&req);
apply_non_body_fault(fault.as_ref(), &self.control).await?;
let input = req.input;
let version = find_version(&lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref())?;
Ok(apply_response_fault(
S3Response::new(GetObjectLegalHoldOutput {
legal_hold: Some(ObjectLockLegalHold {
status: Some(
version
.lock
.legal_hold_status()
.unwrap_or_else(|| ObjectLockLegalHoldStatus::from_static(ObjectLockLegalHoldStatus::OFF)),
),
}),
}),
fault.as_ref(),
))
}
async fn put_object_legal_hold(
&self,
req: S3Request<PutObjectLegalHoldInput>,
) -> S3Result<S3Response<PutObjectLegalHoldOutput>> {
let fault = request_fault(&req);
apply_non_body_fault(fault.as_ref(), &self.control).await?;
let input = req.input;
let legal_hold_on = input
.legal_hold
.and_then(|hold| hold.status)
.is_some_and(|status| status.as_str().eq_ignore_ascii_case("ON"));
update_version_lock(&mut lock(&self.store), &input.bucket, &input.key, input.version_id.as_deref(), |lock| {
lock.legal_hold = Some(legal_hold_on);
})?;
Ok(apply_response_fault(S3Response::new(PutObjectLegalHoldOutput::default()), fault.as_ref()))
}
async fn delete_object_tagging( async fn delete_object_tagging(
&self, &self,
req: S3Request<DeleteObjectTaggingInput>, req: S3Request<DeleteObjectTaggingInput>,
@@ -2420,6 +2671,7 @@ impl S3 for FakeBackend {
return Ok(apply_response_fault(S3Response::new(DeleteObjectOutput::default()), fault.as_ref())); return Ok(apply_response_fault(S3Response::new(DeleteObjectOutput::default()), fault.as_ref()));
} }
if let Some(version_id) = input.version_id { if let Some(version_id) = input.version_id {
let reject_unknown = state.reject_unknown_version_deletes;
let (removed_bytes, removed_versions, delete_marker, remove_key) = { let (removed_bytes, removed_versions, delete_marker, remove_key) = {
let Some(versions) = state let Some(versions) = state
.buckets .buckets
@@ -2428,6 +2680,9 @@ impl S3 for FakeBackend {
.objects .objects
.get_mut(&input.key) .get_mut(&input.key)
else { else {
if reject_unknown {
return Err(s3s::s3_error!(NoSuchVersion, "The specified version does not exist."));
}
return Ok(apply_response_fault( return Ok(apply_response_fault(
S3Response::new(DeleteObjectOutput { S3Response::new(DeleteObjectOutput {
version_id: Some(version_id), version_id: Some(version_id),
@@ -2436,6 +2691,9 @@ impl S3 for FakeBackend {
fault.as_ref(), fault.as_ref(),
)); ));
}; };
if reject_unknown && !versions.iter().any(|version| version.version_id == version_id) {
return Err(s3s::s3_error!(NoSuchVersion, "The specified version does not exist."));
}
let mut removed_bytes = 0usize; let mut removed_bytes = 0usize;
let mut removed_versions = 0usize; let mut removed_versions = 0usize;
let mut delete_marker = None; let mut delete_marker = None;
@@ -2489,6 +2747,7 @@ impl S3 for FakeBackend {
standard_headers: StandardHeaders::default(), standard_headers: StandardHeaders::default(),
tags: Vec::new(), tags: Vec::new(),
replication_sse_headers: Vec::new(), replication_sse_headers: Vec::new(),
lock: VersionLock::default(),
}, },
)?; )?;
Ok(apply_response_fault( Ok(apply_response_fault(
@@ -2543,6 +2802,11 @@ impl S3 for FakeBackend {
metadata: input.metadata, metadata: input.metadata,
standard_headers, standard_headers,
replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted), replication_sse_headers: captured_replication_sse_headers(&headers, drop_unlisted),
lock: VersionLock::from_headers(
input.object_lock_mode,
input.object_lock_retain_until_date,
input.object_lock_legal_hold_status,
),
parts: BTreeMap::new(), parts: BTreeMap::new(),
}, },
); );
@@ -2683,6 +2947,7 @@ impl S3 for FakeBackend {
metadata: upload.metadata.clone(), metadata: upload.metadata.clone(),
standard_headers: upload.standard_headers.clone(), standard_headers: upload.standard_headers.clone(),
replication_sse_headers: upload.replication_sse_headers.clone(), replication_sse_headers: upload.replication_sse_headers.clone(),
lock: upload.lock.clone(),
parts: BTreeMap::new(), parts: BTreeMap::new(),
}, },
selected, selected,
@@ -2713,6 +2978,7 @@ impl S3 for FakeBackend {
standard_headers: upload.standard_headers, standard_headers: upload.standard_headers,
tags: Vec::new(), tags: Vec::new(),
replication_sse_headers: upload.replication_sse_headers, replication_sse_headers: upload.replication_sse_headers,
lock: upload.lock,
}; };
let mut state = lock(&self.store); let mut state = lock(&self.store);
let versioned = bucket_versioned(&state, &input.bucket); let versioned = bucket_versioned(&state, &input.bucket);
@@ -2894,6 +3160,81 @@ mod tests {
aws_sdk_s3::primitives::DateTime::from_secs(4_102_444_800) aws_sdk_s3::primitives::DateTime::from_secs(4_102_444_800)
} }
#[tokio::test]
async fn get_object_gate_holds_retries_and_releases_on_drop() -> Result<(), BoxError> {
let target = FakeS3Target::start().await?;
let bucket = "gated-target";
target.create_bucket(bucket);
for key in ["held", "unrelated"] {
target.put_seed_object(bucket, key, Bytes::from_static(b"payload"), &SeedMetadata::default());
}
{
let gate = target.hold_get_object(bucket, "held");
let request = || S3Request {
input: GetObjectInput {
bucket: bucket.to_string(),
key: "held".to_string(),
..Default::default()
},
method: Method::GET,
uri: Uri::from_static("/gated-target/held"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
// Without a fault, only the gate can suspend this backend method.
let mut first = target.backend.get_object(request());
assert!(futures::poll!(first.as_mut()).is_pending(), "the first GET must wait at the gate");
drop(first);
let mut retry = target.backend.get_object(request());
assert!(futures::poll!(retry.as_mut()).is_pending(), "a cancelled GET must not consume the gate");
drop(gate);
let std::task::Poll::Ready(response) = futures::poll!(retry.as_mut()) else {
panic!("dropping the gate must release the waiting GET");
};
let mut body = response?.output.body.expect("released GET body");
assert_eq!(body.next().await.transpose()?, Some(Bytes::from_static(b"payload")));
assert!(body.next().await.is_none(), "released GET body must be complete");
}
let client = client(&target);
let mut gate = target.hold_get_object(bucket, "held");
let mut requests = tokio::task::JoinSet::new();
let first = client.clone();
requests.spawn(async move { get_bytes(&first, bucket, "held", None).await });
timeout(Duration::from_secs(2), gate.wait_until_entered()).await??;
requests.abort_all();
assert!(
requests
.join_next()
.await
.expect("first GET task")
.expect_err("cancel the first GET attempt")
.is_cancelled()
);
let retry = client.clone();
requests.spawn(async move { get_bytes(&retry, bucket, "held", None).await });
timeout(Duration::from_secs(2), gate.entered.wait_for(|count| *count == 2)).await??;
assert_eq!(
timeout(Duration::from_secs(2), get_bytes(&client, bucket, "unrelated", None)).await??,
Bytes::from_static(b"payload")
);
assert!(requests.try_join_next().is_none(), "the retry must remain behind the gate");
drop(gate);
assert_eq!(
timeout(Duration::from_secs(2), requests.join_next())
.await?
.expect("retried GET task")??,
Bytes::from_static(b"payload")
);
assert_eq!(get_bytes(&client, bucket, "held", None).await?, Bytes::from_static(b"payload"));
assert_eq!(target.count_requests(Operation::GetObject, "held"), 3);
Ok(())
}
#[tokio::test] #[tokio::test]
async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> { async fn object_lock_target_requires_a_checksum_on_locked_puts() -> Result<(), BoxError> {
use aws_sdk_s3::error::ProvideErrorMetadata; use aws_sdk_s3::error::ProvideErrorMetadata;
@@ -4469,6 +4810,7 @@ mod tests {
metadata: None, metadata: None,
standard_headers: StandardHeaders::default(), standard_headers: StandardHeaders::default(),
replication_sse_headers: Vec::new(), replication_sse_headers: Vec::new(),
lock: VersionLock::default(),
parts: BTreeMap::new(), parts: BTreeMap::new(),
}, },
); );
@@ -16,24 +16,128 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post}; use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
use crate::common::{ use crate::common::{
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
rustfs_binary_path,
}; };
use crate::storage_api::RUSTFS_META_BUCKET; use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
use http::Method; use http::Method;
use sha2::{Digest, Sha256};
use std::collections::HashSet; use std::collections::HashSet;
use std::error::Error; use std::error::Error;
use std::io::{Read, Write};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use tokio::time::{Duration, Instant, sleep, timeout}; use tokio::time::{Duration, Instant, sleep, timeout};
use tracing::info; use tracing::info;
#[cfg(target_os = "linux")]
use tracing::warn;
const POOL_METADATA_OBJECT: &str = "pool.bin"; const POOL_METADATA_OBJECT: &str = "pool.bin";
#[derive(serde::Deserialize)]
struct EvidenceBuild {
sha256: String,
}
#[derive(serde::Deserialize)]
struct RestartEvidenceRun {
schema: u32,
run_id: String,
source_revision: String,
test_build: serde_json::Value,
binary: EvidenceBuild,
test_binary: EvidenceBuild,
}
#[derive(Clone, Copy)]
struct ScannerHealEvidenceCase {
id: &'static str,
oracle: &'static str,
}
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
id: "background-target-restart",
oracle: "background-target-restart.json",
};
struct RestartEvidenceContext {
directory: PathBuf,
run: RestartEvidenceRun,
case: ScannerHealEvidenceCase,
}
fn file_sha256(path: &Path) -> Result<String, Box<dyn Error + Send + Sync>> {
let mut file = std::fs::File::open(path)?;
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
}
fn restart_evidence_run(
binary: &Path,
case: ScannerHealEvidenceCase,
) -> Result<Option<RestartEvidenceContext>, Box<dyn Error + Send + Sync>> {
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
return Ok(None);
};
if case.id.is_empty()
|| case.oracle.is_empty()
|| !case.oracle.ends_with(".json")
|| case.oracle.contains('/')
|| case.oracle.contains('\\')
|| case.oracle.contains("..")
{
return Err("invalid scanner/heal evidence case".into());
}
let directory = PathBuf::from(directory);
let receipt = directory.join("run.json");
if receipt.metadata()?.len() > 1024 * 1024 {
return Err("oversized scanner/heal execution receipt".into());
}
let run: RestartEvidenceRun = serde_json::from_slice(&std::fs::read(receipt)?)?;
if run.schema != 1 || run.run_id.len() != 32 || run.source_revision.len() != 40 {
return Err("invalid scanner/heal execution identity".into());
}
let built = compiled_test_identity();
for key in ["source_revision", "dirty", "lock_blob", "features"] {
assert_eq!(built[key], run.test_build[key], "compiled test identity differs for {key}");
}
assert_eq!(file_sha256(binary)?, run.binary.sha256, "server binary must match the run receipt");
assert_eq!(
file_sha256(&std::env::current_exe()?)?,
run.test_binary.sha256,
"test executable must match the run receipt"
);
if directory.join(case.oracle).exists() {
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
}
Ok(Some(RestartEvidenceContext { directory, run, case }))
}
fn compiled_test_identity() -> serde_json::Value {
serde_json::json!({
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
})
}
struct TcpPortBlackhole { struct TcpPortBlackhole {
port: u16, port: u16,
comment: String, comment: String,
@@ -42,6 +146,49 @@ mod tests {
} }
impl TcpPortBlackhole { impl TcpPortBlackhole {
/// Environment flag that turns an unusable fault-injection host into a
/// hard failure instead of a logged skip. Lanes that provision
/// `CAP_NET_ADMIN` set it so a broken runner cannot pass silently.
#[cfg(target_os = "linux")]
const REQUIRE_ENV: &str = "RUSTFS_E2E_REQUIRE_NET_FAULT_INJECTION";
/// Probe whether this host can manipulate the OUTPUT chain at all.
///
/// Returns `Ok(Some(reason))` when `iptables` is missing or lacks
/// `CAP_NET_ADMIN` (the nf_tables backend reports "Permission denied"
/// even under `sudo` inside an unprivileged container) and the lane did
/// not demand fault injection; returns an error when the lane demands
/// it; returns `Ok(None)` when the blackhole can be installed.
#[cfg(target_os = "linux")]
fn unavailable_reason() -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
let id = Command::new("id").arg("-u").output()?;
if !id.status.success() {
return Err(format!("failed to determine the test process uid: {}", String::from_utf8_lossy(&id.stderr)).into());
}
let use_sudo = String::from_utf8_lossy(&id.stdout).trim() != "0";
let mut command = if use_sudo {
let mut command = Command::new("sudo");
command.args(["-n", "iptables"]);
command
} else {
Command::new("iptables")
};
let probe = command.args(["-w", "5", "-S", "OUTPUT"]).output();
let reason = match probe {
Ok(output) if output.status.success() => return Ok(None),
Ok(output) => format!(
"iptables cannot read the OUTPUT chain (status {}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
),
Err(err) => format!("iptables is not runnable: {err}"),
};
if std::env::var_os(Self::REQUIRE_ENV).is_some() {
return Err(format!("{} is set but network fault injection is unavailable: {reason}", Self::REQUIRE_ENV).into());
}
Ok(Some(reason))
}
fn install(address: &str) -> Result<Self, Box<dyn Error + Send + Sync>> { fn install(address: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
let address = address.parse::<SocketAddr>()?; let address = address.parse::<SocketAddr>()?;
if !address.ip().is_loopback() { if !address.ip().is_loopback() {
@@ -126,6 +273,27 @@ mod tests {
} }
} }
/// Remove a disk directory underneath a running server. Background writers
/// (scanner, usage cache, heal markers) can recreate entries between the
/// recursive listing and the final `rmdir`, which surfaces as
/// `DirectoryNotEmpty` on macOS; retry briefly so the wipe reflects the
/// operator action rather than a listing race.
fn wipe_directory_while_server_runs(disk: &Path) -> std::io::Result<()> {
let mut last_err = None;
for _ in 0..20 {
match std::fs::remove_dir_all(disk) {
Ok(()) => return Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
last_err = Some(err);
std::thread::sleep(std::time::Duration::from_millis(100));
}
Err(err) => return Err(err),
}
}
Err(last_err.expect("retry loop only exits without success after recording an error"))
}
fn has_file_under(path: &Path) -> bool { fn has_file_under(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(path) else { let Ok(entries) = std::fs::read_dir(path) else {
return false; return false;
@@ -195,8 +363,9 @@ mod tests {
clients: &[aws_sdk_s3::Client], clients: &[aws_sdk_s3::Client],
bucket: &str, bucket: &str,
expected_keys: &HashSet<String>, expected_keys: &HashSet<String>,
) -> Result<(), Box<dyn Error + Send + Sync>> { ) -> Result<Vec<Vec<String>>, Box<dyn Error + Send + Sync>> {
const PAGE_SIZE: i32 = 10; const PAGE_SIZE: i32 = 10;
let mut node_listings = Vec::with_capacity(clients.len());
for (node_index, client) in clients.iter().enumerate() { for (node_index, client) in clients.iter().enumerate() {
let mut listed_keys = Vec::new(); let mut listed_keys = Vec::new();
let mut continuation_token = None; let mut continuation_token = None;
@@ -243,8 +412,10 @@ mod tests {
&listed_key_set, expected_keys, &listed_key_set, expected_keys,
"node {node_index} did not expose the complete recovered namespace" "node {node_index} did not expose the complete recovered namespace"
); );
listed_keys.sort();
node_listings.push(listed_keys);
} }
Ok(()) Ok(node_listings)
} }
fn heal_task_status_diagnostic(body: &str) -> String { fn heal_task_status_diagnostic(body: &str) -> String {
@@ -405,7 +576,7 @@ mod tests {
); );
} }
std::fs::remove_dir_all(&disk0).expect("disk0 wipe should succeed while server is running"); wipe_directory_while_server_runs(&disk0).expect("disk0 wipe should succeed while server is running");
std::fs::create_dir_all(&disk0).expect("disk0 should be recreated empty while server is running"); std::fs::create_dir_all(&disk0).expect("disk0 should be recreated empty while server is running");
assert!(!has_file_under(&disk0), "disk0 must be empty immediately after runtime wipe"); assert!(!has_file_under(&disk0), "disk0 must be empty immediately after runtime wipe");
@@ -792,6 +963,18 @@ mod tests {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[tokio::test(flavor = "multi_thread")] #[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_after_target_endpoint_blackhole() -> Result<(), Box<dyn Error + Send + Sync>> { async fn test_cluster_root_heal_recovers_after_target_endpoint_blackhole() -> Result<(), Box<dyn Error + Send + Sync>> {
if let Some(reason) = TcpPortBlackhole::unavailable_reason()? {
init_logging();
warn!(
event = "heal_interruption_skipped",
component = "e2e_test",
subsystem = "heal",
interruption_kind = "target_endpoint_blackhole",
reason,
"Skipping endpoint blackhole scenario: network fault injection is unavailable on this host"
);
return Ok(());
}
timeout( timeout(
Duration::from_secs(420), Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::TargetEndpointBlackhole), run_cluster_root_heal_interruption(InterruptionScenario::TargetEndpointBlackhole),
@@ -808,6 +991,13 @@ mod tests {
} }
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> { async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
let server_binary = rustfs_binary_path();
let evidence_run = if scenario == InterruptionScenario::BackgroundTargetRestart {
restart_evidence_run(&server_binary, BACKGROUND_TARGET_RESTART_EVIDENCE)?
} else {
None
};
let mut evidence_objects = Vec::new();
let (background_enabled, interruption_node, interruption_kind) = match scenario { let (background_enabled, interruption_node, interruption_kind) = match scenario {
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"), InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"), InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
@@ -855,7 +1045,7 @@ mod tests {
for node_index in 0..cluster.nodes.len() { for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?; cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
} }
cluster.start().await?; cluster.start_with_binary(&server_binary).await?;
let clients = cluster.create_all_clients()?; let clients = cluster.create_all_clients()?;
let bucket = "heal-restart-during-rebuild"; let bucket = "heal-restart-during-rebuild";
@@ -996,7 +1186,7 @@ mod tests {
} }
} }
cluster.start_node(1).await?; cluster.start_node_from_binary(1, &server_binary).await?;
let status_url = format!("{}/rustfs/admin/v3/background-heal/status", cluster.nodes[0].url); let status_url = format!("{}/rustfs/admin/v3/background-heal/status", cluster.nodes[0].url);
let recovery_deadline = Instant::now() + Duration::from_secs(60); let recovery_deadline = Instant::now() + Duration::from_secs(60);
@@ -1274,7 +1464,7 @@ mod tests {
} }
} }
} }
cluster.start_node(interruption_node).await?; cluster.start_node_from_binary(interruption_node, &server_binary).await?;
if interruption_node == 0 { if interruption_node == 0 {
let target = cluster.nodes[1] let target = cluster.nodes[1]
.process .process
@@ -1373,7 +1563,7 @@ mod tests {
.map(|manifest| manifest.key.clone()) .map(|manifest| manifest.key.clone())
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
assert!(expected_keys.insert(outage_key.to_string())); assert!(expected_keys.insert(outage_key.to_string()));
assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?; let node_listings = assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
let target_client = cluster.create_s3_client(1)?; let target_client = cluster.create_s3_client(1)?;
for expected in &expected_manifests { for expected in &expected_manifests {
@@ -1381,11 +1571,31 @@ mod tests {
let actual = response.body.collect().await?.into_bytes(); let actual = response.body.collect().await?.into_bytes();
let expected_body = deterministic_object_body(object_size_bytes, expected.payload_seed); let expected_body = deterministic_object_body(object_size_bytes, expected.payload_seed);
assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {}", expected.key); assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {}", expected.key);
if evidence_run.is_some() {
evidence_objects.push(serde_json::json!({
"key": expected.key, "version_id": expected.shard_census.version_id,
"expected_bytes": expected_body.len(), "actual_bytes": actual.len(),
"expected_sha256": sha256_hex(&expected_body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": expected.shard_census,
"physical": census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?,
}));
}
} }
let response = target_client.get_object().bucket(bucket).key(outage_key).send().await?; let response = target_client.get_object().bucket(bucket).key(outage_key).send().await?;
let actual = response.body.collect().await?.into_bytes(); let actual = response.body.collect().await?.into_bytes();
let expected_outage_body = deterministic_object_body(object_size_bytes, outage_payload_seed); let expected_outage_body = deterministic_object_body(object_size_bytes, outage_payload_seed);
assert_eq!(actual.as_ref(), expected_outage_body.as_slice(), "object body changed for {outage_key}"); assert_eq!(actual.as_ref(), expected_outage_body.as_slice(), "object body changed for {outage_key}");
if evidence_run.is_some() {
evidence_objects.push(serde_json::json!({
"key": outage_key, "version_id": null,
"expected_bytes": expected_outage_body.len(), "actual_bytes": actual.len(),
"expected_sha256": sha256_hex(&expected_outage_body),
"actual_sha256": sha256_hex(&actual),
"expected_physical": null,
"physical": census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?,
}));
}
let terminal_deadline = Instant::now() + Duration::from_secs(30); let terminal_deadline = Instant::now() + Duration::from_secs(30);
loop { loop {
@@ -1432,6 +1642,36 @@ mod tests {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into()); return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
} }
if let Some(evidence_context) = evidence_run {
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
assert_ne!(target_pid, restarted_pid, "target must be a new process");
assert_eq!(
file_sha256(&server_binary)?,
evidence_context.run.binary.sha256,
"server build changed during restart"
);
let evidence = serde_json::json!({
"schema": 1, "case": evidence_context.case.id, "evidence": "process-restart",
"run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
"test_build": compiled_test_identity(),
"binary_sha256": evidence_context.run.binary.sha256,
"test_binary_sha256": evidence_context.run.test_binary.sha256,
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
"pid_before": target_pid, "pid_after": restarted_pid,
"objects": evidence_objects, "node_listings": node_listings,
});
let data = serde_json::to_vec(&evidence)?;
if data.len() > 1024 * 1024 {
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
}
let mut output = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(evidence_context.directory.join(evidence_context.case.oracle))?;
output.write_all(&data)?;
output.sync_all()?;
}
Ok(()) Ok(())
} }
+5
View File
@@ -378,6 +378,11 @@ mod bucket_stats_regression_test;
#[cfg(test)] #[cfg(test)]
mod distributed_startup_regression_test; mod distributed_startup_regression_test;
// 4-node / 4-disk distributed Actions suite (S3, lock, versioning, replication,
// quota, observability, expand/decommission/rebalance, site replication, chaos).
#[cfg(test)]
mod distributed;
// P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024) // P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024)
#[cfg(test)] #[cfg(test)]
mod tier_transition_regression_test; mod tier_transition_regression_test;
@@ -20,9 +20,10 @@
//! journal (`count_requests`) carries the assertion in every one of them. //! journal (`count_requests`) carries the assertion in every one of them.
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env}; use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
use crate::fake_s3_target::Operation; use crate::fake_s3_target::{FaultAction, Operation};
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration}; use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes; use bytes::Bytes;
use futures::{StreamExt, TryStreamExt};
use std::time::Duration; use std::time::Duration;
type TestResult = Result<(), BoxError>; type TestResult = Result<(), BoxError>;
@@ -145,14 +146,38 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
.await?; .await?;
let body = payload(128 * 1024); let body = payload(128 * 1024);
let blocker = "queue/blocker.bin";
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(blocker, body.clone())]);
// The one-chunk range completes immediately; its full background pull
// occupies the only slot while the remaining requests fill the queue.
env.source.inject_for_key(
Operation::GetObject,
blocker,
FaultAction::SlowSendBody {
chunk_bytes: 1024,
delay: Duration::from_millis(100),
},
2,
);
let response = env
.raw_object_request(http::Method::GET, bucket, blocker, &[("range", "bytes=0-1023")])
.await?;
assert_eq!(response.status, 206);
assert_eq!(response.body, body.slice(0..1024));
env.wait_for_status_counter(bucket, "/inflight_pulls", 1, SETTLE).await?;
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect(); let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect(); let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
env.seed_source(SOURCE_BUCKET, &seeds); env.seed_source(SOURCE_BUCKET, &seeds);
let responses: Vec<RawResponse> = futures::future::try_join_all( // Bound source connections below the fixture's limit while still
// submitting all 100 requests to the eight-slot background queue.
let responses: Vec<RawResponse> = futures::stream::iter(
keys.iter() keys.iter()
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])), .map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
) )
.buffered(16)
.try_collect()
.await?; .await?;
for (key, response) in keys.iter().zip(&responses) { for (key, response) in keys.iter().zip(&responses) {
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body)); assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
@@ -168,6 +193,15 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE) .wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
.await?; .await?;
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue"); assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
let queue_full = usize::try_from(queue_full)?;
assert!(queue_full <= REQUESTS);
env.wait_for_status_counter(
bucket,
"/counters/pulled_objects_total/background",
u64::try_from(REQUESTS + 1 - queue_full)?,
SETTLE,
)
.await?;
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum(); let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
assert!( assert!(
@@ -175,9 +209,6 @@ async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients()
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers" "every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
); );
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count(); let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
assert!( assert_eq!(dropped, queue_full, "only overflowed keys remain without a background GET");
dropped > 0,
"the overflowed keys are the ones with no backfill GET, but every key got one"
);
Ok(()) Ok(())
} }
@@ -21,7 +21,7 @@
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject}; use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject};
use crate::fake_s3_target::{BucketMode, Operation}; use crate::fake_s3_target::{BucketMode, Operation};
use aws_sdk_s3::error::ProvideErrorMetadata; use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration}; use aws_sdk_s3::types::{BucketVersioningStatus, ObjectAttributes, VersioningConfiguration};
use bytes::Bytes; use bytes::Bytes;
use std::time::Duration; use std::time::Duration;
@@ -103,11 +103,19 @@ async fn get_miss_pulls_inline_and_serves_locally_afterwards() -> TestResult {
#[tokio::test] #[tokio::test]
async fn get_large_object_streams_through_and_backfills_in_background() -> TestResult { async fn get_large_object_streams_through_and_backfills_in_background() -> TestResult {
const PART_SIZE: usize = 5 * 1024 * 1024;
let bucket = "odm-get-large"; let bucket = "odm-get-large";
let env = configured_env(bucket, |spec| spec.policy.inline_max_bytes = 4096).await?; let env = configured_env(bucket, |spec| {
spec.policy.inline_max_bytes = 4096;
spec.policy.multipart_part_size_bytes = u64::try_from(PART_SIZE).expect("part size fits in u64");
})
.await?;
let key = "large/archive.bin"; let key = "large/archive.bin";
let body = payload(512 * 1024); let body = payload(PART_SIZE + 4096);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]); let etag = env
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
.remove(0);
assert_eq!(etag.len(), 32, "the source fixture has a plain MD5 ETag");
let response = env.raw_get(bucket, key).await?; let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body)); assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
@@ -125,6 +133,68 @@ async fn get_large_object_streams_through_and_backfills_in_background() -> TestR
vec![None, None], vec![None, None],
"one passthrough GET plus one background pull, both unranged" "one passthrough GET plus one background pull, both unranged"
); );
let source_requests = env.source.requests().len();
let second_part = env.client.get_object().bucket(bucket).key(key).part_number(2).send().await?;
assert_eq!(second_part.content_length(), Some(4096), "the completed second part is the tail");
assert_eq!(
second_part.content_range(),
Some(format!("bytes {PART_SIZE}-{}/{}", body.len() - 1, body.len()).as_str()),
"partNumber reads the stored multipart boundary"
);
assert_eq!(
second_part.body.collect().await?.into_bytes(),
body.slice(PART_SIZE..),
"the local second part contains the exact source tail"
);
let third_part = env
.client
.get_object()
.bucket(bucket)
.key(key)
.part_number(3)
.send()
.await
.expect_err("the completed object has exactly two parts");
assert_eq!(third_part.code(), Some("InvalidPart"));
let mut part_marker = None;
for (part_number, part_size) in [(1, PART_SIZE), (2, 4096)] {
let attributes = env
.client
.get_object_attributes()
.bucket(bucket)
.key(key)
.object_attributes(ObjectAttributes::ObjectParts)
.object_attributes(ObjectAttributes::Etag)
.max_parts(1)
.set_part_number_marker(part_marker.clone())
.send()
.await?;
assert_eq!(
attributes.e_tag().map(|value| value.trim_matches('"')),
Some(etag.as_str()),
"multipart write-back preserves the source MD5 ETag"
);
let parts = attributes
.object_parts()
.expect("RustFS must expose the stored multipart layout");
assert_eq!(parts.total_parts_count(), Some(2));
assert_eq!(parts.max_parts(), Some(1));
assert_eq!(parts.is_truncated(), Some(part_number == 1));
assert_eq!(parts.parts().len(), 1, "RustFS returns one stored part per requested page");
assert_eq!(parts.parts()[0].part_number(), Some(part_number));
assert_eq!(parts.parts()[0].size(), Some(i64::try_from(part_size).expect("part size fits in i64")));
part_marker = parts.next_part_number_marker().map(str::to_owned);
if part_number == 1 {
assert_eq!(part_marker.as_deref(), Some("1"), "the next request continues after the first part");
}
}
assert_eq!(
env.source.requests().len(),
source_requests,
"local part reads must not consult the source"
);
Ok(()) Ok(())
} }
@@ -22,17 +22,21 @@
//! local object and what the source was asked for. //! local object and what the source was asked for.
use super::common::{ use super::common::{
AdminResponse, BoxError, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env, ALLOW_LOOPBACK_SOURCE_ENV, AdminResponse, BackfillOp, BackfillRequest, BoxError, ODM_MODULE_SWITCH_ENV, ODM_SERVER_ENV,
start_configured_env_with, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env, start_configured_env_with, start_source_rustfs,
}; };
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request}; use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation}; use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
use crate::object_lock::common::put_object_lock_configuration; use crate::object_lock::common::put_object_lock_configuration;
use crate::replication_extension_test::{
ReplicationTargetOptions, enable_bucket_versioning, set_replication_target_with_options,
};
use aws_sdk_s3::error::ProvideErrorMetadata; use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{ use aws_sdk_s3::types::{
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter, BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault, ObjectAttributes, ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption,
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, VersioningConfiguration, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Tag, Tagging,
VersioningConfiguration,
}; };
use bytes::Bytes; use bytes::Bytes;
use local_ip_address::local_ip; use local_ip_address::local_ip;
@@ -580,6 +584,130 @@ async fn test_odm_pulled_object_replicates_and_target_as_source_is_rejected() ->
"a bucket may not migrate from its own replication target: {}", "a bucket may not migrate from its own replication target: {}",
rejected.body rejected.body
); );
Box::pin(assert_odm_multipart_replicates_to_rustfs(&env, bucket)).await?;
Ok(())
}
async fn assert_odm_multipart_replicates_to_rustfs(env: &OdmTestEnv, bucket: &str) -> TestResult {
const PART_SIZE: usize = 5 * 1024 * 1024;
let replica = start_source_rustfs().await?;
let replica_bucket = "odm-real-replica";
replica.create_test_bucket(replica_bucket).await?;
enable_bucket_versioning(&replica, replica_bucket).await?;
let arn = set_replication_target_with_options(
&env.rustfs,
bucket,
ReplicationTargetOptions {
endpoint: &replica.address,
access_key: &replica.access_key,
secret_key: &replica.secret_key,
target_bucket: replica_bucket,
secure: false,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await?;
put_bucket_replication(&env.rustfs, bucket, &arn).await?;
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
// Below the 16 MiB inline default the pull is one tee'd PUT with a single
// part; force the passthrough + background multipart write-back instead.
spec.policy.inline_max_bytes = 4096;
spec.policy.multipart_part_size_bytes = PART_SIZE as u64;
spec.policy.preserve_etag = true;
env.configure_and_wait(bucket, &spec).await?;
let key = "replicated/preserved-md5-multipart.bin";
let body = payload(PART_SIZE + 4096);
let source_put = env
.source_client()
.put_object()
.bucket(SOURCE_BUCKET)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body.clone()))
.send()
.await?;
let etag = source_put.e_tag().ok_or("source PUT omitted its ETag")?.trim_matches('"');
assert_eq!(etag.len(), 32, "the source must retain a single-PUT MD5 ETag");
assert!(etag.bytes().all(|byte| byte.is_ascii_hexdigit()));
let pulled = env.raw_get(bucket, key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert_eq!(pulled.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the multipart pull must persist");
let deadline = Instant::now() + SETTLE;
let source_head = loop {
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
match head.replication_status().map(|status| status.as_str()) {
Some("COMPLETED") => break head,
Some("FAILED") => return Err("the ODM multipart copy failed replication to RustFS".into()),
_ => {
assert!(Instant::now() < deadline, "the ODM multipart copy never completed replication to RustFS");
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
};
let version = source_head
.version_id()
.ok_or("the versioned ODM copy omitted its version id")?;
assert_ne!(version, "null");
let replica_client = replica.create_s3_client();
for (client, object_bucket) in [(&env.client, bucket), (&replica_client, replica_bucket)] {
let attributes = client
.get_object_attributes()
.bucket(object_bucket)
.key(key)
.version_id(version)
.object_attributes(ObjectAttributes::Etag)
.object_attributes(ObjectAttributes::ObjectParts)
.send()
.await?;
assert_eq!(attributes.e_tag().map(|value| value.trim_matches('"')), Some(etag));
let parts = attributes
.object_parts()
.ok_or("the local copy and replica must both expose two parts")?;
assert_eq!(parts.total_parts_count(), Some(2));
assert_eq!(
parts
.parts()
.iter()
.map(|part| (part.part_number(), part.size()))
.collect::<Vec<_>>(),
[(Some(1), Some(PART_SIZE as i64)), (Some(2), Some(4096))]
);
}
// REPLICA status surfaces on HEAD, like the other inbound-replica checks.
let replica_head = replica_client
.head_object()
.bucket(replica_bucket)
.key(key)
.version_id(version)
.send()
.await?;
assert_eq!(replica_head.replication_status().map(|status| status.as_str()), Some("REPLICA"));
let replica_get = replica_client
.get_object()
.bucket(replica_bucket)
.key(key)
.version_id(version)
.send()
.await?;
assert_eq!(replica_get.version_id(), Some(version));
assert_eq!(replica_get.body.collect().await?.into_bytes(), body);
let boundary = replica_client
.get_object()
.bucket(replica_bucket)
.key(key)
.version_id(version)
.range(format!("bytes={}-{}", PART_SIZE - 32, PART_SIZE + 31))
.send()
.await?;
assert_eq!(boundary.body.collect().await?.into_bytes(), body.slice(PART_SIZE - 32..PART_SIZE + 32));
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
2,
"one passthrough GET plus one background pull; replication and local reads must not fetch the migration source again"
);
Ok(()) Ok(())
} }
@@ -733,6 +861,242 @@ async fn test_odm_disable_keeps_pulled_objects_and_stops_source_traffic() -> Tes
Ok(()) Ok(())
} }
/// The process switch preserves configured buckets and unfinished jobs while
/// restoring local-only S3 behavior, including after an ordinary metadata write.
#[tokio::test]
async fn test_odm_global_disable_preserves_data_config_and_backfill_across_restarts() -> TestResult {
let bucket = "odm-global-disable";
let mut env = start_configured_env(bucket, SOURCE_BUCKET, |spec| spec.policy.list_through = true).await?;
let pulled_key = "migrated/pulled.bin";
let remote_key = "remote/untouched.bin";
let pending_key = "backfill/pending.bin";
let local_key = "local/kept.bin";
let source_body = Bytes::from_static(b"source payload");
let local_body = Bytes::from_static(b"client payload");
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(pulled_key, source_body.clone()),
SeedObject::new(remote_key, source_body.clone()),
SeedObject::new(pending_key, source_body.clone()),
],
);
env.client
.put_object()
.bucket(bucket)
.key(local_key)
.body(local_body.clone().into())
.send()
.await?;
let pulled = env.raw_get(bucket, pulled_key).await?;
assert_eq!(pulled.status, 200);
assert_eq!(pulled.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(pulled.body, source_body);
let stored = env.raw_get(bucket, pulled_key).await?;
assert_eq!(stored.status, 200);
assert_eq!(stored.header(ODM_RESPONSE_HEADER), None, "the inline pull has committed locally");
assert_eq!(stored.body, source_body);
let config = env.get_config(bucket).await?;
assert_eq!(config.status, 200, "{}", config.body);
let config = config.json()?;
// Hold every attempt until the process has exited, so retries cannot commit
// the only backfill object before the crash. The start checkpoint exists.
let mut pending_get = env.source.hold_get_object(SOURCE_BUCKET, pending_key);
let started = env
.start_backfill(
bucket,
BackfillRequest {
prefix: Some("backfill/".to_string()),
..BackfillRequest::default()
},
)
.await?;
assert_eq!(started.status, 200, "{}", started.body);
let job_id = started.json()?["job"]["job_id"].as_str().ok_or("missing job ID")?.to_string();
tokio::time::timeout(Duration::from_secs(10), pending_get.wait_until_entered())
.await
.expect("backfill never reached the held source GET")?;
let process = env.rustfs.process.as_mut().ok_or("missing RustFS process before crash")?;
assert!(process.try_wait()?.is_none(), "RustFS exited before the controlled crash");
process.kill()?;
let stopped = process.wait()?;
assert!(!stopped.success(), "the interrupted process must exit after being killed");
drop(env.rustfs.process.take());
drop(pending_get);
env.source.take_requests();
env.rustfs
.restart_server_preserving_data(vec![], &[(ODM_MODULE_SWITCH_ENV, "false"), (ALLOW_LOOPBACK_SOURCE_ENV, "true")])
.await?;
let off_config = env.get_config(bucket).await?;
assert_eq!(off_config.status, 200, "{}", off_config.body);
assert_eq!(off_config.json()?, config, "the saved configuration and timestamp survive disabling");
let status = env.status_json(bucket).await?;
assert_eq!(status["configured"], true, "{status}");
assert_eq!(status["enabled"], true, "the bucket remains configured as enabled: {status}");
assert_eq!(status["module_enabled"], false, "{status}");
assert_eq!(status["counters"], Value::Null, "no bucket runtime is installed: {status}");
let checkpoint = env.backfill_job(bucket).await?.ok_or("disabled module lost the checkpoint")?;
assert_eq!(checkpoint["job_id"], job_id);
assert_eq!(checkpoint["state"], "running", "the interrupted job is retained: {checkpoint}");
for (key, body) in [(local_key, &local_body), (pulled_key, &source_body)] {
let get = env.raw_get(bucket, key).await?;
assert_eq!(get.status, 200);
assert_eq!(&get.body, body);
assert_eq!(get.header(ODM_RESPONSE_HEADER), None);
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
assert_eq!(head.content_length(), Some(i64::try_from(body.len())?));
}
for key in [remote_key, pending_key] {
let get = env.raw_get(bucket, key).await?;
assert_eq!(get.status, 404, "disabled source GET {key}: {}", String::from_utf8_lossy(&get.body));
let head = env.client.head_object().bucket(bucket).key(key).send().await;
let err = head.expect_err("a source-only object must remain absent locally");
assert_eq!(err.raw_response().map(|response| response.status().as_u16()), Some(404));
}
let replacement = Bytes::from_static(b"written while the module is off");
for key in [local_key, "local/deleted.bin"] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(replacement.clone().into())
.send()
.await?;
}
env.client
.delete_object()
.bucket(bucket)
.key("local/deleted.bin")
.send()
.await?;
assert_eq!(env.raw_get(bucket, "local/deleted.bin").await?.status, 404);
assert_eq!(env.raw_get(bucket, local_key).await?.body, replacement);
// Both wire protocols must finish their local pages even though the saved
// configuration still requests list-through.
for use_v2 in [false, true] {
let mut cursor = None;
let mut listed = Vec::new();
for page_number in 0..2 {
let (keys, truncated, next) = if use_v2 {
let page = env
.client
.list_objects_v2()
.bucket(bucket)
.max_keys(1)
.set_continuation_token(cursor)
.send()
.await?;
(
page.contents()
.iter()
.map(|object| object.key().expect("listed key").to_string())
.collect::<Vec<_>>(),
page.is_truncated(),
page.next_continuation_token().map(str::to_string),
)
} else {
let page = env
.client
.list_objects()
.bucket(bucket)
.max_keys(1)
.set_marker(cursor)
.send()
.await?;
// V1 may omit NextMarker without a delimiter; clients then
// continue from the last returned key.
let next = page.next_marker().or_else(|| {
if page.is_truncated() == Some(true) {
page.contents().last().and_then(|object| object.key())
} else {
None
}
});
(
page.contents()
.iter()
.map(|object| object.key().expect("listed key").to_string())
.collect::<Vec<_>>(),
page.is_truncated(),
next.map(str::to_string),
)
};
assert_eq!(keys.len(), 1, "one local key per page, V2={use_v2}");
assert_eq!(truncated, Some(page_number == 0), "local pagination must terminate, V2={use_v2}");
if page_number == 0 {
assert!(next.as_ref().is_some_and(|value| !value.is_empty()), "missing local cursor, V2={use_v2}");
}
cursor = next;
listed.extend(keys);
}
assert_eq!(listed, [local_key, pulled_key], "source-only keys must stay absent, V2={use_v2}");
}
let spec = env.fake_source_spec(SOURCE_BUCKET);
for response in [
env.configure_source(bucket, &spec).await?,
env.validate_source(bucket, &spec).await?,
env.backfill(bucket, BackfillOp::Start(BackfillRequest::default())).await?,
] {
assert_eq!(response.status, 400, "{}", response.body);
assert!(response.body.contains("OnDemandMigrationDisabled"), "{}", response.body);
}
let tagging = Tagging::builder()
.tag_set(Tag::builder().key("module").value("disabled").build()?)
.build()?;
env.client
.put_bucket_tagging()
.bucket(bucket)
.tagging(tagging.clone())
.send()
.await?;
assert_eq!(env.get_config(bucket).await?.json()?, config, "an unrelated metadata write preserves ODM");
assert_eq!(
env.backfill_job(bucket).await?,
Some(checkpoint),
"no recovery or checkpoint update while disabled"
);
assert!(
env.source.requests().is_empty(),
"disabled startup and all requests must leave the source untouched"
);
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
env.wait_until_source_consulted(bucket).await?;
assert_eq!(
env.get_config(bucket).await?.json()?,
config,
"reenabling uses the persisted configuration"
);
let tags = env.client.get_bucket_tagging().bucket(bucket).send().await?;
assert_eq!(tags.tag_set(), tagging.tag_set(), "the ordinary metadata write also persists");
let resumed = env.raw_get(bucket, remote_key).await?;
assert_eq!(resumed.status, 200);
assert_eq!(resumed.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(resumed.body, source_body, "stored credentials still authenticate without reconfiguration");
let completed = env
.wait_for_backfill(bucket, SETTLE, |job| job["state"] == "completed")
.await?;
assert_eq!(completed["job_id"], job_id, "the interrupted job resumes without a new start");
assert_eq!(completed["failed"], 0, "{completed}");
for (key, body) in [
(local_key, &replacement),
(pulled_key, &source_body),
(pending_key, &source_body),
] {
let get = env.raw_get(bucket, key).await?;
assert_eq!(get.status, 200);
assert_eq!(&get.body, body);
assert_eq!(get.header(ODM_RESPONSE_HEADER), None, "{key} remains stored locally");
}
Ok(())
}
/// Case 19: the admin surface an operator sees — the configuration read back /// Case 19: the admin surface an operator sees — the configuration read back
/// without its secret, and a status document whose counters match the source /// without its secret, and a status document whose counters match the source
/// journal exactly. /// journal exactly.
@@ -265,16 +265,13 @@ async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?; let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}"); assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":2").as_bytes()); let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":3").as_bytes());
let rejected = env assert_ne!(tampered, token, "the test must change the token version");
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}")) let query = serde_urlencoded::to_string([("continuation-token", tampered.as_str())])?;
.await?; let rejected = env.raw_list_objects_v2(bucket, &query).await?;
assert_eq!( let error_body = String::from_utf8_lossy(&rejected.body);
rejected.status, assert_eq!(rejected.status, 400, "a bumped token version is a client error: {}", error_body);
400, assert!(error_body.contains("<Code>InvalidArgument</Code>"), "{error_body}");
"a bumped token version is a client error: {}",
String::from_utf8_lossy(&rejected.body)
);
Ok(()) Ok(())
} }
+348 -15
View File
@@ -368,23 +368,23 @@ impl Drop for SlowReplicationTargetGuard {
// Mirrors madmin-go `ResyncTargetsInfo`/`ResyncTarget` json tags — the same // Mirrors madmin-go `ResyncTargetsInfo`/`ResyncTarget` json tags — the same
// shape `mc replicate resync status` decodes. // shape `mc replicate resync status` decodes.
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
struct ReplicationResetStatusResponse { pub(crate) struct ReplicationResetStatusResponse {
#[serde(rename = "target", default)] #[serde(rename = "target", default)]
targets: Vec<ReplicationResetStatusTarget>, pub(crate) targets: Vec<ReplicationResetStatusTarget>,
} }
#[derive(Debug, Clone, serde::Deserialize)] #[derive(Debug, Clone, serde::Deserialize)]
struct ReplicationResetStatusTarget { pub(crate) struct ReplicationResetStatusTarget {
#[serde(rename = "arn", default)] #[serde(rename = "arn", default)]
arn: String, pub(crate) arn: String,
#[serde(rename = "resetid", default)] #[serde(rename = "resetid", default)]
reset_id: String, pub(crate) reset_id: String,
#[serde(rename = "resyncStatus", default)] #[serde(rename = "resyncStatus", default)]
status: String, pub(crate) status: String,
#[serde(rename = "replicationCount", default)] #[serde(rename = "replicationCount", default)]
replicated_count: i64, pub(crate) replicated_count: i64,
#[serde(rename = "object", default)] #[serde(rename = "object", default)]
object: String, pub(crate) object: String,
} }
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> { fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
@@ -512,7 +512,7 @@ pub(crate) async fn put_bucket_replication(
put_bucket_replication_with_delete_statuses(env, bucket, target_arn, "Enabled", None).await put_bucket_replication_with_delete_statuses(env, bucket, target_arn, "Enabled", None).await
} }
async fn put_bucket_replication_with_delete_statuses( pub(crate) async fn put_bucket_replication_with_delete_statuses(
env: &RustFSTestEnvironment, env: &RustFSTestEnvironment,
bucket: &str, bucket: &str,
target_arn: &str, target_arn: &str,
@@ -2294,7 +2294,7 @@ async fn site_replication_state_edit(
/// return the target `(arn, reset_id)`, asserting the response carries the /// return the target `(arn, reset_id)`, asserting the response carries the
/// madmin `ResyncTargetsInfo` shape (`target[0].arn` / `target[0].resetid`) /// madmin `ResyncTargetsInfo` shape (`target[0].arn` / `target[0].resetid`)
/// that `mc replicate resync start` decodes. /// that `mc replicate resync start` decodes.
async fn start_bucket_replication_reset( pub(crate) async fn start_bucket_replication_reset(
env: &RustFSTestEnvironment, env: &RustFSTestEnvironment,
bucket: &str, bucket: &str,
) -> Result<(String, String), Box<dyn Error + Send + Sync>> { ) -> Result<(String, String), Box<dyn Error + Send + Sync>> {
@@ -2314,7 +2314,7 @@ async fn start_bucket_replication_reset(
Ok((arn, reset_id)) Ok((arn, reset_id))
} }
async fn get_replication_reset_status( pub(crate) async fn get_replication_reset_status(
env: &RustFSTestEnvironment, env: &RustFSTestEnvironment,
bucket: &str, bucket: &str,
arn: &str, arn: &str,
@@ -3837,6 +3837,244 @@ async fn test_bucket_replication_converges_delete_marker_and_version_purge() ->
Ok(()) Ok(())
} }
/// Regression for rustfs/backlog#2340 (not Wasabi specific): a directory
/// marker (`prefix/` with a body) in a versioned bucket is stored as the null
/// version, like MinIO (`putOpts`: "for directory objects skip creating new
/// versions"), and must still replicate to completion instead of staying
/// `PENDING`.
#[tokio::test]
async fn test_bucket_replication_replicates_directory_marker_in_versioned_bucket() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-dir-marker-src";
let target_bucket = "replication-dir-marker-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let marker_key = "dir/trailing/";
let body = b"directory marker body";
let put = source_client
.put_object()
.bucket(source_bucket)
.key(marker_key)
.body(ByteStream::from_static(body))
.send()
.await?;
assert!(
put.version_id()
.is_none_or(|id| id == "null" || id == uuid::Uuid::nil().to_string()),
"a directory marker is the null version even in a versioned bucket: {:?}",
put.version_id()
);
wait_for_source_replication_status(&source_client, source_bucket, marker_key, "COMPLETED", false).await?;
let replica = target_client
.get_object()
.bucket(target_bucket)
.key(marker_key)
.send()
.await?;
assert_eq!(replica.body.collect().await?.into_bytes().as_ref(), body);
let listed = target_client
.list_object_versions()
.bucket(target_bucket)
.prefix(marker_key)
.send()
.await?;
let marker_versions: Vec<_> = listed.versions().iter().filter(|v| v.key() == Some(marker_key)).collect();
assert_eq!(marker_versions.len(), 1, "the marker must land exactly once: {marker_versions:?}");
assert_eq!(
marker_versions[0].version_id(),
Some("null"),
"the replica keeps the null version identity"
);
Ok(())
}
/// Regression for rustfs/backlog#2340 (not Wasabi specific): permanently
/// deleting a version whose payload lives in a data dir must leave the source
/// clean once the purge replicates. Managed-SSE objects are never inlined and a
/// plain object above the inline threshold takes the same layout. The version
/// retained with a pending purge used to lose its data dir, so the purge state
/// could never be applied (`VersionNotFound` on every retry) and the bucket
/// stayed `BucketNotEmpty` while `ListObjectVersions` was already empty.
#[tokio::test]
async fn test_bucket_replication_version_purge_of_non_inline_object_releases_source_bucket() -> TestResult {
init_logging();
let (source_env, target_env, source_bucket, target_bucket) = build_sse_replication_pair("purge-datadir", true, true).await?;
let target_arn = wait_for_remote_target_arn(&source_env, &source_bucket).await?;
put_bucket_replication_with_delete_statuses(&source_env, &source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
let sse_key = "sse-object.bin";
let large_key = "large-object.bin";
let sse_put = source_client
.put_object()
.bucket(&source_bucket)
.key(sse_key)
.body(ByteStream::from_static(b"encrypted source payload"))
.server_side_encryption(ServerSideEncryption::Aes256)
.send()
.await?;
let large_put = source_client
.put_object()
.bucket(&source_bucket)
.key(large_key)
.body(ByteStream::from(vec![0x5a; 2 * 1024 * 1024]))
.send()
.await?;
let purged = [
(sse_key, sse_put.version_id().ok_or("SSE PUT omitted version ID")?.to_string()),
(large_key, large_put.version_id().ok_or("large PUT omitted version ID")?.to_string()),
];
assert_replication_converged(&source_client, &source_bucket, &target_client, &target_bucket).await?;
for (key, version_id) in &purged {
source_client
.delete_object()
.bucket(&source_bucket)
.key(*key)
.version_id(version_id)
.send()
.await?;
}
assert_replication_converged(&source_client, &source_bucket, &target_client, &target_bucket).await?;
let target_state = list_replication_state(&target_client, &target_bucket).await?;
assert!(target_state.is_empty(), "target retained an explicitly purged version: {target_state:?}");
// The purge state is applied on the source asynchronously after the target
// acknowledges the delete; only then does the retained version go away and
// the bucket become deletable. A listing that is empty while DeleteBucket
// keeps answering BucketNotEmpty is exactly the regression.
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
loop {
let listing = source_client.list_object_versions().bucket(&source_bucket).send().await?;
let listed = listing.versions().len() + listing.delete_markers().len();
match source_client.delete_bucket().bucket(&source_bucket).send().await {
Ok(_) => break,
Err(err) if err.code() == Some("BucketNotEmpty") => {
if tokio::time::Instant::now() >= deadline {
return Err(format!(
"source bucket stayed BucketNotEmpty after the version purge replicated; \
ListObjectVersions shows {listed} entries"
)
.into());
}
sleep(Duration::from_millis(500)).await;
}
Err(err) => return Err(err.into()),
}
}
Ok(())
}
/// Regression for rustfs/backlog#2340 (not Wasabi specific): a single-part
/// object uploaded with `x-amz-checksum-*` must reach the target with the same
/// checksum. The outbound options keyed the stored record by algorithm name,
/// which the target client sent as `x-amz-meta-*` user metadata, so a replica
/// never carried a checksum although the source HEAD returned one.
#[tokio::test]
async fn test_bucket_replication_forwards_single_part_object_checksums() -> TestResult {
init_logging();
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env = RustFSTestEnvironment::new().await?;
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
let source_bucket = "replication-checksum-src";
let target_bucket = "replication-checksum-dst";
let source_client = source_env.create_s3_client();
let target_client = target_env.create_s3_client();
source_client.create_bucket().bucket(source_bucket).send().await?;
target_client.create_bucket().bucket(target_bucket).send().await?;
enable_bucket_versioning(&source_env, source_bucket).await?;
enable_bucket_versioning(&target_env, target_bucket).await?;
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
let body = b"123456789";
let crc32_key = "checksum-crc32.txt";
let sha256_key = "checksum-sha256.txt";
let crc32_put = source_client
.put_object()
.bucket(source_bucket)
.key(crc32_key)
.body(ByteStream::from_static(body))
.checksum_algorithm(aws_sdk_s3::types::ChecksumAlgorithm::Crc32)
.send()
.await?;
let expected_crc32 = crc32_put.checksum_crc32().ok_or("source PUT omitted CRC32")?.to_string();
let sha256_put = source_client
.put_object()
.bucket(source_bucket)
.key(sha256_key)
.body(ByteStream::from_static(body))
.checksum_algorithm(aws_sdk_s3::types::ChecksumAlgorithm::Sha256)
.send()
.await?;
let expected_sha256 = sha256_put.checksum_sha256().ok_or("source PUT omitted SHA256")?.to_string();
for key in [crc32_key, sha256_key] {
wait_for_source_replication_status(&source_client, source_bucket, key, "COMPLETED", false).await?;
}
let replica = target_client
.head_object()
.bucket(target_bucket)
.key(crc32_key)
.checksum_mode(aws_sdk_s3::types::ChecksumMode::Enabled)
.send()
.await?;
assert_eq!(replica.checksum_crc32(), Some(expected_crc32.as_str()), "replica lost the CRC32 checksum");
let replica = target_client
.head_object()
.bucket(target_bucket)
.key(sha256_key)
.checksum_mode(aws_sdk_s3::types::ChecksumMode::Enabled)
.send()
.await?;
assert_eq!(
replica.checksum_sha256(),
Some(expected_sha256.as_str()),
"replica lost the SHA256 checksum"
);
// The bare algorithm name must not leak as user metadata either.
assert!(
replica
.metadata()
.is_none_or(|meta| !meta.keys().any(|k| k.eq_ignore_ascii_case("sha256"))),
"replica carries the checksum as user metadata: {:?}",
replica.metadata()
);
Ok(())
}
#[tokio::test] #[tokio::test]
async fn test_bucket_replication_disabled_delete_marker_does_not_propagate() -> TestResult { async fn test_bucket_replication_disabled_delete_marker_does_not_propagate() -> TestResult {
init_logging(); init_logging();
@@ -6743,6 +6981,99 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
Ok(()) Ok(())
} }
#[tokio::test]
async fn test_site_replication_replays_bucket_created_during_peer_outage_real_dual_node() -> TestResult {
init_logging();
// Keep compilation outside the scenario timeout. Recovery itself waits
// for the production 30-second lightweight retry tick.
let _rustfs_binary = rustfs_binary_path();
match timeout(Duration::from_secs(150), async {
let mut site_env = replication_fast_env();
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
let mut site_a_env = RustFSTestEnvironment::new().await?;
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut site_b_env = RustFSTestEnvironment::new().await?;
site_b_env.start_rustfs_server_without_cleanup_with_env(&site_env).await?;
let site_a_client = site_a_env.create_s3_client();
let site_b_client = site_b_env.create_s3_client();
let bucket = "site-repl-peer-outage";
let key = "after-recovery.txt";
let payload = b"site replication recovered the missed bucket".to_vec();
let add_status = site_replication_add(
&site_a_env,
&[
PeerSite {
name: "outage-site-a".to_string(),
endpoint: site_a_env.url.clone(),
access_key: site_a_env.access_key.clone(),
secret_key: site_a_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "outage-site-b".to_string(),
endpoint: site_b_env.url.clone(),
access_key: site_b_env.access_key.clone(),
secret_key: site_b_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
wait_for_site_replication_enabled(&site_a_env, 2).await?;
wait_for_site_replication_enabled(&site_b_env, 2).await?;
site_b_env.stop_server();
site_a_client.create_bucket().bucket(bucket).send().await?;
site_a_client.head_bucket().bucket(bucket).send().await?;
let queued = site_replication_info(&site_a_env)
.await?
.retry_stats
.ok_or("peer outage did not persist a site replication retry event")?;
assert!(queued.pending + queued.failed > 0, "peer outage retry queue was unexpectedly empty");
site_b_env.restart_server_preserving_data(vec![], &site_env).await?;
let recovery_deadline = tokio::time::Instant::now() + Duration::from_secs(75);
loop {
let bucket_recovered = site_b_client.head_bucket().bucket(bucket).send().await.is_ok();
let queue_empty = site_replication_info(&site_a_env).await?.retry_stats.is_none();
if bucket_recovered && queue_empty {
break;
}
if tokio::time::Instant::now() >= recovery_deadline {
return Err(format!(
"site replication retry did not settle after peer recovery; bucket_recovered={bucket_recovered}, queue_empty={queue_empty}"
)
.into());
}
sleep(Duration::from_millis(250)).await;
}
site_a_client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(payload.clone()))
.send()
.await?;
assert_eq!(wait_for_object_on_target(&site_b_client, bucket, key).await?, payload);
Ok(())
})
.await
{
Ok(result) => result,
Err(_) => Err("site replication peer-outage recovery timed out after 150 seconds".into()),
}
}
/// Re-applying a site's own replication config must not disable the peer's reverse direction. /// Re-applying a site's own replication config must not disable the peer's reverse direction.
/// ///
/// `PutBucketReplication` broadcasts the config to every peer — the console's replication /// `PutBucketReplication` broadcasts the config to every peer — the console's replication
@@ -8958,11 +9289,13 @@ async fn test_replication_check_flags_version_minting_target() -> TestResult {
fidelity["Code"], "BucketRemoteTargetVersionMismatch", fidelity["Code"], "BucketRemoteTargetVersionMismatch",
"the failure must carry a machine-readable code: {payload}" "the failure must carry a machine-readable code: {payload}"
); );
// The probe PUT itself succeeded (fidelity is judged from its response); // The probe PUT itself succeeded (fidelity is judged from its response).
// the later mutation phases are pointless against a drifting target and // The mutation phases address the id the target assigned — the ledger
// must be skipped, but cleanup still runs. // the worker records per object (rustfs/backlog#2340) — so they run and
// pass on a drifting target, and cleanup uses the same id.
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}"); assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "SKIPPED", "{payload}"); assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "OK", "{payload}");
assert_eq!(target_report["Phases"]["VersionDelete"]["Status"], "OK", "{payload}");
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}"); assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
// The probe PUT must carry the source version as `?versionId=` — the // The probe PUT must carry the source version as `?versionId=` — the
@@ -31,17 +31,21 @@
//! Adding a target behavior the fleet has shown: add the mode to the fake //! Adding a target behavior the fleet has shown: add the mode to the fake
//! target, add a row here, and record any cell that is red before the fix. //! target, add a row here, and record any cell that is red before the fix.
use crate::common::{RustFSTestEnvironment, init_logging, replication_fast_env}; use crate::common::{init_logging, replication_fast_env};
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY}; use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
use crate::fake_s3_target::{FakeS3Target, Operation as FakeTargetOperation, RequestRecord}; use crate::fake_s3_target::{FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation, RequestRecord};
use crate::on_demand_migration::common::fake_source_client; use crate::on_demand_migration::common::{OdmEnvOptions, OdmTestEnv, fake_source_client};
use crate::replication_extension_test::{ use crate::replication_extension_test::{
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, put_bucket_replication, LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, get_replication_reset_status,
set_replication_target_with_options, put_bucket_replication, put_bucket_replication_with_delete_statuses, set_replication_target_with_options,
start_bucket_replication_reset,
}; };
use aws_sdk_s3::Client; use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::{ByteStream, DateTime}; use aws_sdk_s3::primitives::{ByteStream, DateTime};
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockMode}; use aws_sdk_s3::types::{
Checksum, ChecksumAlgorithm, CompletedMultipartUpload, CompletedPart, ObjectAttributes, ObjectLockLegalHold,
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, Tag, Tagging,
};
use bytes::Bytes; use bytes::Bytes;
use std::error::Error; use std::error::Error;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
@@ -63,7 +67,10 @@ enum TargetMode {
/// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`. /// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`.
RequireChecksumWithObjectLock, RequireChecksumWithObjectLock,
/// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids /// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids
/// (rustfs/backlog#2085). Data must still land. /// (rustfs/backlog#2085) and, like Wasabi, answers NoSuchVersion to a
/// DELETE of an id it never had (rustfs/backlog#2340). Data must still
/// land, and every version-addressed mutation must resolve the replica
/// through the target-version ledger.
MintOwnVersionIds, MintOwnVersionIds,
} }
@@ -80,7 +87,10 @@ impl TargetMode {
TargetMode::Baseline => {} TargetMode::Baseline => {}
TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true), TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true),
TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true), TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true),
TargetMode::MintOwnVersionIds => target.assign_own_version_ids(true), TargetMode::MintOwnVersionIds => {
target.assign_own_version_ids(true);
target.reject_unknown_version_deletes(true);
}
} }
} }
@@ -110,16 +120,23 @@ enum ObjectShape {
/// Two-part multipart upload with a GOVERNANCE retention period; the /// Two-part multipart upload with a GOVERNANCE retention period; the
/// lock headers travel on CreateMultipartUpload, which has no body. /// lock headers travel on CreateMultipartUpload, which has no body.
LockedMultipart, LockedMultipart,
/// ODM stores two local parts while preserving a single-PUT source's MD5 ETag.
OdmPreservedMd5Multipart,
/// Single-part object uploaded with `x-amz-checksum-sha256`; the replica
/// must carry the same header (rustfs/backlog#2340).
Checksummed,
} }
impl ObjectShape { impl ObjectShape {
const ALL: [ObjectShape; 6] = [ const ALL: [ObjectShape; 8] = [
ObjectShape::Empty, ObjectShape::Empty,
ObjectShape::Plain, ObjectShape::Plain,
ObjectShape::Retention, ObjectShape::Retention,
ObjectShape::LegalHold, ObjectShape::LegalHold,
ObjectShape::Multipart, ObjectShape::Multipart,
ObjectShape::LockedMultipart, ObjectShape::LockedMultipart,
ObjectShape::OdmPreservedMd5Multipart,
ObjectShape::Checksummed,
]; ];
fn key(self) -> &'static str { fn key(self) -> &'static str {
@@ -130,6 +147,17 @@ impl ObjectShape {
ObjectShape::LegalHold => "matrix/legal-hold.bin", ObjectShape::LegalHold => "matrix/legal-hold.bin",
ObjectShape::Multipart => "matrix/multipart.bin", ObjectShape::Multipart => "matrix/multipart.bin",
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin", ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
ObjectShape::OdmPreservedMd5Multipart => "matrix/odm-preserved-md5.bin",
ObjectShape::Checksummed => "matrix/checksummed.bin",
}
}
/// The `x-amz-checksum-*` header the source stored and every upload of
/// the replica must repeat.
fn forwarded_checksum_header(self) -> Option<&'static str> {
match self {
ObjectShape::Checksummed => Some("x-amz-checksum-sha256"),
_ => None,
} }
} }
@@ -139,7 +167,8 @@ impl ObjectShape {
/// Upload the shape to the source and return the bytes the target must /// Upload the shape to the source and return the bytes the target must
/// end up holding. /// end up holding.
async fn put(self, client: &Client, bucket: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> { async fn put(self, env: &OdmTestEnv, bucket: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
let client = &env.client;
let key = self.key(); let key = self.key();
match self { match self {
ObjectShape::Empty => { ObjectShape::Empty => {
@@ -190,6 +219,19 @@ impl ObjectShape {
} }
ObjectShape::Multipart => multipart_put(client, bucket, key, 0x44, false).await, ObjectShape::Multipart => multipart_put(client, bucket, key, 0x44, false).await,
ObjectShape::LockedMultipart => multipart_put(client, bucket, key, 0x55, true).await, ObjectShape::LockedMultipart => multipart_put(client, bucket, key, 0x55, true).await,
ObjectShape::OdmPreservedMd5Multipart => odm_preserved_md5_multipart(env, bucket, key).await,
ObjectShape::Checksummed => {
let body = payload(40 * 1024, 0x66);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.checksum_algorithm(ChecksumAlgorithm::Sha256)
.send()
.await?;
Ok(body)
}
} }
} }
} }
@@ -219,6 +261,460 @@ fn expectation(mode: TargetMode, shape: ObjectShape) -> Expectation {
.unwrap_or(Expectation::Completed) .unwrap_or(Expectation::Completed)
} }
/// rustfs/backlog#2340: a target that mints its own version ids (Wasabi,
/// AWS S3) answers 404 to a HEAD by the source uuid, which the worker used to
/// read as "replica missing" and re-drive the PUT — one more target version
/// per heal, MRF retry or resync. Two re-drive shapes, both must converge on
/// the single version the first PUT created:
/// - 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.
#[tokio::test]
async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult {
init_logging();
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);
let mut env_vars = replication_fast_env();
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
env_vars.extend_from_slice(&[
("NO_PROXY", "127.0.0.1,localhost"),
("HTTP_PROXY", ""),
("HTTPS_PROXY", ""),
// The scanner heal pass is what re-drives a FAILED object.
("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_SCANNER_START_DELAY_SECS", "1"),
]);
let env = OdmTestEnv::start_with(OdmEnvOptions {
env: env_vars,
..OdmEnvOptions::default()
})
.await?;
let source_env = &env.rustfs;
let source_bucket = "matrix-mint-own-redrive-src";
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
enable_bucket_versioning(source_env, source_bucket).await?;
let target_arn = set_replication_target_with_options(
source_env,
source_bucket,
ReplicationTargetOptions {
endpoint: &target.address(),
access_key: FAKE_ACCESS_KEY,
secret_key: FAKE_SECRET_KEY,
target_bucket: &target_bucket,
secure: false,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await?;
put_bucket_replication(source_env, source_bucket, &target_arn).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?;
assert_eq!(
wait_for_terminal_replication_status(&source_client, source_bucket, probe_key).await?,
"COMPLETED"
);
// 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?;
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?;
assert_eq!(
wait_for_terminal_replication_status(&source_client, source_bucket, resync_key).await?,
"COMPLETED"
);
let (reset_arn, _reset_id) = start_bucket_replication_reset(source_env, source_bucket).await?;
assert_eq!(reset_arn, target_arn);
let resync = async {
loop {
let status = get_replication_reset_status(source_env, source_bucket, &target_arn).await?;
if let Some(entry) = status.targets.iter().find(|entry| entry.arn == target_arn)
&& entry.status == "Completed"
{
return Ok::<_, Box<dyn Error + Send + Sync>>(entry.replicated_count);
}
sleep(Duration::from_millis(250)).await;
}
};
let replicated = timeout(Duration::from_secs(90), resync)
.await
.map_err(|_| "existing-object resync did not complete within 90 seconds")??;
assert!(replicated >= 3, "resync must count the located replicas as replicated, got {replicated}");
for key in [probe_key, heal_key, resync_key] {
let versions = target.stored_versions(&target_bucket, key);
assert_eq!(
versions.len(),
1,
"{key}: a re-drive against a target that mints its own version ids must not mint another one: {versions:?}"
);
}
target.shutdown().await;
Ok(())
}
/// rustfs/backlog#2340 (target-version ledger): on a target that mints its own
/// version ids and answers NoSuchVersion to an unknown id (the Wasabi shape),
/// every version-addressed mutation must land on the version the target
/// assigned, which the replication PUT recorded on the source:
/// - a tag update changes the existing target version, no new version;
/// - a retention extension and legal hold ON/OFF change that version too;
/// - a permanent delete of the older of two same-content generations removes
/// exactly that replica and keeps the live one (content identity alone
/// could not tell them apart).
#[tokio::test]
async fn matrix_mint_own_version_ids_addresses_mutations_through_the_ledger() -> TestResult {
init_logging();
let target = FakeS3Target::start().await?;
let target_bucket = "matrix-mint-own-ledger-dst".to_string();
target.create_bucket_with_object_lock(target_bucket.clone());
TargetMode::MintOwnVersionIds.apply(&target);
let mut env_vars = replication_fast_env();
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
env_vars.extend_from_slice(&[
("NO_PROXY", "127.0.0.1,localhost"),
("HTTP_PROXY", ""),
("HTTPS_PROXY", ""),
// The scanner heal pass retries a purge the first attempt lost.
("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_SCANNER_START_DELAY_SECS", "1"),
]);
let env = OdmTestEnv::start_with(OdmEnvOptions {
env: env_vars,
..OdmEnvOptions::default()
})
.await?;
let source_env = &env.rustfs;
let source_bucket = "matrix-mint-own-ledger-src";
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(source_bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
enable_bucket_versioning(source_env, source_bucket).await?;
let target_arn = set_replication_target_with_options(
source_env,
source_bucket,
ReplicationTargetOptions {
endpoint: &target.address(),
access_key: FAKE_ACCESS_KEY,
secret_key: FAKE_SECRET_KEY,
target_bucket: &target_bucket,
secure: false,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await?;
put_bucket_replication_with_delete_statuses(source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
let target_client = fake_source_client(&target);
// Tag update on an existing version.
let tag_key = "ledger/tags.bin";
let tagged = source_client
.put_object()
.bucket(source_bucket)
.key(tag_key)
.body(ByteStream::from(payload(4 * 1024, 0x01)))
.send()
.await?;
let tag_source_version = tagged.version_id().ok_or("source PUT returned no version id")?.to_string();
assert_eq!(
wait_for_terminal_replication_status(&source_client, source_bucket, tag_key).await?,
"COMPLETED"
);
let tag_target_version = single_target_version(&target, &target_bucket, tag_key)?;
source_client
.put_object_tagging()
.bucket(source_bucket)
.key(tag_key)
.version_id(&tag_source_version)
.tagging(
Tagging::builder()
.tag_set(Tag::builder().key("phase").value("after").build()?)
.build()?,
)
.send()
.await?;
wait_until("tag update on the existing target version", || async {
let tags = target_client
.get_object_tagging()
.bucket(&target_bucket)
.key(tag_key)
.version_id(&tag_target_version)
.send()
.await?;
Ok(tags
.tag_set()
.iter()
.any(|tag| tag.key() == "phase" && tag.value() == "after"))
})
.await?;
assert_stable_single_version(&target, &target_bucket, tag_key, &tag_target_version).await?;
// Retention extension and legal hold on an existing version.
let lock_key = "ledger/lock.bin";
let locked = source_client
.put_object()
.bucket(source_bucket)
.key(lock_key)
.body(ByteStream::from(payload(4 * 1024, 0x02)))
.object_lock_mode(ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until())
.send()
.await?;
let lock_source_version = locked.version_id().ok_or("source PUT returned no version id")?.to_string();
assert_eq!(
wait_for_terminal_replication_status(&source_client, source_bucket, lock_key).await?,
"COMPLETED"
);
let lock_target_version = single_target_version(&target, &target_bucket, lock_key)?;
let extended = DateTime::from_secs(retain_until().secs() + 86_400);
source_client
.put_object_retention()
.bucket(source_bucket)
.key(lock_key)
.version_id(&lock_source_version)
.retention(
ObjectLockRetention::builder()
.mode(ObjectLockRetentionMode::Governance)
.retain_until_date(extended)
.build(),
)
.send()
.await?;
source_client
.put_object_legal_hold()
.bucket(source_bucket)
.key(lock_key)
.version_id(&lock_source_version)
.legal_hold(ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::On).build())
.send()
.await?;
wait_until("retention extension and legal hold on the existing target version", || async {
let head = target_client
.head_object()
.bucket(&target_bucket)
.key(lock_key)
.version_id(&lock_target_version)
.send()
.await?;
Ok(head.object_lock_retain_until_date().map(|date| date.secs()) == Some(extended.secs())
&& head.object_lock_legal_hold_status() == Some(&ObjectLockLegalHoldStatus::On))
})
.await?;
source_client
.put_object_legal_hold()
.bucket(source_bucket)
.key(lock_key)
.version_id(&lock_source_version)
.legal_hold(ObjectLockLegalHold::builder().status(ObjectLockLegalHoldStatus::Off).build())
.send()
.await?;
wait_until("legal hold removal on the existing target version", || async {
let head = target_client
.head_object()
.bucket(&target_bucket)
.key(lock_key)
.version_id(&lock_target_version)
.send()
.await?;
Ok(head.object_lock_legal_hold_status() == Some(&ObjectLockLegalHoldStatus::Off))
})
.await?;
assert_stable_single_version(&target, &target_bucket, lock_key, &lock_target_version).await?;
// Permanent delete of the older of two same-content generations.
let generations_key = "ledger/generations.bin";
let body = payload(4 * 1024, 0x03);
let older = source_client
.put_object()
.bucket(source_bucket)
.key(generations_key)
.body(ByteStream::from(body.clone()))
.send()
.await?;
let older_version = older.version_id().ok_or("source PUT returned no version id")?.to_string();
assert_eq!(
wait_for_terminal_replication_status(&source_client, source_bucket, generations_key).await?,
"COMPLETED"
);
let older_replica = single_target_version(&target, &target_bucket, generations_key)?;
source_client
.put_object()
.bucket(source_bucket)
.key(generations_key)
.body(ByteStream::from(body))
.send()
.await?;
assert_eq!(
wait_for_terminal_replication_status(&source_client, source_bucket, generations_key).await?,
"COMPLETED"
);
wait_until("both generations replicated", || async {
Ok(target.stored_versions(&target_bucket, generations_key).len() == 2)
})
.await?;
let newer_replica = target
.stored_versions(&target_bucket, generations_key)
.into_iter()
.map(|(version_id, _)| version_id)
.find(|version_id| version_id != &older_replica)
.ok_or("the second generation must have its own target version")?;
source_client
.delete_object()
.bucket(source_bucket)
.key(generations_key)
.version_id(&older_version)
.send()
.await?;
wait_until("permanent delete of the older generation's replica", || async {
let versions: Vec<String> = target
.stored_versions(&target_bucket, generations_key)
.into_iter()
.map(|(version_id, _)| version_id)
.collect();
Ok(versions == [newer_replica.clone()])
})
.await?;
assert_stable_single_version(&target, &target_bucket, generations_key, &newer_replica).await?;
// No mutation above may have gone out as a re-PUT: one upload per key.
for key in [tag_key, lock_key] {
let puts = target
.requests()
.iter()
.filter(|record| record.key.as_deref() == Some(key) && record.operation == FakeTargetOperation::PutObject)
.count();
assert_eq!(
puts, 1,
"{key}: a metadata update must not re-PUT the object on a target that mints its own ids"
);
}
target.shutdown().await;
Ok(())
}
fn single_target_version(target: &FakeS3Target, target_bucket: &str, key: &str) -> Result<String, Box<dyn Error + Send + Sync>> {
let versions = target.stored_versions(target_bucket, key);
match versions.as_slice() {
[(version_id, false)] => Ok(version_id.clone()),
other => Err(format!("{key}: expected exactly one live target version, got {other:?}").into()),
}
}
/// The target keeps holding exactly `version_id` for a few scanner cycles: a
/// re-driven PUT or a wrong delete would show up here.
async fn assert_stable_single_version(target: &FakeS3Target, target_bucket: &str, key: &str, version_id: &str) -> TestResult {
for _ in 0..8 {
let versions = target.stored_versions(target_bucket, key);
if versions.len() != 1 || versions[0].0 != version_id {
return Err(
format!("{key}: target versions drifted from the single expected replica {version_id}: {versions:?}").into(),
);
}
sleep(Duration::from_millis(500)).await;
}
Ok(())
}
async fn wait_until<F, Fut>(what: &str, mut probe: F) -> TestResult
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<bool, Box<dyn Error + Send + Sync>>>,
{
let wait = async {
loop {
if probe().await? {
return Ok::<_, Box<dyn Error + Send + Sync>>(());
}
sleep(Duration::from_millis(250)).await;
}
};
timeout(Duration::from_secs(90), wait)
.await
.map_err(|_| format!("{what} did not happen within 90 seconds"))?
}
/// Wait until `key` is COMPLETED on the source and, for the observation
/// window after that, the target still holds exactly one live version of it.
async fn wait_for_replication_status_and_single_version(
source_client: &Client,
source_bucket: &str,
target: &FakeS3Target,
target_bucket: &str,
key: &str,
) -> TestResult {
// The lost PUT response first settles the object FAILED; only the next
// scanner heal pass can turn that into COMPLETED, so FAILED is transient
// here and the wait is for COMPLETED alone.
let converged = async {
loop {
let head = source_client.head_object().bucket(source_bucket).key(key).send().await?;
if head.replication_status().is_some_and(|status| status.as_str() == "COMPLETED") {
return Ok::<_, Box<dyn Error + Send + Sync>>(());
}
sleep(Duration::from_millis(250)).await;
}
};
timeout(Duration::from_secs(90), converged)
.await
.map_err(|_| format!("{key}: heal re-drive did not converge to COMPLETED within 90 seconds"))??;
// The heal pass keeps visiting the key for a few scanner cycles; a
// duplicate would show up here as a second stored version.
for _ in 0..12 {
let versions = target.stored_versions(target_bucket, key);
assert_eq!(versions.len(), 1, "{key}: target minted another version on re-drive: {versions:?}");
sleep(Duration::from_millis(500)).await;
}
Ok(())
}
#[tokio::test] #[tokio::test]
async fn matrix_baseline_target() -> TestResult { async fn matrix_baseline_target() -> TestResult {
run_row(TargetMode::Baseline).await run_row(TargetMode::Baseline).await
@@ -270,11 +766,15 @@ async fn run_row(mode: TargetMode) -> TestResult {
target.create_bucket_with_object_lock(target_bucket.clone()); target.create_bucket_with_object_lock(target_bucket.clone());
mode.apply(&target); mode.apply(&target);
let mut source_env = RustFSTestEnvironment::new().await?;
let mut env_vars = replication_fast_env(); let mut env_vars = replication_fast_env();
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV); env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]); env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
source_env.start_rustfs_server_with_env(vec![], &env_vars).await?; let env = OdmTestEnv::start_with(OdmEnvOptions {
env: env_vars,
..OdmEnvOptions::default()
})
.await?;
let source_env = &env.rustfs;
let source_bucket = format!("matrix-{}-src", mode.slug()); let source_bucket = format!("matrix-{}-src", mode.slug());
let source_client = source_env.create_s3_client(); let source_client = source_env.create_s3_client();
@@ -284,9 +784,9 @@ async fn run_row(mode: TargetMode) -> TestResult {
.object_lock_enabled_for_bucket(true) .object_lock_enabled_for_bucket(true)
.send() .send()
.await?; .await?;
enable_bucket_versioning(&source_env, &source_bucket).await?; enable_bucket_versioning(source_env, &source_bucket).await?;
let target_arn = set_replication_target_with_options( let target_arn = set_replication_target_with_options(
&source_env, source_env,
&source_bucket, &source_bucket,
ReplicationTargetOptions { ReplicationTargetOptions {
endpoint: &target.address(), endpoint: &target.address(),
@@ -299,14 +799,21 @@ async fn run_row(mode: TargetMode) -> TestResult {
}, },
) )
.await?; .await?;
put_bucket_replication(&source_env, &source_bucket, &target_arn).await?; put_bucket_replication(source_env, &source_bucket, &target_arn).await?;
let target_client = fake_source_client(&target); let target_client = fake_source_client(&target);
let mut failures = Vec::new(); let mut failures = Vec::new();
for shape in ObjectShape::ALL { for shape in ObjectShape::ALL {
let cell = format!("{}/{:?}", mode.slug(), shape); let cell = format!("{}/{:?}", mode.slug(), shape);
let expected_body = shape.put(&source_client, &source_bucket).await?; let expected_body = shape.put(&env, &source_bucket).await?;
let status = wait_for_terminal_replication_status(&source_client, &source_bucket, shape.key()).await?; let status = wait_for_terminal_replication_status(&source_client, &source_bucket, shape.key()).await?;
if shape == ObjectShape::OdmPreservedMd5Multipart {
assert_eq!(
env.source.count_requests(FakeTargetOperation::GetObject, shape.key()),
2,
"one passthrough GET plus one background pull; replication must read the persisted local parts"
);
}
let journal = target.requests(); let journal = target.requests();
let outcome = match expectation(mode, shape) { let outcome = match expectation(mode, shape) {
Expectation::Completed => { Expectation::Completed => {
@@ -379,6 +886,36 @@ async fn check_completed_cell(
if uploads.is_empty() { if uploads.is_empty() {
return Err("no upload reached the target although the source reports COMPLETED".into()); return Err("no upload reached the target although the source reports COMPLETED".into());
} }
if shape == ObjectShape::OdmPreservedMd5Multipart {
let key_requests: Vec<_> = journal
.iter()
.filter(|record| record.key.as_deref() == Some(shape.key()))
.collect();
for operation in [
FakeTargetOperation::CreateMultipartUpload,
FakeTargetOperation::CompleteMultipartUpload,
] {
if !key_requests.iter().any(|record| record.operation == operation) {
return Err(format!("preserved-MD5 multipart object did not use {operation:?}").into());
}
}
if key_requests
.iter()
.any(|record| record.operation == FakeTargetOperation::PutObject)
{
return Err("preserved-MD5 multipart object used a single PutObject".into());
}
let mut part_numbers: Vec<_> = key_requests
.iter()
.filter(|record| record.operation == FakeTargetOperation::UploadPart)
.map(|record| record.part_number)
.collect();
part_numbers.sort_unstable();
part_numbers.dedup();
if part_numbers != [Some(1), Some(2)] {
return Err(format!("preserved-MD5 multipart object uploaded unexpected parts: {part_numbers:?}").into());
}
}
if let Some(framed) = uploads.iter().find(|record| record.transport.aws_chunked) { if let Some(framed) = uploads.iter().find(|record| record.transport.aws_chunked) {
return Err(format!("{cell}: an upload went out aws-chunked (rustfs#6853 framing): {framed:?}").into()); return Err(format!("{cell}: an upload went out aws-chunked (rustfs#6853 framing): {framed:?}").into());
} }
@@ -401,6 +938,19 @@ async fn check_completed_cell(
}) { }) {
return Err(format!("a locked PutObject went out without any integrity header (rustfs#7082): {bare:?}").into()); return Err(format!("a locked PutObject went out without any integrity header (rustfs#7082): {bare:?}").into());
} }
// rustfs/backlog#2340 contract: a source checksum reaches the target as
// the `x-amz-checksum-*` header, not as user metadata; every PutObject of
// the shape carries it.
if let Some(header) = shape.forwarded_checksum_header()
&& let Some(missing) = uploads.iter().find(|record| {
record.operation == FakeTargetOperation::PutObject
&& !record.transport.checksum_headers.iter().any(|name| name == header)
})
{
return Err(
format!("a PutObject went out without the source's {header} header (rustfs/backlog#2340): {missing:?}").into(),
);
}
Ok(()) Ok(())
} }
@@ -455,6 +1005,71 @@ async fn wait_for_terminal_replication_status(
} }
} }
async fn odm_preserved_md5_multipart(env: &OdmTestEnv, bucket: &str, key: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
const PART_SIZE: usize = 5 * 1024 * 1024;
let origin_bucket = format!("{bucket}-origin");
env.source.create_bucket_with_mode(&origin_bucket, BucketMode::Unversioned);
let mut spec = env.fake_source_spec(&origin_bucket);
// Below the 16 MiB inline default the pull is one tee'd PUT with a single
// part; force the passthrough + background multipart write-back instead.
spec.policy.inline_max_bytes = 4096;
spec.policy.multipart_part_size_bytes = PART_SIZE as u64;
spec.policy.preserve_etag = true;
env.configure_and_wait(bucket, &spec).await?;
// A normal source PUT produces the MD5 ETag; only ODM chooses the local parts.
let body = payload(PART_SIZE + 4096, 0x66);
let source_put = env
.source_client()
.put_object()
.bucket(&origin_bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.send()
.await?;
let source_etag = source_put.e_tag().ok_or("source PUT omitted its ETag")?.trim_matches('"');
assert_eq!(source_etag.len(), 32, "source fixture must have a single-PUT MD5 ETag");
assert!(source_etag.bytes().all(|byte| byte.is_ascii_hexdigit()));
let pulled = env.raw_get(bucket, key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert_eq!(pulled.body, body);
assert!(
env.wait_local_listed(bucket, key, Duration::from_secs(30)).await?,
"ODM must persist the object"
);
let attributes = env
.client
.get_object_attributes()
.bucket(bucket)
.key(key)
.object_attributes(ObjectAttributes::Etag)
.object_attributes(ObjectAttributes::ObjectParts)
.object_attributes(ObjectAttributes::Checksum)
.send()
.await?;
assert_eq!(attributes.e_tag().map(|etag| etag.trim_matches('"')), Some(source_etag));
let parts = attributes
.object_parts()
.ok_or("the ODM copy must expose its two local parts")?;
assert_eq!(parts.total_parts_count(), Some(2));
assert_eq!(
parts
.parts()
.iter()
.map(|part| (part.part_number(), part.size()))
.collect::<Vec<_>>(),
[(Some(1), Some(PART_SIZE as i64)), (Some(2), Some(4096))]
);
assert!(
attributes
.checksum()
.is_none_or(|checksum| checksum == &Checksum::builder().build()),
"multipart routing must work without an object checksum record"
);
Ok(body)
}
async fn multipart_put( async fn multipart_put(
client: &Client, client: &Client,
bucket: &str, bucket: &str,
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -31,6 +31,7 @@ workspace = true
[features] [features]
default = [] default = []
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
# Compiles the controlled list-objects namespace-journal chaos injector into a # Compiles the controlled list-objects namespace-journal chaos injector into a
# production binary (it is always available to tests). Off by default so the # production binary (it is always available to tests). Off by default so the
# RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal # RUSTFS_LIST_OBJECTS_NAMESPACE_JOURNAL_CHAOS_* env vars cannot rewrite journal
@@ -212,8 +213,8 @@ aws-smithy-runtime-api = { workspace = true, features = ["http-1x"] }
parking_lot = { workspace = true } parking_lot = { workspace = true }
base64-simd.workspace = true base64-simd.workspace = true
serde_urlencoded.workspace = true serde_urlencoded.workspace = true
google-cloud-storage = { workspace = true } google-cloud-storage = { workspace = true, optional = true }
google-cloud-auth = { workspace = true } google-cloud-auth = { workspace = true, optional = true }
faster-hex = { workspace = true } faster-hex = { workspace = true }
ratelimit = { workspace = true } ratelimit = { workspace = true }
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] } aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
@@ -225,7 +226,7 @@ metrics = { workspace = true }
# crates.io. The guard scripts/check_no_tokio_io_uring.sh allows an explicit # crates.io. The guard scripts/check_no_tokio_io_uring.sh allows an explicit
# io-uring integration; only the tokio "io-uring" runtime feature is banned. # io-uring integration; only the tokio "io-uring" runtime feature is banned.
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
rustfs-uring = "0.2.1" rustfs-uring = "0.2.2"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winapi-util.workspace = true winapi-util.workspace = true
@@ -244,6 +245,7 @@ windows-sys = { workspace = true, features = [
windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] } windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] }
[dev-dependencies] [dev-dependencies]
aws-smithy-async.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
criterion = { workspace = true, features = ["html_reports"] } criterion = { workspace = true, features = ["html_reports"] }
temp-env = { workspace = true, features = ["async_closure"] } temp-env = { workspace = true, features = ["async_closure"] }
+64 -70
View File
@@ -32,7 +32,7 @@ pub mod bucket {
pub mod bucket_target_sys { pub mod bucket_target_sys {
pub use crate::bucket::bucket_target_sys::{ pub use crate::bucket::bucket_target_sys::{
AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError, AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError,
SsecPassthroughCapability, TargetClient, append_version_id_query, SsecPassthroughCapability, TargetClient, VersionIdentityCapability, append_version_id_query,
}; };
} }
@@ -69,11 +69,33 @@ pub mod bucket {
}; };
} }
pub mod recovery_control {
pub use crate::bucket::lifecycle::recovery_control::{
IlmRecoveryClassification, IlmRecoveryControlPage, IlmRecoveryControlView, IlmRecoveryProtocol,
inspect_recovery_control, list_recovery_controls,
};
}
pub mod recovery_disposition {
pub use crate::bucket::lifecycle::recovery_disposition::{
IlmRecoveryDispositionExecutionOutcome, IlmRecoveryDispositionReasonCode, IlmRecoveryDispositionState,
dry_run_recovery_disposition, execute_recovery_disposition,
};
}
pub mod recovery_export {
pub use crate::bucket::lifecycle::recovery_export::{
IlmRecoveryExportCreated, IlmRecoveryExportObservation, create_recovery_export,
inspect_recovery_export_observation, load_recovery_export,
};
}
pub mod transition_transaction { pub mod transition_transaction {
pub use crate::bucket::lifecycle::transition_transaction::{ pub use crate::bucket::lifecycle::transition_transaction::{
TransitionOperatorDeleteResult, TransitionOperatorError, TransitionOperatorProbe, TransitionOperatorStatus, TransitionOperatorDeleteResult, TransitionOperatorError, TransitionOperatorProbe, TransitionOperatorStatus,
delete_transition_candidate_for_operator, finalize_missing_transition_transaction_for_operator, TransitionRecoveryRetryResult, TransitionRecoveryRetryStatus, delete_transition_candidate_for_operator,
inspect_transition_transaction_for_operator, finalize_missing_transition_transaction_for_operator, inspect_transition_recovery_retry_for_operator,
inspect_transition_transaction_for_operator, retry_transition_recovery_for_operator,
}; };
#[cfg(feature = "test-util")] #[cfg(feature = "test-util")]
pub use crate::bucket::lifecycle::transition_transaction::{ pub use crate::bucket::lifecycle::transition_transaction::{
@@ -89,8 +111,9 @@ pub mod bucket {
#[allow(clippy::module_inception)] #[allow(clippy::module_inception)]
pub mod lifecycle { pub mod lifecycle {
pub use crate::bucket::lifecycle::lifecycle::{ pub use crate::bucket::lifecycle::lifecycle::{
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate, Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate,
TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time, object_opts_from_object_info, ObjectOpts, RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, expected_expiry_time,
object_opts_from_object_info,
}; };
} }
@@ -146,66 +169,25 @@ pub mod bucket {
}; };
} }
pub mod on_demand_migration {
pub use crate::bucket::on_demand_migration::{
ApplyOutcome, BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION,
Breaker, BreakerState, BreakerTransition, BreakerVerdict, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, GaugeGuard,
LastSourceError, LatencyBucketSnapshot, NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache, OdmBucketSnapshot, OdmLookup,
OdmOp, OdmOutcome, OdmStateError, OdmStats, OdmStatsSnapshot, OnDemandMigrationSys, PullError, PullFailureReason,
PullFollower, PullLeader, PullOutcome, PullPath, PullResult, PullSlot, SOURCE_LATENCY_BUCKET_BOUNDS_MS,
SourceLatencySnapshot, source_client_spec,
};
pub use crate::bucket::on_demand_migration::{
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy,
SourceConfig, SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
};
pub use crate::bucket::on_demand_migration::{
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody,
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
idle_guarded_body,
};
pub use crate::bucket::on_demand_migration::{
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
};
pub mod backfill {
pub use crate::bucket::on_demand_migration::backfill::{
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
BACKFILL_LEASE_LOCK_PREFIX, BACKFILL_LIST_PAGE_SIZE, BACKFILL_RECOVERY_INTERVAL, BACKFILL_SAVE_EVERY_KEYS,
BACKFILL_SAVE_INTERVAL, BackfillCheckpoint, BackfillContext, BackfillContextFactory, BackfillError,
BackfillLastError, BackfillOwner, BackfillRecoveryStats, BackfillRequest, BackfillRunner, BackfillState,
BucketBackfillContext, LocalBackfillObject, PriorityPullPermits, PullPermit, PullPriority, SkipExisting,
StoredCheckpoint, SysBackfillContexts, global_backfill_runner, install_global_backfill_runner, key_hash,
read_checkpoint, run_backfill_recovery_loop, spawn_backfill_recovery_loop,
};
}
pub mod source_client {
pub use crate::bucket::on_demand_migration::source_client::{
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
SourceProbe, SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
resolve_path_style,
};
}
}
pub mod metadata_sys { pub mod metadata_sys {
#[cfg(feature = "test-util")]
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
pub use crate::bucket::metadata_sys::{ pub use crate::bucket::metadata_sys::{
BucketMetadataMutationGuard, BucketMetadataSys, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock, BUCKET_CONFIG_PUBLISH_HOOK, BucketConfigPublishHook, BucketMetadataMutationGuard, BucketMetadataSys,
acquire_bucket_metadata_transaction_lock_for_incarnation, capture_bucket_metadata_incarnation, delete, ObjectLockConfigState, acquire_bucket_metadata_transaction_lock,
delete_if_incarnation, delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy, acquire_bucket_metadata_transaction_lock_for_incarnation, acquire_scanner_bucket_incarnation_fence,
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config, capture_bucket_metadata_incarnation, delete, delete_if_incarnation, delete_if_incarnation_at,
delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy, get_bucket_policy_raw,
get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config, get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config, get_public_access_block_config, get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config,
get_quota_config, get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_on_demand_migration_config_in, get_public_access_block_config, get_quota_config, get_replication_config,
get_versioning_config, get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
remove_bucket_metadata, set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
update_config_with, update_if_incarnation, update_quota_if_incarnation, update_under_transaction_lock, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
update_if_incarnation_at, update_quota_if_incarnation, update_quota_if_incarnation_at, update_under_transaction_lock,
update_under_transaction_lock_at,
}; };
#[cfg(feature = "test-util")]
pub use crate::bucket::metadata_sys::{ConfigWriteLockProbe, test_support};
} }
pub mod migration { pub mod migration {
@@ -248,7 +230,7 @@ pub mod bucket {
pub mod remote_s3_client { pub mod remote_s3_client {
pub use crate::bucket::remote_s3_client::{ pub use crate::bucket::remote_s3_client::{
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client, PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
validate_remote_endpoint, build_remote_s3_config, validate_remote_endpoint, validate_target_ca_pem,
}; };
} }
@@ -326,7 +308,7 @@ pub mod cache {
pub mod capacity { pub mod capacity {
pub use crate::core::pools::{ pub use crate::core::pools::{
DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free, DecommissionUnresolvedEntry, PoolDecommissionInfo, PoolStatus, get_total_usable_capacity, get_total_usable_capacity_free,
path2_bucket_object, path2_bucket_object_with_base_path, 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; pub use crate::store::utils::is_reserved_or_invalid_bucket;
} }
@@ -479,9 +461,14 @@ pub mod notification {
#[cfg(any(test, feature = "test-util"))] #[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::rotate_cross_pool_fence_fleet_proof_for_test;
pub use crate::services::notification_sys::{ pub use crate::services::notification_sys::{
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant, ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys, LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe, 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,
}; };
} }
@@ -492,9 +479,9 @@ pub mod object {
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission, ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest, RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError, SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
ScannerPublicationCommitState, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, ScannerPublicationCommitState, StreamConsumer, WriteCompletion, get_object_body_cache_plaintext_len,
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
unregister_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
}; };
pub use crate::store::{ pub use crate::store::{
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError, PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
@@ -532,7 +519,8 @@ pub mod rpc {
pub use crate::cluster::rpc::{ pub use crate::cluster::rpc::{
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
ScannerBucketListing, ScannerPeerActivity, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, TONIC_RPC_PREFIX, ScannerBucketListing, ScannerDirtyUsageAcknowledgement, ScannerPeerActivity, ScannerPeerDirtyUsageBucket,
ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, ScannerScopedDirtyUsageAckEntry, TONIC_RPC_PREFIX,
TonicInterceptor, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options, TonicInterceptor, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers, encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth, gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth,
@@ -558,6 +546,12 @@ pub mod set_disk {
pub mod test_util { pub mod test_util {
pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test; pub use crate::bucket::quota::reservation::fail_next_quota_ledger_save_for_test;
pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause}; pub use crate::set_disk::{MultipartCommitBarrier, MultipartCommitPause, PutObjectCommitBarrier, PutObjectCommitPause};
/// Keep a namespace commit pending until the returned owner is dropped.
#[must_use]
pub fn hold_namespace_commit(store: &crate::store::ECStore) -> impl Send + Sync {
store.ctx.begin_namespace_commit()
}
} }
} }
+508 -30
View File
@@ -18,7 +18,7 @@ use crate::bucket::metadata_sys::get_replication_config;
use crate::bucket::remote_s3_client::{ use crate::bucket::remote_s3_client::{
PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, build_remote_s3_client, PathStyle, REPLICATION_TARGET_RETRY_POLICY, RemoteCredentials, RemoteS3EndpointSpec, build_remote_s3_client,
}; };
use crate::bucket::replication::{ObjectLockIntegrity, object_lock_put_integrity}; use crate::bucket::replication::{ObjectLockIntegrity, object_lock_put_integrity, replication_etags_match};
use crate::bucket::replication::{ReplicationStatusType, ReplicationTargetConfigBridge}; use crate::bucket::replication::{ReplicationStatusType, ReplicationTargetConfigBridge};
use crate::bucket::target::ARN; use crate::bucket::target::ARN;
use crate::bucket::target::BucketTargetType; use crate::bucket::target::BucketTargetType;
@@ -33,6 +33,8 @@ use aws_sdk_s3::operation::get_object::{GetObjectError, GetObjectOutput};
use aws_sdk_s3::operation::get_object_tagging::{GetObjectTaggingError, GetObjectTaggingOutput}; use aws_sdk_s3::operation::get_object_tagging::{GetObjectTaggingError, GetObjectTaggingOutput};
use aws_sdk_s3::operation::head_bucket::HeadBucketError; use aws_sdk_s3::operation::head_bucket::HeadBucketError;
use aws_sdk_s3::operation::head_object::HeadObjectError; use aws_sdk_s3::operation::head_object::HeadObjectError;
use aws_sdk_s3::operation::put_object_legal_hold::{PutObjectLegalHoldError, PutObjectLegalHoldOutput};
use aws_sdk_s3::operation::put_object_retention::{PutObjectRetentionError, PutObjectRetentionOutput};
use aws_sdk_s3::operation::put_object_tagging::{PutObjectTaggingError, PutObjectTaggingOutput}; use aws_sdk_s3::operation::put_object_tagging::{PutObjectTaggingError, PutObjectTaggingOutput};
use aws_sdk_s3::operation::upload_part::UploadPartOutput; use aws_sdk_s3::operation::upload_part::UploadPartOutput;
use aws_sdk_s3::primitives::ByteStream; use aws_sdk_s3::primitives::ByteStream;
@@ -42,6 +44,7 @@ use aws_sdk_s3::types::{
ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockRetentionMode, ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
ServerSideEncryption, ServerSideEncryption,
}; };
use aws_sdk_s3::types::{ObjectLockLegalHold, ObjectLockRetention};
use aws_sdk_s3::{Client as S3Client, operation::head_object::HeadObjectOutput}; use aws_sdk_s3::{Client as S3Client, operation::head_object::HeadObjectOutput};
use aws_smithy_runtime_api::client::orchestrator::HttpRequest; use aws_smithy_runtime_api::client::orchestrator::HttpRequest;
use futures::{StreamExt, stream}; use futures::{StreamExt, stream};
@@ -59,7 +62,7 @@ use rustfs_utils::http::{
insert_header, insert_header,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::error::Error; use std::error::Error;
use std::fmt; use std::fmt;
use std::str::FromStr as _; use std::str::FromStr as _;
@@ -126,6 +129,25 @@ impl From<&BucketTarget> for RemoteS3EndpointSpec {
} }
pub type HeadObjectSdkError = Box<SdkError<HeadObjectError>>; pub type HeadObjectSdkError = Box<SdkError<HeadObjectError>>;
/// Whether an edited bucket target still addresses the same remote service
/// (endpoint, bucket, path style, TLS and identity), so a verdict learned
/// about that service stays valid across the edit.
fn same_replication_service(edited: &BucketTarget, previous: &BucketTarget) -> bool {
let access_key = |target: &BucketTarget| target.credentials.as_ref().map(|credentials| credentials.access_key.clone());
edited.endpoint == previous.endpoint
&& edited.target_bucket == previous.target_bucket
&& edited.secure == previous.secure
&& edited.path == previous.path
&& access_key(edited) == access_key(previous)
}
/// Page size and page budget for [`TargetClient::locate_replica_by_etag`].
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.
const FIND_VERSION_BY_ETAG_MAX_MATCHES: usize = 16;
pub type GetObjectSdkError = Box<SdkError<GetObjectError>>; pub type GetObjectSdkError = Box<SdkError<GetObjectError>>;
pub type GetObjectTaggingSdkError = Box<SdkError<GetObjectTaggingError>>; pub type GetObjectTaggingSdkError = Box<SdkError<GetObjectTaggingError>>;
pub type PutObjectTaggingSdkError = Box<SdkError<PutObjectTaggingError>>; pub type PutObjectTaggingSdkError = Box<SdkError<PutObjectTaggingError>>;
@@ -349,6 +371,13 @@ struct TargetClientBuildProbe {
/// their import path while the verdict vocabulary lives with the /// their import path while the verdict vocabulary lives with the
/// replication decision logic. /// replication decision logic.
pub use crate::bucket::replication::SsecPassthroughCapability; pub use crate::bucket::replication::SsecPassthroughCapability;
/// Version-identity verdicts (see the enum's own docs in
/// `rustfs-replication`) are cached here per target ARN and follow the same
/// `arn_remotes_map` lifecycle. They carry no TTL: the verdict is refreshed
/// by every replication write's response, so it can only go stale on a
/// target that receives no writes — and a stale `MintsOwn` costs one extra
/// content-identity lookup before a PUT, never a lost replica.
pub use crate::bucket::replication::VersionIdentityCapability;
/// How long an audited SSE-C passthrough verdict stays authoritative. /// How long an audited SSE-C passthrough verdict stays authoritative.
/// ///
@@ -375,7 +404,17 @@ pub struct BucketTargetSys {
/// SSE-C passthrough capability verdicts keyed by target ARN. See /// SSE-C passthrough capability verdicts keyed by target ARN. See
/// [`SsecPassthroughCapability`]; reset alongside `arn_remotes_map`. /// [`SsecPassthroughCapability`]; reset alongside `arn_remotes_map`.
ssec_passthrough_map: Arc<RwLock<HashMap<String, SsecPassthroughRecord>>>, ssec_passthrough_map: Arc<RwLock<HashMap<String, SsecPassthroughRecord>>>,
/// Version-identity verdicts keyed by target ARN. See
/// [`VersionIdentityCapability`]; reset alongside `arn_remotes_map`. A std
/// lock (never held across an await) so the replication worker can record
/// a verdict from inside its synchronous PUT-response audit.
version_identity_map: Arc<std::sync::RwLock<HashMap<String, VersionIdentityCapability>>>,
pub targets_map: Arc<RwLock<HashMap<String, Vec<BucketTarget>>>>, pub targets_map: Arc<RwLock<HashMap<String, Vec<BucketTarget>>>>,
/// Buckets whose persisted `bucket-targets.json` exists but cannot be
/// decoded (rustfs/backlog#2282). Written under the bucket's update mutex
/// alongside `targets_map`, and read before it so an unreadable
/// configuration surfaces as a typed error instead of an empty target set.
unreadable_targets: Arc<RwLock<HashSet<String>>>,
pub h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>, pub h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>,
target_h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>, target_h_mutex: Arc<RwLock<HashMap<String, EpHealth>>>,
pub hc_client: Arc<HttpClient>, pub hc_client: Arc<HttpClient>,
@@ -418,7 +457,9 @@ impl BucketTargetSys {
Self { Self {
arn_remotes_map: Arc::new(RwLock::new(HashMap::new())), arn_remotes_map: Arc::new(RwLock::new(HashMap::new())),
ssec_passthrough_map: Arc::new(RwLock::new(HashMap::new())), ssec_passthrough_map: Arc::new(RwLock::new(HashMap::new())),
version_identity_map: Arc::new(std::sync::RwLock::new(HashMap::new())),
targets_map: Arc::new(RwLock::new(HashMap::new())), targets_map: Arc::new(RwLock::new(HashMap::new())),
unreadable_targets: Arc::new(RwLock::new(HashSet::new())),
h_mutex: Arc::new(RwLock::new(HashMap::new())), h_mutex: Arc::new(RwLock::new(HashMap::new())),
target_h_mutex: Arc::new(RwLock::new(HashMap::new())), target_h_mutex: Arc::new(RwLock::new(HashMap::new())),
hc_client: Arc::new(build_health_check_client()), hc_client: Arc::new(build_health_check_client()),
@@ -628,12 +669,19 @@ impl BucketTargetSys {
health_map.clone() health_map.clone()
} }
pub async fn list_targets(&self, bucket: &str, arn_type: &str) -> Vec<BucketTarget> { /// Targets of one bucket, or of every bucket when `bucket` is empty.
///
/// A bucket that simply has no targets yields an empty list; a bucket
/// whose persisted configuration cannot be decoded is an error, so an
/// admin listing reports the fault instead of an empty list that reads as
/// "replication is not configured" (rustfs/backlog#2282).
pub async fn list_targets(&self, bucket: &str, arn_type: &str) -> Result<Vec<BucketTarget>, BucketTargetError> {
let health_stats = self.target_health_stats().await; let health_stats = self.target_health_stats().await;
let mut targets = Vec::new(); let mut targets = Vec::new();
if !bucket.is_empty() { if !bucket.is_empty() {
if let Ok(bucket_targets) = self.list_bucket_targets(bucket).await { match self.list_bucket_targets(bucket).await {
Ok(bucket_targets) => {
for mut target in bucket_targets.targets { for mut target in bucket_targets.targets {
if arn_type.is_empty() || target.target_type.to_string() == arn_type { if arn_type.is_empty() || target.target_type.to_string() == arn_type {
if let Some(health) = health_stats.get(&target.arn) { if let Some(health) = health_stats.get(&target.arn) {
@@ -651,7 +699,10 @@ impl BucketTargetSys {
} }
} }
} }
return targets; Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => {}
Err(err) => return Err(err),
}
return Ok(targets);
} }
let targets_map = self.targets_map.read().await; let targets_map = self.targets_map.read().await;
@@ -674,10 +725,16 @@ impl BucketTargetSys {
} }
} }
targets Ok(targets)
} }
pub async fn list_bucket_targets(&self, bucket: &str) -> Result<BucketTargets, BucketTargetError> { pub async fn list_bucket_targets(&self, bucket: &str) -> Result<BucketTargets, BucketTargetError> {
if self.unreadable_targets.read().await.contains(bucket) {
return Err(BucketTargetError::BucketRemoteTargetsUnreadable {
bucket: bucket.to_string(),
});
}
let targets_map = self.targets_map.read().await; let targets_map = self.targets_map.read().await;
if let Some(targets) = targets_map.get(bucket) { if let Some(targets) = targets_map.get(bucket) {
Ok(BucketTargets { Ok(BucketTargets {
@@ -690,13 +747,30 @@ impl BucketTargetSys {
} }
} }
/// Record that this bucket's persisted targets configuration exists but
/// cannot be decoded (rustfs/backlog#2282).
///
/// Any snapshot published from an earlier readable load is deliberately
/// left in place: withdrawing it would produce exactly the silent "no
/// targets configured" state this marker exists to prevent. The marker is
/// cleared by the next successful publish, which is what makes a repaired
/// configuration take effect without a restart.
pub async fn mark_targets_unreadable(&self, bucket: &str) {
let update_mutex = self.target_update_mutex(bucket).await;
let _update_guard = update_mutex.lock().await;
self.unreadable_targets.write().await.insert(bucket.to_string());
}
pub async fn delete(&self, bucket: &str) { pub async fn delete(&self, bucket: &str) {
let update_mutex = self.target_update_mutex(bucket).await; let update_mutex = self.target_update_mutex(bucket).await;
let _update_guard = update_mutex.lock().await; let _update_guard = update_mutex.lock().await;
// Lock order: targets_map, then arn_remotes_map, then target_h_mutex, // Lock order: unreadable_targets, then targets_map, then
// then ssec_passthrough_map (always last; also taken standalone by the // arn_remotes_map, then target_h_mutex, then ssec_passthrough_map
// capability accessors). // (always last; also taken standalone by the capability accessors).
self.unreadable_targets.write().await.remove(bucket);
let mut targets_map = self.targets_map.write().await; let mut targets_map = self.targets_map.write().await;
let mut arn_remotes_map = self.arn_remotes_map.write().await; let mut arn_remotes_map = self.arn_remotes_map.write().await;
let mut health_map = self.target_h_mutex.write().await; let mut health_map = self.target_h_mutex.write().await;
@@ -707,10 +781,40 @@ impl BucketTargetSys {
arn_remotes_map.remove(&target.arn); arn_remotes_map.remove(&target.arn);
health_map.remove(&target.arn); health_map.remove(&target.arn);
ssec_map.remove(&target.arn); ssec_map.remove(&target.arn);
self.forget_version_identity_capability(&target.arn);
} }
} }
} }
/// Cached version-identity verdict for a target ARN; `Unknown` until a
/// replication write or a replication-check VersionFidelity probe judged
/// it since the target was built.
pub fn version_identity_capability(&self, arn: &str) -> VersionIdentityCapability {
self.version_identity_map
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.get(arn)
.copied()
.unwrap_or_default()
}
/// Record a version-identity verdict for a target ARN. Written by the
/// replication worker after every PutObject / CompleteMultipartUpload
/// response and by the replication-check VersionFidelity phase.
pub fn record_version_identity_capability(&self, arn: &str, capability: VersionIdentityCapability) {
self.version_identity_map
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(arn.to_string(), capability);
}
fn forget_version_identity_capability(&self, arn: &str) {
self.version_identity_map
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(arn);
}
/// Cached SSE-C passthrough capability for a target ARN, plus whether the /// Cached SSE-C passthrough capability for a target ARN, plus whether the
/// verdict is older than [`SSEC_PASSTHROUGH_CAPABILITY_TTL`]. `(Unknown, /// verdict is older than [`SSEC_PASSTHROUGH_CAPABILITY_TTL`]. `(Unknown,
/// false)` when no verdict has been recorded since the target was built. /// false)` when no verdict has been recorded since the target was built.
@@ -755,17 +859,24 @@ impl BucketTargetSys {
) -> Result<BucketTargets, BucketTargetError> { ) -> Result<BucketTargets, BucketTargetError> {
self.validate_target(bucket, target).await?; self.validate_target(bucket, target).await?;
let mut bucket_targets = match self.list_bucket_targets(bucket).await { let mut bucket_targets = self.targets_base_for_write(bucket).await?;
Ok(targets) => targets,
Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => BucketTargets::default(),
Err(err) => return Err(err),
};
Self::upsert_target_entry(&mut bucket_targets.targets, target, update)?; Self::upsert_target_entry(&mut bucket_targets.targets, target, update)?;
Ok(bucket_targets) Ok(bucket_targets)
} }
/// Ordinary writes must not turn an unreadable cached snapshot into an
/// empty configuration. Explicit repair belongs to the metadata transaction
/// that can inspect the current persisted state.
async fn targets_base_for_write(&self, bucket: &str) -> Result<BucketTargets, BucketTargetError> {
match self.list_bucket_targets(bucket).await {
Ok(targets) => Ok(targets),
Err(BucketTargetError::BucketRemoteTargetNotFound { .. }) => Ok(BucketTargets::default()),
Err(err) => Err(err),
}
}
pub async fn validate_target(&self, bucket: &str, target: &BucketTarget) -> Result<(), BucketTargetError> { pub async fn validate_target(&self, bucket: &str, target: &BucketTarget) -> Result<(), BucketTargetError> {
if !target.target_type.is_valid() { if !target.target_type.is_valid() {
return Err(BucketTargetError::BucketRemoteArnTypeInvalid { return Err(BucketTargetError::BucketRemoteArnTypeInvalid {
@@ -824,7 +935,9 @@ impl BucketTargetSys {
Ok(()) Ok(())
} }
fn upsert_target_entry( /// Merge a validated target into a caller-owned snapshot. The caller must
/// protect that snapshot through persistence.
pub fn upsert_target_entry(
bucket_targets: &mut Vec<BucketTarget>, bucket_targets: &mut Vec<BucketTarget>,
target: &BucketTarget, target: &BucketTarget,
update: bool, update: bool,
@@ -1093,6 +1206,11 @@ impl BucketTargetSys {
/// Keeping persisted-config reads under the same mutex prevents a stale /// Keeping persisted-config reads under the same mutex prevents a stale
/// reload from overwriting a concurrent credential rotation. /// reload from overwriting a concurrent credential rotation.
async fn update_all_targets_locked(&self, bucket: &str, targets: Option<&BucketTargets>) { async fn update_all_targets_locked(&self, bucket: &str, targets: Option<&BucketTargets>) {
// Reaching here means the persisted configuration decoded, so the
// unreadable marker (if any) is stale. Cleared before the maps below
// so `unreadable_targets` stays the outermost of this module's locks.
self.unreadable_targets.write().await.remove(bucket);
let mut clients = Vec::new(); let mut clients = Vec::new();
if let Some(new_targets) = targets { if let Some(new_targets) = targets {
for target in &new_targets.targets { for target in &new_targets.targets {
@@ -1100,21 +1218,41 @@ impl BucketTargetSys {
} }
} }
// Lock order: targets_map, then arn_remotes_map, then target_h_mutex, // Lock order: unreadable_targets (above), then targets_map, then
// then ssec_passthrough_map (always last; also taken standalone by the // arn_remotes_map, then target_h_mutex, then ssec_passthrough_map
// capability accessors). // (always last; also taken standalone by the capability accessors).
let mut targets_map = self.targets_map.write().await; let mut targets_map = self.targets_map.write().await;
let mut arn_remotes_map = self.arn_remotes_map.write().await; let mut arn_remotes_map = self.arn_remotes_map.write().await;
let mut health_map = self.target_h_mutex.write().await; let mut health_map = self.target_h_mutex.write().await;
// Remove existing targets // Remove existing targets
if let Some(existing_targets) = targets_map.remove(bucket) { if let Some(existing_targets) = targets_map.remove(bucket) {
let mut ssec_map = self.ssec_passthrough_map.write().await; let mut ssec_map = self.ssec_passthrough_map.write().await;
let unchanged_service: HashMap<&str, &BucketTarget> = targets
.map(|new_targets| {
new_targets
.targets
.iter()
.map(|target| (target.arn.as_str(), target))
.collect()
})
.unwrap_or_default();
for target in existing_targets { for target in existing_targets {
arn_remotes_map.remove(&target.arn); arn_remotes_map.remove(&target.arn);
health_map.remove(&target.arn); health_map.remove(&target.arn);
// A rebuilt/edited target may point at a different service: // A rebuilt/edited target may point at a different service:
// the SSE-C passthrough verdict must be re-audited from Unknown. // the SSE-C passthrough verdict must be re-audited from Unknown.
ssec_map.remove(&target.arn); ssec_map.remove(&target.arn);
// The version-identity verdict survives an edit that keeps the
// same remote service (a resync start or a bandwidth change
// rewrites the entry in place): forgetting it there would make
// the very resync that follows re-drive every object as a
// duplicate on a target that mints its own version ids.
if unchanged_service
.get(target.arn.as_str())
.is_none_or(|edited| !same_replication_service(edited, &target))
{
self.forget_version_identity_capability(&target.arn);
}
self.update_bandwidth_limit(bucket, &target.arn, 0); self.update_bandwidth_limit(bucket, &target.arn, 0);
} }
} }
@@ -1161,6 +1299,11 @@ impl BucketTargetSys {
} }
pub async fn set(&self, bucket: &str, meta: &BucketMetadata) { pub async fn set(&self, bucket: &str, meta: &BucketMetadata) {
if meta.bucket_targets_unreadable() {
self.mark_targets_unreadable(bucket).await;
return;
}
let Some(config) = &meta.bucket_target_config else { let Some(config) = &meta.bucket_target_config else {
return; return;
}; };
@@ -1178,9 +1321,13 @@ impl BucketTargetSys {
return (String::new(), false); return (String::new(), false);
}; };
{
let targets_map = self.targets_map.read().await; let targets_map = self.targets_map.read().await;
if let Some(targets) = targets_map.get(bucket) { let targets = targets_map.get(bucket).map(Vec::as_slice).unwrap_or_default();
Self::remote_arn_for_targets(targets, target, depl_id)
}
/// Resolve create idempotency against the snapshot the caller will persist.
pub fn remote_arn_for_targets(targets: &[BucketTarget], target: &BucketTarget, depl_id: &str) -> (String, bool) {
for tgt in targets { for tgt in targets {
if tgt.target_type == target.target_type if tgt.target_type == target.target_type
&& tgt.target_bucket == target.target_bucket && tgt.target_bucket == target.target_bucket
@@ -1197,8 +1344,6 @@ impl BucketTargetSys {
return (tgt.arn.clone(), true); return (tgt.arn.clone(), true);
} }
} }
}
}
if !target.target_type.is_valid() { if !target.target_type.is_valid() {
return (String::new(), false); return (String::new(), false);
@@ -1224,6 +1369,7 @@ fn generate_arn(t: &BucketTarget, depl_id: &str) -> String {
arn.to_string() arn.to_string()
} }
#[derive(Debug, Clone)]
pub struct RemoveObjectOptions { pub struct RemoveObjectOptions {
pub force_delete: bool, pub force_delete: bool,
pub governance_bypass: bool, pub governance_bypass: bool,
@@ -1832,6 +1978,125 @@ impl TargetClient {
.map_err(Box::new) .map_err(Box::new)
} }
/// 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
/// [`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.
///
/// 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(
&self,
bucket: &str,
object: &str,
source_etag: &str,
) -> Result<Vec<String>, 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();
for _ in 0..FIND_VERSION_BY_ETAG_MAX_PAGES {
let page = self
.client
.list_object_versions()
.bucket(bucket)
.prefix(object)
.max_keys(FIND_VERSION_BY_ETAG_PAGE_SIZE)
.set_key_marker(key_marker.take())
.set_version_id_marker(version_id_marker.take())
.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)),
);
// 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
|| page
.versions()
.iter()
.any(|version| version.key().is_some_and(|key| key > object))
|| !page.is_truncated().unwrap_or(false)
{
break;
}
key_marker = page.next_key_marker().map(str::to_string);
version_id_marker = page.next_version_id_marker().map(str::to_string);
if key_marker.is_none() {
break;
}
}
matches.truncate(FIND_VERSION_BY_ETAG_MAX_MATCHES);
Ok(matches)
}
/// PutObjectRetention against a replica version on a target that does not
/// take retention through the replication PUT's own headers (it mints its
/// own version ids, so a re-PUT would create another version instead of
/// updating this one). Anti-loop marker always added.
pub async fn put_object_retention(
&self,
bucket: &str,
object: &str,
version_id: Option<String>,
mode: ObjectLockRetentionMode,
retain_until: aws_sdk_s3::primitives::DateTime,
) -> Result<PutObjectRetentionOutput, Box<SdkError<PutObjectRetentionError>>> {
let headers = proxy_outbound_headers(HeaderMap::new());
self.client
.put_object_retention()
.bucket(bucket)
.key(object)
.set_version_id(resolve_read_api_version_id(version_id))
.retention(
ObjectLockRetention::builder()
.mode(mode)
.retain_until_date(retain_until)
.build(),
)
.customize()
.map_request(move |req| apply_extra_headers(req, &headers))
.send()
.await
.map_err(Box::new)
}
/// PutObjectLegalHold counterpart of [`Self::put_object_retention`].
pub async fn put_object_legal_hold(
&self,
bucket: &str,
object: &str,
version_id: Option<String>,
status: ObjectLockLegalHoldStatus,
) -> Result<PutObjectLegalHoldOutput, Box<SdkError<PutObjectLegalHoldError>>> {
let headers = proxy_outbound_headers(HeaderMap::new());
self.client
.put_object_legal_hold()
.bucket(bucket)
.key(object)
.set_version_id(resolve_read_api_version_id(version_id))
.legal_hold(ObjectLockLegalHold::builder().status(status).build())
.customize()
.map_request(move |req| apply_extra_headers(req, &headers))
.send()
.await
.map_err(Box::new)
}
/// HEAD used by the read-proxy path (GET/HEAD of an object not yet /// HEAD used by the read-proxy path (GET/HEAD of an object not yet
/// replicated locally, MinIO `proxyHeadToRepTarget`). /// replicated locally, MinIO `proxyHeadToRepTarget`).
/// ///
@@ -2004,7 +2269,15 @@ impl TargetClient {
} }
} }
match builder // A forwarded source checksum is this PUT's integrity header. In
// streaming-checksum mode (`RUSTFS_REPLICATION_STREAMING_CHECKSUMS`)
// the SDK would still add its default CRC32 trailer, and a target that
// receives both keeps the trailer's algorithm: a forwarded SHA256
// vanished from the replica while the source reported COMPLETED. Pin
// this request to WhenRequired so nothing is sent beside the source's
// own checksum.
let forwards_source_checksum = headers.keys().any(|name| name.as_str().starts_with("x-amz-checksum-"));
let mut operation = builder
.bucket(bucket) .bucket(bucket)
.key(object) .key(object)
.content_length(size) .content_length(size)
@@ -2024,10 +2297,14 @@ impl TargetClient {
} }
Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req) Result::<_, aws_smithy_types::error::operation::BuildError>::Ok(req)
}) });
.send() if forwards_source_checksum {
.await operation = operation.config_override(
{ aws_sdk_s3::config::Builder::new()
.request_checksum_calculation(aws_sdk_s3::config::RequestChecksumCalculation::WhenRequired),
);
}
match operation.send().await {
Ok(output) => { Ok(output) => {
// Under SSE-KMS/DSSE or SSE-C the target's ETag is not the MD5 // Under SSE-KMS/DSSE or SSE-C the target's ETag is not the MD5
// of the stored plaintext, so it cannot be compared against the // of the stored plaintext, so it cannot be compared against the
@@ -2271,11 +2548,57 @@ 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`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplicaLocation {
/// No live version under the key carries the source ETag.
Missing,
/// Exactly one live version carries it: 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.
Ambiguous { newest: String },
}
impl ReplicaLocation {
/// `matches` newest first, as the target listed them.
pub fn from_matches(mut matches: Vec<String>) -> Self {
match matches.len() {
0 => Self::Missing,
1 => Self::Unique(matches.remove(0)),
_ => Self::Ambiguous {
newest: matches.remove(0),
},
}
}
/// The version to read for existence/ETag checks, where an ambiguous
/// match is still a located replica.
pub fn any_version_id(&self) -> Option<&str> {
match self {
Self::Missing => None,
Self::Unique(version_id) | Self::Ambiguous { newest: version_id } => Some(version_id),
}
}
}
#[derive(Debug)] #[derive(Debug)]
pub enum BucketTargetError { pub enum BucketTargetError {
BucketRemoteTargetNotFound { BucketRemoteTargetNotFound {
bucket: String, bucket: String,
}, },
/// The bucket's persisted targets configuration exists but cannot be
/// decoded. Distinct from `BucketRemoteTargetNotFound`, which means the
/// bucket genuinely has no targets: callers must not degrade this one to
/// an empty target set (rustfs/backlog#2282).
BucketRemoteTargetsUnreadable {
bucket: String,
},
BucketRemoteArnTypeInvalid { BucketRemoteArnTypeInvalid {
bucket: String, bucket: String,
}, },
@@ -2309,6 +2632,9 @@ impl fmt::Display for BucketTargetError {
BucketTargetError::BucketRemoteTargetNotFound { bucket } => { BucketTargetError::BucketRemoteTargetNotFound { bucket } => {
write!(f, "Remote target not found for bucket: {bucket}") write!(f, "Remote target not found for bucket: {bucket}")
} }
BucketTargetError::BucketRemoteTargetsUnreadable { bucket } => {
write!(f, "Persisted replication target configuration is unreadable for bucket: {bucket}")
}
BucketTargetError::BucketRemoteArnTypeInvalid { bucket } => { BucketTargetError::BucketRemoteArnTypeInvalid { bucket } => {
write!(f, "Invalid ARN type for bucket: {bucket}") write!(f, "Invalid ARN type for bucket: {bucket}")
} }
@@ -2437,13 +2763,21 @@ mod tests {
} }
fn header_recording_target_client(response_headers: Vec<(String, String)>) -> (TargetClient, RecordedHeaders) { fn header_recording_target_client(response_headers: Vec<(String, String)>) -> (TargetClient, RecordedHeaders) {
header_recording_target_client_with_checksums(response_headers, replication_request_checksum_calculation())
}
fn header_recording_target_client_with_checksums(
response_headers: Vec<(String, String)>,
checksums: RequestChecksumCalculation,
) -> (TargetClient, RecordedHeaders) {
let request_headers: RecordedHeaders = Arc::new(std::sync::Mutex::new(Vec::new())); let request_headers: RecordedHeaders = Arc::new(std::sync::Mutex::new(Vec::new()));
let connector = SharedHttpConnector::new(RecordingHeaderConnector { let connector = SharedHttpConnector::new(RecordingHeaderConnector {
request_headers: Arc::clone(&request_headers), request_headers: Arc::clone(&request_headers),
response_headers, response_headers,
}); });
let http_client = http_client_fn(move |_settings, _components| connector.clone()); let http_client = http_client_fn(move |_settings, _components| connector.clone());
let client = s3_client_for_test(443, Some(http_client)); let client =
s3_client_for_endpoint_test_with_checksums("https://localhost:443".to_string(), Some(http_client), checksums);
( (
TargetClient { TargetClient {
endpoint: "https://localhost:443".to_string(), endpoint: "https://localhost:443".to_string(),
@@ -2610,6 +2944,47 @@ mod tests {
} }
} }
/// With streaming checksums enabled the SDK adds a CRC32 trailer to every
/// upload. A PUT that forwards the source's checksum must not get that
/// second algorithm: a target that receives both keeps the trailer's and
/// the forwarded SHA256 never reaches the replica (rustfs/backlog#2340).
#[tokio::test]
async fn streaming_put_object_with_forwarded_checksum_sends_no_sdk_checksum() {
let (client, recorded) =
header_recording_target_client_with_checksums(Vec::new(), RequestChecksumCalculation::WhenSupported);
let mut forwarded = PutObjectOptions::default();
forwarded.user_metadata.insert(
"x-amz-checksum-sha256".to_string(),
"OoJ3yNhRwv3wwtZoGqEIPrPX9xwTnfLl+ka0wStN1g0=".to_string(),
);
client
.put_object("target-bucket", "object", 4, streaming_test_body(b"data"), &forwarded)
.await
.expect("recorded put_object should succeed");
client
.put_object("target-bucket", "object", 4, streaming_test_body(b"data"), &PutObjectOptions::default())
.await
.expect("recorded put_object should succeed");
let recorded = recorded.lock().expect("recorded header lock should not be poisoned");
let with_forwarded = &recorded[0];
assert_eq!(
recorded_header(with_forwarded, "x-amz-checksum-sha256"),
Some("OoJ3yNhRwv3wwtZoGqEIPrPX9xwTnfLl+ka0wStN1g0=")
);
assert_eq!(
recorded_header(with_forwarded, "x-amz-trailer"),
None,
"the SDK must not add a trailer checksum"
);
assert_eq!(recorded_header(with_forwarded, "x-amz-sdk-checksum-algorithm"), None);
// Control: the same client still streams a trailer when nothing is forwarded.
let without_forwarded = &recorded[1];
assert!(
recorded_header(without_forwarded, "x-amz-trailer").is_some(),
"streaming mode must still apply to uploads without a forwarded checksum: {without_forwarded:?}"
);
}
/// A forwarded source checksum already satisfies the rule; nothing is added. /// A forwarded source checksum already satisfies the rule; nothing is added.
#[tokio::test] #[tokio::test]
async fn locked_put_object_keeps_a_forwarded_source_checksum() { async fn locked_put_object_keeps_a_forwarded_source_checksum() {
@@ -2975,6 +3350,14 @@ mod tests {
} }
fn s3_client_for_endpoint_test(endpoint: String, http_client: Option<SharedHttpClient>) -> S3Client { fn s3_client_for_endpoint_test(endpoint: String, http_client: Option<SharedHttpClient>) -> S3Client {
s3_client_for_endpoint_test_with_checksums(endpoint, http_client, replication_request_checksum_calculation())
}
fn s3_client_for_endpoint_test_with_checksums(
endpoint: String,
http_client: Option<SharedHttpClient>,
checksums: RequestChecksumCalculation,
) -> S3Client {
let credentials = SdkCredentials::builder() let credentials = SdkCredentials::builder()
.access_key_id("test-access") .access_key_id("test-access")
.secret_access_key("test-secret") .secret_access_key("test-secret")
@@ -2988,7 +3371,7 @@ mod tests {
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest()) .behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
// Mirror the production remote-target builder so recorded requests // Mirror the production remote-target builder so recorded requests
// exercise the same checksum/framing behavior (#6853). // exercise the same checksum/framing behavior (#6853).
.request_checksum_calculation(replication_request_checksum_calculation()); .request_checksum_calculation(checksums);
if let Some(http_client) = http_client { if let Some(http_client) = http_client {
config = config.http_client(http_client); config = config.http_client(http_client);
} }
@@ -3151,6 +3534,64 @@ mod tests {
assert!(message.contains("connection refused")); assert!(message.contains("connection refused"));
} }
#[test]
fn same_replication_service_ignores_resync_and_bandwidth_edits() {
let base = BucketTarget {
endpoint: "target.example:9000".to_string(),
target_bucket: "replica".to_string(),
secure: true,
path: "on".to_string(),
arn: "arn:rustfs:replication:us-east-1:bucket:same".to_string(),
credentials: Some(Credentials {
access_key: "access".to_string(),
..Default::default()
}),
..Default::default()
};
let resync_edit = BucketTarget {
reset_id: "reset-1".to_string(),
bandwidth_limit: 1024,
..base.clone()
};
assert!(same_replication_service(&resync_edit, &base));
for moved in [
BucketTarget {
endpoint: "other.example:9000".to_string(),
..base.clone()
},
BucketTarget {
target_bucket: "other".to_string(),
..base.clone()
},
BucketTarget {
secure: false,
..base.clone()
},
BucketTarget {
credentials: Some(Credentials {
access_key: "rotated".to_string(),
..Default::default()
}),
..base.clone()
},
] {
assert!(!same_replication_service(&moved, &base));
}
}
#[test]
fn version_identity_verdict_is_per_arn_and_forgotten_with_the_target() {
let sys = BucketTargetSys::default();
let arn = "arn:rustfs:replication:us-east-1:bucket:identity";
assert_eq!(sys.version_identity_capability(arn), VersionIdentityCapability::Unknown);
sys.record_version_identity_capability(arn, VersionIdentityCapability::MintsOwn);
assert_eq!(sys.version_identity_capability(arn), VersionIdentityCapability::MintsOwn);
assert_eq!(sys.version_identity_capability("other"), VersionIdentityCapability::Unknown);
// A rebuilt target may point at a different service.
sys.forget_version_identity_capability(arn);
assert_eq!(sys.version_identity_capability(arn), VersionIdentityCapability::Unknown);
}
#[test] #[test]
fn endpoint_health_key_preserves_explicit_port() { fn endpoint_health_key_preserves_explicit_port() {
let url = Url::parse("https://remote.example:9443").expect("url should parse"); let url = Url::parse("https://remote.example:9443").expect("url should parse");
@@ -3256,7 +3697,7 @@ mod tests {
}], }],
); );
let targets = sys.list_targets("", "").await; let targets = sys.list_targets("", "").await.expect("listing every bucket's targets");
assert_eq!(targets.len(), 1); assert_eq!(targets.len(), 1);
assert!(!targets[0].online); assert!(!targets[0].online);
@@ -4254,4 +4695,41 @@ mod tests {
let window = LastMinuteLatency::new(); let window = LastMinuteLatency::new();
assert_eq!(window.get_total().avg, Duration::from_secs(0)); assert_eq!(window.get_total().avg, Duration::from_secs(0));
} }
fn repair_target(bucket: &str, id: &str) -> BucketTarget {
BucketTarget {
source_bucket: bucket.to_string(),
endpoint: "remote.example.com".to_string(),
target_bucket: "remote".to_string(),
arn: format!("arn:rustfs:replication:us-east-1:{bucket}:{id}"),
target_type: BucketTargetType::ReplicationService,
region: "us-east-1".to_string(),
..Default::default()
}
}
#[tokio::test]
async fn an_unreadable_target_set_refuses_cached_writes() {
let sys = BucketTargetSys::default();
let bucket = "targets-repair-opt-in";
sys.mark_targets_unreadable(bucket).await;
assert!(matches!(
sys.targets_base_for_write(bucket).await,
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. })
));
}
#[tokio::test]
async fn a_readable_target_set_remains_the_write_base() {
let sys = BucketTargetSys::default();
let bucket = "targets-repair-readable";
let existing = repair_target(bucket, "keep");
sys.targets_map
.write()
.await
.insert(bucket.to_string(), vec![existing.clone()]);
let base = sys.targets_base_for_write(bucket).await.expect("read targets");
assert_eq!(base.targets.len(), 1);
assert_eq!(base.targets[0].arn, existing.arn);
}
} }
File diff suppressed because it is too large Load Diff
@@ -41,6 +41,21 @@ where
com::read_config(api, file).await com::read_config(api, file).await
} }
pub(crate) async fn read_config_limited_preserve_empty<S>(api: Arc<S>, file: &str, max_bytes: usize) -> Result<Vec<u8>>
where
S: ObjectIO<
Error = Error,
RangeSpec = HTTPRangeSpec,
HeaderMap = HeaderMap,
ObjectOptions = ObjectOptions,
ObjectInfo = ObjectInfo,
GetObjectReader = GetObjectReader,
PutObjectReader = PutObjReader,
>,
{
com::read_config_limited_preserve_empty(api, file, max_bytes).await
}
pub(crate) async fn read_config_with_metadata<S>(api: Arc<S>, file: &str, opts: &ObjectOptions) -> Result<(Vec<u8>, ObjectInfo)> pub(crate) async fn read_config_with_metadata<S>(api: Arc<S>, file: &str, opts: &ObjectOptions) -> Result<(Vec<u8>, ObjectInfo)>
where where
S: ObjectIO< S: ObjectIO<
@@ -56,6 +71,26 @@ where
com::read_config_with_metadata(api, file, opts).await com::read_config_with_metadata(api, file, opts).await
} }
pub(crate) async fn read_config_limited_preserve_empty_with_metadata<S>(
api: Arc<S>,
file: &str,
opts: &ObjectOptions,
max_bytes: usize,
) -> Result<(Vec<u8>, ObjectInfo)>
where
S: ObjectIO<
Error = Error,
RangeSpec = HTTPRangeSpec,
HeaderMap = HeaderMap,
ObjectOptions = ObjectOptions,
ObjectInfo = ObjectInfo,
GetObjectReader = GetObjectReader,
PutObjectReader = PutObjReader,
>,
{
com::read_config_limited_preserve_empty_with_metadata_opts(api, file, opts, max_bytes).await
}
pub(crate) async fn save_config<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()> pub(crate) async fn save_config<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
where where
S: ObjectIO< S: ObjectIO<
@@ -126,20 +161,30 @@ where
DeletedObject = DeletedObject, DeletedObject = DeletedObject,
>, >,
{ {
match api delete_config_if_match_with_opts(api, file, etag, ObjectOptions::default()).await
.delete_object( }
RUSTFS_META_BUCKET,
file, pub(crate) async fn delete_config_if_match_with_opts<S>(
ObjectOptions { api: Arc<S>,
http_preconditions: Some(HTTPPreconditions { file: &str,
etag: &str,
mut options: ObjectOptions,
) -> Result<()>
where
S: ObjectOperations<
Error = Error,
ObjectInfo = ObjectInfo,
ObjectOptions = ObjectOptions,
FileInfo = FileInfo,
ObjectToDelete = ObjectToDelete,
DeletedObject = DeletedObject,
>,
{
options.http_preconditions = Some(HTTPPreconditions {
if_match: Some(etag.to_string()), if_match: Some(etag.to_string()),
..Default::default() ..Default::default()
}), });
..Default::default() match api.delete_object(RUSTFS_META_BUCKET, file, options).await {
},
)
.await
{
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(err) => { Err(err) => {
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) { if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
+3 -3
View File
@@ -15,9 +15,9 @@
use crate::object_api::ObjectInfo; use crate::object_api::ObjectInfo;
pub use rustfs_lifecycle::{ pub use rustfs_lifecycle::{
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate, TRANSITION_COMPLETE, Event, ExpirationOptions, IlmAction, LIFECYCLE_MALFORMED_XML_ERROR_KIND, Lifecycle, LifecycleCalculate, ObjectOpts,
TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due, expected_expiry_time, RuleValidate, TRANSITION_COMPLETE, TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due,
expiration_action_has_valid_target, expected_expiry_time, expiration_action_has_valid_target,
}; };
pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts { pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts {
@@ -22,7 +22,7 @@ use super::{
bucket_lifecycle_ops::{ bucket_lifecycle_ops::{
ManualTransitionQueueSnapshot, ManualTransitionRunReport, decode_manual_transition_continuation_token, ManualTransitionQueueSnapshot, ManualTransitionRunReport, decode_manual_transition_continuation_token,
}, },
manual_transition_job, tier_delete_journal, transition_transaction, manual_transition_job, recovery_control, recovery_disposition, recovery_export, tier_delete_journal, transition_transaction,
}; };
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::services::tier::tier_probe_intent; use crate::services::tier::tier_probe_intent;
@@ -41,6 +41,9 @@ pub(crate) enum DurableIlmRecordKind {
ManualTransitionScope, ManualTransitionScope,
ManualTransitionTask, ManualTransitionTask,
ManualTransitionWorkerResult, ManualTransitionWorkerResult,
RecoveryControl,
RecoveryExport,
RecoveryDisposition,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -105,8 +108,26 @@ pub(crate) const MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE: DurableIlmNamespace
max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE, max_record_size: manual_transition_job::MAX_MANUAL_TRANSITION_WORKER_RESULT_RECORD_SIZE,
kind: DurableIlmRecordKind::ManualTransitionWorkerResult, kind: DurableIlmRecordKind::ManualTransitionWorkerResult,
}; };
pub(crate) const RECOVERY_CONTROL_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "recovery-control",
prefix: recovery_control::ILM_RECOVERY_CONTROL_PREFIX,
max_record_size: recovery_control::MAX_ILM_RECOVERY_CONTROL_SIZE,
kind: DurableIlmRecordKind::RecoveryControl,
};
pub(crate) const RECOVERY_EXPORT_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "recovery-export",
prefix: recovery_export::ILM_RECOVERY_EXPORT_PREFIX,
max_record_size: recovery_export::MAX_ILM_RECOVERY_EXPORT_SIZE,
kind: DurableIlmRecordKind::RecoveryExport,
};
pub(crate) const RECOVERY_DISPOSITION_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "recovery-disposition",
prefix: recovery_disposition::ILM_RECOVERY_DISPOSITION_PREFIX,
max_record_size: recovery_disposition::MAX_ILM_RECOVERY_DISPOSITION_SIZE,
kind: DurableIlmRecordKind::RecoveryDisposition,
};
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 9] = [ pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 12] = [
TIER_DELETE_JOURNAL_NAMESPACE, TIER_DELETE_JOURNAL_NAMESPACE,
TIER_DELETE_JOURNAL_V6_NAMESPACE, TIER_DELETE_JOURNAL_V6_NAMESPACE,
TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE, TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE,
@@ -116,6 +137,9 @@ pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 9] = [
MANUAL_TRANSITION_SCOPE_NAMESPACE, MANUAL_TRANSITION_SCOPE_NAMESPACE,
MANUAL_TRANSITION_TASK_NAMESPACE, MANUAL_TRANSITION_TASK_NAMESPACE,
MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE, MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE,
RECOVERY_CONTROL_NAMESPACE,
RECOVERY_EXPORT_NAMESPACE,
RECOVERY_DISPOSITION_NAMESPACE,
]; ];
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -241,6 +265,40 @@ pub(crate) enum DurableIlmRecordCheckpoint {
ManualTransitionWorkerResult { ManualTransitionWorkerResult {
content_sha256: String, content_sha256: String,
}, },
RecoveryControl {
content_sha256: String,
identity_sha256: String,
source_generation_sha256: String,
first_seen_at_unix_nanos: i64,
revision: u64,
classification: recovery_control::IlmRecoveryClassification,
attempt_count: u64,
consecutive_failure_count: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
owner_fence_sha256: Option<String>,
},
RecoveryExport {
content_sha256: String,
source_generation_sha256: String,
topology_generation: String,
member_epochs_sha256: String,
creator_sha256: String,
retain_until_unix_nanos: i64,
},
RecoveryDisposition {
content_sha256: String,
identity_sha256: String,
copy_manifest_sha256: String,
copy_manifest_count: usize,
created_at_unix_nanos: i64,
revision: u64,
state: recovery_disposition::IlmRecoveryDispositionState,
owner_fence_sha256: Option<String>,
owner_lease_acquired_at_unix_nanos: Option<i64>,
owner_lease_expires_at_unix_nanos: Option<i64>,
confirmed_absent_sha256: Vec<String>,
retain_until_unix_nanos: i64,
},
} }
impl DurableIlmRecordCheckpoint { impl DurableIlmRecordCheckpoint {
@@ -254,7 +312,10 @@ impl DurableIlmRecordCheckpoint {
| Self::ManualTransitionJob { content_sha256, .. } | Self::ManualTransitionJob { content_sha256, .. }
| Self::ManualTransitionScope { content_sha256, .. } | Self::ManualTransitionScope { content_sha256, .. }
| Self::ManualTransitionTask { content_sha256 } | Self::ManualTransitionTask { content_sha256 }
| Self::ManualTransitionWorkerResult { content_sha256 } => content_sha256, | Self::ManualTransitionWorkerResult { content_sha256 }
| Self::RecoveryControl { content_sha256, .. }
| Self::RecoveryExport { content_sha256, .. }
| Self::RecoveryDisposition { content_sha256, .. } => content_sha256,
} }
} }
@@ -295,6 +356,9 @@ impl DurableIlmRecordCheckpoint {
{ {
return Err(Error::other("durable ILM tier delete journal checkpoint is invalid")); return Err(Error::other("durable ILM tier delete journal checkpoint is invalid"));
} }
if !recovery_disposition_checkpoint_is_valid(checkpoint) {
return Err(Error::other("durable ILM recovery disposition checkpoint is invalid"));
}
} }
if self == next { if self == next {
if let Self::ManualTransitionJob { if let Self::ManualTransitionJob {
@@ -435,9 +499,7 @@ impl DurableIlmRecordCheckpoint {
}, },
) => { ) => {
previous_identity == next_identity previous_identity == next_identity
&& transition_state_distance(*previous_state, *next_state) && transition_state_revision_is_successor(*previous_state, *previous_revision, *next_state, *next_revision)
.and_then(|distance| previous_revision.checked_add(distance))
.is_some_and(|expected_revision| *next_revision == expected_revision)
&& (!previous_remote_version_known || previous_remote_version == next_remote_version) && (!previous_remote_version_known || previous_remote_version == next_remote_version)
} }
( (
@@ -528,6 +590,128 @@ impl DurableIlmRecordCheckpoint {
.. ..
}, },
) => previous_identity == next_identity && next_updated_at > previous_updated_at, ) => previous_identity == next_identity && next_updated_at > previous_updated_at,
(
Self::RecoveryControl {
identity_sha256: previous_identity,
source_generation_sha256: previous_generation,
first_seen_at_unix_nanos: previous_first_seen,
revision: previous_revision,
classification: previous_classification,
attempt_count: previous_attempts,
consecutive_failure_count: previous_failures,
owner_fence_sha256: previous_owner,
..
},
Self::RecoveryControl {
identity_sha256: next_identity,
source_generation_sha256: next_generation,
first_seen_at_unix_nanos: next_first_seen,
revision: next_revision,
classification: next_classification,
attempt_count: next_attempts,
consecutive_failure_count: next_failures,
owner_fence_sha256: next_owner,
..
},
) => {
let adjacent = previous_identity == next_identity
&& previous_first_seen == next_first_seen
&& previous_revision.checked_add(1) == Some(*next_revision);
let claim = next_owner.is_some()
&& *previous_classification == recovery_control::IlmRecoveryClassification::Retrying
&& *next_classification == recovery_control::IlmRecoveryClassification::Retrying
&& previous_attempts.checked_add(1) == Some(*next_attempts)
&& previous_failures == next_failures;
let source_refresh = previous_owner.is_some()
&& previous_owner == next_owner
&& *previous_classification == recovery_control::IlmRecoveryClassification::Retrying
&& *next_classification == recovery_control::IlmRecoveryClassification::Retrying
&& previous_attempts == next_attempts
&& previous_failures == next_failures
&& previous_generation != next_generation;
let completion = previous_owner.is_some()
&& next_owner.is_none()
&& previous_generation == next_generation
&& previous_attempts == next_attempts;
adjacent && (claim || source_refresh || completion)
}
(
Self::RecoveryDisposition {
identity_sha256: previous_identity,
copy_manifest_sha256: previous_manifest,
copy_manifest_count: previous_manifest_count,
created_at_unix_nanos: previous_created_at,
revision: previous_revision,
state: previous_state,
owner_fence_sha256: previous_owner,
owner_lease_acquired_at_unix_nanos: previous_owner_acquired,
owner_lease_expires_at_unix_nanos: previous_owner_expires,
confirmed_absent_sha256: previous_confirmed,
retain_until_unix_nanos: previous_retain_until,
..
},
Self::RecoveryDisposition {
identity_sha256: next_identity,
copy_manifest_sha256: next_manifest,
copy_manifest_count: next_manifest_count,
created_at_unix_nanos: next_created_at,
revision: next_revision,
state: next_state,
owner_fence_sha256: next_owner,
owner_lease_acquired_at_unix_nanos: next_owner_acquired,
owner_lease_expires_at_unix_nanos: next_owner_expires,
confirmed_absent_sha256: next_confirmed,
retain_until_unix_nanos: next_retain_until,
..
},
) => {
use recovery_disposition::IlmRecoveryDispositionState::{Applying, Completed, Prepared};
let immutable_identity_matches = previous_identity == next_identity
&& previous_manifest == next_manifest
&& previous_manifest_count == next_manifest_count
&& previous_created_at == next_created_at
&& previous_retain_until == next_retain_until;
let adjacent = previous_revision.checked_add(1) == Some(*next_revision);
let progress_is_monotonic = sorted_sha256_set_is_subset(previous_confirmed, next_confirmed);
let legal_edge = match (previous_state, next_state) {
(Prepared, Prepared) => {
let claim = previous_owner.is_none() && next_owner.is_some();
let takeover = previous_owner.is_some()
&& previous_owner != next_owner
&& previous_owner_expires
.zip(*next_owner_acquired)
.is_some_and(|(expires, acquired)| acquired >= expires);
previous_confirmed == next_confirmed && (claim || takeover)
}
(Prepared, Applying) => {
previous_confirmed == next_confirmed
&& previous_owner.is_some()
&& previous_owner == next_owner
&& previous_owner_acquired == next_owner_acquired
&& previous_owner_expires == next_owner_expires
}
(Applying, Applying) => {
let progress = previous_owner == next_owner
&& previous_owner_acquired == next_owner_acquired
&& previous_owner_expires == next_owner_expires
&& previous_confirmed.len().checked_add(1) == Some(next_confirmed.len());
let takeover = previous_owner.is_some()
&& previous_owner != next_owner
&& previous_confirmed == next_confirmed
&& previous_owner_expires
.zip(*next_owner_acquired)
.is_some_and(|(expires, acquired)| acquired >= expires);
progress || takeover
}
(Applying, Completed) => {
previous_owner.is_some() && next_owner.is_none() && previous_confirmed == next_confirmed
}
_ => false,
};
immutable_identity_matches && adjacent && progress_is_monotonic && legal_edge
}
_ => false, _ => false,
}; };
@@ -545,6 +729,11 @@ impl DurableIlmRecordCheckpoint {
/// after the exact terminal ETag and terminal receipt were committed, to /// after the exact terminal ETag and terminal receipt were committed, to
/// purge older object versions exposed by that deletion. /// purge older object versions exposed by that deletion.
pub(crate) fn is_predecessor_of_terminal(&self, terminal: &Self) -> bool { pub(crate) fn is_predecessor_of_terminal(&self, terminal: &Self) -> bool {
for checkpoint in [self, terminal] {
if !recovery_disposition_checkpoint_is_valid(checkpoint) {
return false;
}
}
if let Self::TierProbeIntent { state, .. } = terminal if let Self::TierProbeIntent { state, .. } = terminal
&& !matches!( && !matches!(
state, state,
@@ -553,6 +742,19 @@ impl DurableIlmRecordCheckpoint {
{ {
return false; return false;
} }
if let Self::RecoveryControl { classification, .. } = terminal
&& !matches!(
classification,
recovery_control::IlmRecoveryClassification::Terminal | recovery_control::IlmRecoveryClassification::Abandoned
)
{
return false;
}
if let Self::RecoveryDisposition { state, .. } = terminal
&& state != &recovery_disposition::IlmRecoveryDispositionState::Completed
{
return false;
}
if self == terminal || self.validate_successor(terminal).is_ok() { if self == terminal || self.validate_successor(terminal).is_ok() {
return true; return true;
} }
@@ -652,11 +854,147 @@ impl DurableIlmRecordCheckpoint {
.is_some_and(|distance| tier_probe_state_reaches(*previous_state, *terminal_state, distance)) .is_some_and(|distance| tier_probe_state_reaches(*previous_state, *terminal_state, distance))
&& (!previous_remote_version_known || previous_remote_version == terminal_remote_version) && (!previous_remote_version_known || previous_remote_version == terminal_remote_version)
} }
(
Self::RecoveryControl {
identity_sha256: previous_identity,
source_generation_sha256: previous_generation,
first_seen_at_unix_nanos: previous_first_seen,
revision: previous_revision,
attempt_count: previous_attempts,
..
},
Self::RecoveryControl {
identity_sha256: terminal_identity,
source_generation_sha256: terminal_generation,
first_seen_at_unix_nanos: terminal_first_seen,
revision: terminal_revision,
attempt_count: terminal_attempts,
classification:
recovery_control::IlmRecoveryClassification::Terminal | recovery_control::IlmRecoveryClassification::Abandoned,
..
},
) => {
previous_identity == terminal_identity
&& (previous_generation == terminal_generation || terminal_attempts > previous_attempts)
&& previous_first_seen == terminal_first_seen
&& terminal_revision > previous_revision
&& terminal_attempts >= previous_attempts
}
(
Self::RecoveryDisposition {
identity_sha256: previous_identity,
copy_manifest_sha256: previous_manifest,
copy_manifest_count: previous_manifest_count,
created_at_unix_nanos: previous_created_at,
revision: previous_revision,
state: previous_state,
owner_fence_sha256: previous_owner,
confirmed_absent_sha256: previous_confirmed,
retain_until_unix_nanos: previous_retain_until,
..
},
Self::RecoveryDisposition {
identity_sha256: terminal_identity,
copy_manifest_sha256: terminal_manifest,
copy_manifest_count: terminal_manifest_count,
created_at_unix_nanos: terminal_created_at,
revision: terminal_revision,
state: recovery_disposition::IlmRecoveryDispositionState::Completed,
confirmed_absent_sha256: terminal_confirmed,
retain_until_unix_nanos: terminal_retain_until,
..
},
) => {
matches!(
previous_state,
recovery_disposition::IlmRecoveryDispositionState::Prepared
| recovery_disposition::IlmRecoveryDispositionState::Applying
) && previous_identity == terminal_identity
&& previous_manifest == terminal_manifest
&& previous_manifest_count == terminal_manifest_count
&& previous_created_at == terminal_created_at
&& previous_retain_until == terminal_retain_until
&& terminal_revision.checked_sub(*previous_revision).is_some_and(|distance| {
let minimum_distance = match previous_state {
recovery_disposition::IlmRecoveryDispositionState::Prepared if previous_owner.is_some() => 3,
recovery_disposition::IlmRecoveryDispositionState::Prepared => 4,
recovery_disposition::IlmRecoveryDispositionState::Applying
if previous_confirmed.len() == *previous_manifest_count =>
{
1
}
recovery_disposition::IlmRecoveryDispositionState::Applying => 2,
recovery_disposition::IlmRecoveryDispositionState::Completed => u64::MAX,
};
distance >= minimum_distance
})
&& sorted_sha256_set_is_subset(previous_confirmed, terminal_confirmed)
}
_ => false, _ => false,
} }
} }
} }
fn recovery_disposition_checkpoint_is_valid(checkpoint: &DurableIlmRecordCheckpoint) -> bool {
use recovery_disposition::IlmRecoveryDispositionState::{Applying, Completed, Prepared};
let DurableIlmRecordCheckpoint::RecoveryDisposition {
content_sha256,
identity_sha256,
copy_manifest_sha256,
copy_manifest_count,
created_at_unix_nanos,
revision,
state,
owner_fence_sha256,
owner_lease_acquired_at_unix_nanos,
owner_lease_expires_at_unix_nanos,
confirmed_absent_sha256,
retain_until_unix_nanos,
} = checkpoint
else {
return true;
};
let owner_fence_sha256 = owner_fence_sha256.as_deref();
is_canonical_sha256(content_sha256)
&& is_canonical_sha256(identity_sha256)
&& is_canonical_sha256(copy_manifest_sha256)
&& *copy_manifest_count > 0
&& *created_at_unix_nanos > 0
&& *revision > 0
&& *retain_until_unix_nanos > 0
&& owner_fence_sha256.is_none_or(is_canonical_sha256)
&& match (
owner_fence_sha256,
*owner_lease_acquired_at_unix_nanos,
*owner_lease_expires_at_unix_nanos,
) {
(None, None, None) => true,
(Some(_), Some(acquired), Some(expires)) => acquired > 0 && expires > acquired,
_ => false,
}
&& confirmed_absent_sha256.len() <= *copy_manifest_count
&& confirmed_absent_sha256.iter().all(|digest| is_canonical_sha256(digest))
&& confirmed_absent_sha256.windows(2).all(|pair| pair[0] < pair[1])
&& match *state {
Prepared => confirmed_absent_sha256.is_empty(),
Applying => owner_fence_sha256.is_some(),
Completed => owner_fence_sha256.is_none() && confirmed_absent_sha256.len() == *copy_manifest_count,
}
}
fn is_canonical_sha256(value: &str) -> bool {
is_sha256_checksum(value)
&& !value
.bytes()
.any(|byte| byte.is_ascii_hexdigit() && byte.is_ascii_uppercase())
}
fn sorted_sha256_set_is_subset(subset: &[String], superset: &[String]) -> bool {
subset.iter().all(|candidate| superset.binary_search(candidate).is_ok())
}
fn tier_delete_dispatch_parent_progress_delta( fn tier_delete_dispatch_parent_progress_delta(
previous_sequence: u64, previous_sequence: u64,
previous_completed_journals: u64, previous_completed_journals: u64,
@@ -690,6 +1028,22 @@ fn transition_state_distance(
} }
} }
fn transition_state_revision_is_successor(
from: transition_transaction::TransitionTransactionState,
from_revision: u64,
to: transition_transaction::TransitionTransactionState,
to_revision: u64,
) -> bool {
use transition_transaction::TransitionTransactionState::{LocalCommitStarted, UploadOutcomeUnknown};
if from == UploadOutcomeUnknown && from_revision == 1 && to == LocalCommitStarted {
return to_revision == 2;
}
transition_state_distance(from, to)
.and_then(|distance| from_revision.checked_add(distance))
.is_some_and(|expected_revision| to_revision == expected_revision)
}
fn tier_probe_state_reaches( fn tier_probe_state_reaches(
from: tier_probe_intent::TierProbeIntentState, from: tier_probe_intent::TierProbeIntentState,
to: tier_probe_intent::TierProbeIntentState, to: tier_probe_intent::TierProbeIntentState,
@@ -1219,6 +1573,84 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
}, },
) )
} }
DurableIlmRecordKind::RecoveryControl => {
let (protocol, control_id) = recovery_control::recovery_control_id_from_record_object_name(path)
.map_err(|err| Error::other(err.to_string()))?;
let control =
recovery_control::IlmRecoveryControl::decode(&control_id, data).map_err(|err| Error::other(err.to_string()))?;
let canonical = recovery_control::recovery_control_record_object_name(protocol, &control_id)
.map_err(|err| Error::other(err.to_string()))?;
if canonical != path || control.identity.protocol != protocol {
return Err(Error::other("ILM recovery control path is not canonical"));
}
let identity_sha256 = checkpoint_hash(&control.identity)?;
let source_generation_sha256 = checkpoint_hash(&control.observed_source_generation)?;
let owner_fence_sha256 = control.owner.as_ref().map(checkpoint_hash).transpose()?;
(
"control_id",
control_id,
DurableIlmRecordCheckpoint::RecoveryControl {
content_sha256,
identity_sha256,
source_generation_sha256,
first_seen_at_unix_nanos: control.first_seen_at_unix_nanos,
revision: control.revision,
classification: control.classification,
attempt_count: control.attempt_count,
consecutive_failure_count: control.consecutive_failure_count,
owner_fence_sha256,
},
)
}
DurableIlmRecordKind::RecoveryExport => {
let (protocol, export_id) = recovery_export::recovery_export_id_from_record_object_name(path)?;
let export = recovery_export::IlmRecoveryExport::decode(&export_id, data)?;
let canonical = recovery_export::recovery_export_record_object_name(protocol, &export_id)?;
if canonical != path || export.protocol != protocol {
return Err(Error::other("ILM recovery export path is not canonical"));
}
let source_generation_sha256 = checkpoint_hash(&export.source_generation)?;
(
"export_id",
export_id,
DurableIlmRecordCheckpoint::RecoveryExport {
content_sha256,
source_generation_sha256,
topology_generation: export.topology_generation,
member_epochs_sha256: export.member_epochs_sha256,
creator_sha256: export.creator_sha256,
retain_until_unix_nanos: export.retain_until_unix_nanos,
},
)
}
DurableIlmRecordKind::RecoveryDisposition => {
// The disposition module owns strict schema, checksum, canonical
// path, immutable-manifest, and state-specific validation. Keep
// this boundary limited to decommission identity/checkpoint
// projection so the two readers cannot accept different records.
let disposition = recovery_disposition::decode_recovery_disposition_checkpoint(path, data)?;
if disposition.content_sha256 != content_sha256 {
return Err(Error::other("ILM recovery disposition checkpoint content digest is invalid"));
}
(
"disposition_id",
disposition.disposition_id,
DurableIlmRecordCheckpoint::RecoveryDisposition {
content_sha256: disposition.content_sha256,
identity_sha256: disposition.identity_sha256,
copy_manifest_sha256: disposition.copy_manifest_sha256,
copy_manifest_count: disposition.copy_manifest_count,
created_at_unix_nanos: disposition.created_at_unix_nanos,
revision: disposition.revision,
state: disposition.state,
owner_fence_sha256: disposition.owner_fence_sha256,
owner_lease_acquired_at_unix_nanos: disposition.owner_lease_acquired_at_unix_nanos,
owner_lease_expires_at_unix_nanos: disposition.owner_lease_expires_at_unix_nanos,
confirmed_absent_sha256: disposition.confirmed_absent_sha256,
retain_until_unix_nanos: disposition.retain_until_unix_nanos,
},
)
}
DurableIlmRecordKind::ManualTransitionJob => { DurableIlmRecordKind::ManualTransitionJob => {
let job_id = manual_transition_job::manual_transition_job_id_from_record_object_name(path) let job_id = manual_transition_job::manual_transition_job_id_from_record_object_name(path)
.map_err(|err| Error::other(err.to_string()))?; .map_err(|err| Error::other(err.to_string()))?;
@@ -1374,6 +1806,203 @@ mod tests {
} }
} }
fn recovery_disposition_checkpoint(
revision: u64,
state: recovery_disposition::IlmRecoveryDispositionState,
owner_fence: Option<&str>,
confirmed_absent_sha256: Vec<String>,
) -> DurableIlmRecordCheckpoint {
let (owner_lease_acquired_at_unix_nanos, owner_lease_expires_at_unix_nanos) = match owner_fence {
Some("f") => (Some(10), Some(20)),
Some(_) => (Some(1), Some(10)),
None => (None, None),
};
DurableIlmRecordCheckpoint::RecoveryDisposition {
content_sha256: format!("{revision:064x}"),
identity_sha256: "a".repeat(64),
copy_manifest_sha256: "d".repeat(64),
copy_manifest_count: 2,
created_at_unix_nanos: 1_700_000_000_000_000_000,
revision,
state,
owner_fence_sha256: owner_fence.map(|digest| digest.repeat(64)),
owner_lease_acquired_at_unix_nanos,
owner_lease_expires_at_unix_nanos,
confirmed_absent_sha256,
retain_until_unix_nanos: 1_820_000_000_000_000_000,
}
}
#[test]
fn recovery_disposition_namespace_is_registered_without_shadowing_its_root() {
let disposition_id = "a".repeat(64);
let path = format!(
"{}/tier_delete_journal/{}/{}/{}.json",
recovery_disposition::ILM_RECOVERY_DISPOSITION_PREFIX,
&disposition_id[..2],
&disposition_id[2..4],
disposition_id
);
let namespace = classify_durable_ilm_record(&path)
.expect("recovery disposition path should classify")
.expect("recovery disposition should be durable");
assert_eq!(namespace, &RECOVERY_DISPOSITION_NAMESPACE);
assert!(classify_durable_ilm_record(recovery_disposition::ILM_RECOVERY_DISPOSITION_PREFIX).is_err());
}
#[test]
fn recovery_disposition_checkpoint_accepts_only_monotonic_progress() {
use recovery_disposition::IlmRecoveryDispositionState::{Applying, Completed, Prepared};
let first_copy = "b".repeat(64);
let second_copy = "c".repeat(64);
let prepared = recovery_disposition_checkpoint(1, Prepared, None, Vec::new());
let claimed = recovery_disposition_checkpoint(2, Prepared, Some("e"), Vec::new());
let applying = recovery_disposition_checkpoint(3, Applying, Some("e"), Vec::new());
let first_absent = recovery_disposition_checkpoint(4, Applying, Some("e"), vec![first_copy.clone()]);
let taken_over = recovery_disposition_checkpoint(5, Applying, Some("f"), vec![first_copy.clone()]);
let all_absent = recovery_disposition_checkpoint(6, Applying, Some("f"), vec![first_copy.clone(), second_copy.clone()]);
let completed = recovery_disposition_checkpoint(7, Completed, None, vec![first_copy.clone(), second_copy.clone()]);
prepared
.validate_successor(&claimed)
.expect("Prepared should record an owner claim without absence progress");
claimed
.validate_successor(&applying)
.expect("Prepared should advance to Applying without folding in deletion progress");
applying
.validate_successor(&first_absent)
.expect("Applying should append newly confirmed absent copies");
first_absent
.validate_successor(&taken_over)
.expect("Applying should record a fenced owner takeover without losing progress");
taken_over
.validate_successor(&all_absent)
.expect("Applying should preserve every earlier confirmation while making progress");
all_absent
.validate_successor(&completed)
.expect("a fully confirmed manifest should advance to Completed");
assert!(
prepared.validate_successor(&completed).is_err(),
"adjacent receipt updates must not skip Applying"
);
assert!(
first_absent
.validate_successor(&recovery_disposition_checkpoint(5, Applying, Some("e"), Vec::new()))
.is_err(),
"confirmed-absent progress must not move backwards"
);
assert!(
applying
.validate_successor(&recovery_disposition_checkpoint(4, Completed, None, vec![first_copy.clone()]))
.is_err(),
"Completed must cover the complete immutable copy manifest"
);
assert!(
completed
.validate_successor(&recovery_disposition_checkpoint(7, Applying, Some("e"), vec![second_copy]))
.is_err(),
"Completed is terminal"
);
assert!(
first_absent
.validate_successor(&recovery_disposition_checkpoint(5, Applying, Some("e"), vec![first_copy.clone()]))
.is_err(),
"a same-state revision bump must change the owner fence or absence progress"
);
assert!(
applying
.validate_successor(&recovery_disposition_checkpoint(4, Applying, None, vec![first_copy]))
.is_err(),
"Applying must retain a fenced owner"
);
let mut noncanonical_identity = claimed.clone();
if let DurableIlmRecordCheckpoint::RecoveryDisposition { identity_sha256, .. } = &mut noncanonical_identity {
*identity_sha256 = "A".repeat(64);
}
assert!(prepared.validate_successor(&noncanonical_identity).is_err());
let mut changed_created_at = claimed;
if let DurableIlmRecordCheckpoint::RecoveryDisposition {
created_at_unix_nanos, ..
} = &mut changed_created_at
{
*created_at_unix_nanos += 1;
}
assert!(prepared.validate_successor(&changed_created_at).is_err());
let mut early_takeover = taken_over;
if let DurableIlmRecordCheckpoint::RecoveryDisposition {
owner_lease_acquired_at_unix_nanos,
..
} = &mut early_takeover
{
*owner_lease_acquired_at_unix_nanos = Some(9);
}
assert!(first_absent.validate_successor(&early_takeover).is_err());
assert!(
applying
.validate_successor(&recovery_disposition_checkpoint(
4,
Applying,
Some("e"),
vec!["c".repeat(64), "b".repeat(64)],
))
.is_err(),
"confirmed-absent entries must be a canonical sorted set"
);
}
#[test]
fn recovery_disposition_terminal_predecessor_requires_exact_identity_and_full_manifest() {
use recovery_disposition::IlmRecoveryDispositionState::{Applying, Completed, Prepared};
let first_copy = "b".repeat(64);
let second_copy = "c".repeat(64);
let prepared = recovery_disposition_checkpoint(1, Prepared, None, Vec::new());
let applying = recovery_disposition_checkpoint(3, Applying, Some("e"), vec![first_copy.clone()]);
let completed = recovery_disposition_checkpoint(5, Completed, None, vec![first_copy.clone(), second_copy]);
assert!(prepared.is_predecessor_of_terminal(&completed));
assert!(applying.is_predecessor_of_terminal(&completed));
assert!(
!prepared.is_predecessor_of_terminal(&recovery_disposition_checkpoint(2, Applying, Some("e"), Vec::new())),
"a nonterminal disposition must not authorize terminal cleanup"
);
assert!(
!prepared.is_predecessor_of_terminal(&recovery_disposition_checkpoint(
4,
Completed,
None,
vec![first_copy.clone(), "c".repeat(64)],
)),
"terminal proof must leave enough revisions for claim, apply, progress, and completion"
);
assert!(
!applying.is_predecessor_of_terminal(&recovery_disposition_checkpoint(
4,
Completed,
None,
vec![first_copy.clone(), "c".repeat(64)],
)),
"an incomplete Applying checkpoint cannot complete without a progress generation"
);
let mut other_identity = completed;
if let DurableIlmRecordCheckpoint::RecoveryDisposition { identity_sha256, .. } = &mut other_identity {
*identity_sha256 = "e".repeat(64);
}
assert!(!prepared.is_predecessor_of_terminal(&other_identity));
let incomplete_terminal = recovery_disposition_checkpoint(4, Completed, None, vec![first_copy]);
assert!(
!prepared.is_predecessor_of_terminal(&incomplete_terminal),
"a partial confirmed-absent set must not become terminal proof"
);
}
fn tier_probe_intent_fixture() -> tier_probe_intent::TierProbeIntent { fn tier_probe_intent_fixture() -> tier_probe_intent::TierProbeIntent {
let probe_id = Uuid::parse_str("36e2220e-9ad2-495b-b3bc-c4d2caf70a31").expect("fixture uuid should parse"); let probe_id = Uuid::parse_str("36e2220e-9ad2-495b-b3bc-c4d2caf70a31").expect("fixture uuid should parse");
tier_probe_intent::TierProbeIntent { tier_probe_intent::TierProbeIntent {
@@ -1412,6 +2041,94 @@ mod tests {
.checkpoint .checkpoint
} }
fn recovery_control_fixture() -> recovery_control::IlmRecoveryControl {
let source_path = "ilm/transition-transactions/records/12/34/1234567890abcdef1234567890abcdef.json";
let generation = recovery_control::IlmRecoverySourceGeneration::new(
transition_transaction::TRANSITION_TRANSACTION_SCHEMA,
"source-etag",
"a".repeat(64),
vec![recovery_control::IlmRecoverySourceCopy {
authority: "pool-0/set-0".to_string(),
canonical_path: source_path.to_string(),
etag: "source-etag".to_string(),
encoded_len: 128,
content_sha256: "a".repeat(64),
}],
)
.expect("source generation should build");
recovery_control::IlmRecoveryControl::new(
recovery_control::IlmRecoveryControlIdentity {
protocol: recovery_control::IlmRecoveryProtocol::TransitionTransaction,
canonical_source_path: source_path.to_string(),
stable_operation_identity: "12345678-90ab-cdef-1234-567890abcdef".to_string(),
record_class: "transition_transaction_v1".to_string(),
},
generation,
recovery_control::IlmRecoveryClassification::Retrying,
1_000_000_000,
recovery_control::IlmRecoveryErrorCode::None,
)
.expect("recovery control should build")
}
fn recovery_control_checkpoint(control: &recovery_control::IlmRecoveryControl) -> DurableIlmRecordCheckpoint {
let control_id = control.identity.source_operation_digest().expect("control id should derive");
let path = recovery_control::recovery_control_record_object_name(control.identity.protocol, &control_id)
.expect("control path should build");
let encoded = control.encode().expect("control should encode");
let namespace = classify_durable_ilm_record(&path)
.expect("recovery control namespace should classify")
.expect("recovery control should be durable");
assert_eq!(namespace, &RECOVERY_CONTROL_NAMESPACE);
validate_durable_ilm_record(&path, &encoded)
.expect("recovery control should validate")
.checkpoint
}
#[test]
fn recovery_control_checkpoint_tracks_claim_retry_and_terminal_generations() {
let initial_control = recovery_control_fixture();
let initial = recovery_control_checkpoint(&initial_control);
let mut claimed_control = initial_control;
let mut advanced_generation = claimed_control.observed_source_generation.clone();
advanced_generation.source_schema = "rustfs-transition-transaction-v2".to_string();
claimed_control
.claim_for_source_generation("node-a", Uuid::new_v4(), 2_000_000_000, 300_000_000_000, advanced_generation)
.expect("control should claim");
let claimed = recovery_control_checkpoint(&claimed_control);
initial.validate_successor(&claimed).expect("claim should advance receipt");
let mut retry_control = claimed_control;
retry_control
.record_retryable_failure(3_000_000_000, recovery_control::IlmRecoveryErrorCode::BackendTimeout)
.expect("retry should persist");
let retry = recovery_control_checkpoint(&retry_control);
claimed.validate_successor(&retry).expect("retry should advance receipt");
let ready_at = retry_control
.next_attempt_at_unix_nanos
.expect("retry deadline should persist");
let mut terminal_control = retry_control;
terminal_control
.claim("node-b", Uuid::new_v4(), ready_at, 300_000_000_000)
.expect("retry should claim");
let reclaimed = recovery_control_checkpoint(&terminal_control);
retry.validate_successor(&reclaimed).expect("reclaim should advance receipt");
terminal_control
.finish_attempt(
recovery_control::IlmRecoveryClassification::Terminal,
recovery_control::IlmRecoveryErrorCode::None,
)
.expect("control should terminate");
let terminal = recovery_control_checkpoint(&terminal_control);
reclaimed
.validate_successor(&terminal)
.expect("terminal state should advance receipt");
assert!(initial.is_predecessor_of_terminal(&terminal));
assert!(!initial.is_predecessor_of_terminal(&retry));
}
#[test] #[test]
fn tier_probe_intent_checkpoint_tracks_exact_monotonic_generations() { fn tier_probe_intent_checkpoint_tracks_exact_monotonic_generations() {
let initial_intent = tier_probe_intent_fixture(); let initial_intent = tier_probe_intent_fixture();
@@ -1470,6 +2187,64 @@ mod tests {
); );
} }
#[test]
fn transition_checkpoint_accepts_only_the_distinguishable_compact_edge() {
let identity_sha256 = "a".repeat(64);
let unknown_remote_sha256 = "b".repeat(64);
let known_remote_sha256 = "c".repeat(64);
let checkpoint = |revision, state, remote_version_sha256: String, remote_version_known| {
DurableIlmRecordCheckpoint::TransitionTransaction {
content_sha256: format!("{revision:064x}"),
identity_sha256: identity_sha256.clone(),
remote_version_sha256,
remote_version_known,
revision,
state,
}
};
let compact_unknown = checkpoint(
1,
transition_transaction::TransitionTransactionState::UploadOutcomeUnknown,
unknown_remote_sha256.clone(),
false,
);
let compact_local_commit = checkpoint(
2,
transition_transaction::TransitionTransactionState::LocalCommitStarted,
known_remote_sha256.clone(),
true,
);
compact_unknown
.validate_successor(&compact_local_commit)
.expect("compact pre-upload fence should advance directly to the exact local-commit fence");
let legacy_unknown = checkpoint(
2,
transition_transaction::TransitionTransactionState::UploadOutcomeUnknown,
unknown_remote_sha256,
false,
);
let invalid_legacy_skip = checkpoint(
3,
transition_transaction::TransitionTransactionState::LocalCommitStarted,
known_remote_sha256.clone(),
true,
);
assert!(
legacy_unknown.validate_successor(&invalid_legacy_skip).is_err(),
"legacy UploadOutcomeUnknown@2 must not masquerade as the compact edge"
);
let valid_legacy_skip = checkpoint(
4,
transition_transaction::TransitionTransactionState::LocalCommitStarted,
known_remote_sha256,
true,
);
legacy_unknown
.validate_successor(&valid_legacy_skip)
.expect("legacy receipts may still observe the existing two-edge state advance");
}
#[test] #[test]
fn tier_delete_dispatch_manifest_namespace_validates_monotonic_branches() { fn tier_delete_dispatch_manifest_namespace_validates_monotonic_branches() {
use tier_delete_journal::TierDeleteDispatchManifestState::{Aborted, Aborting, Completed, DispatchAuthorized, Preparing}; use tier_delete_journal::TierDeleteDispatchManifestState::{Aborted, Aborting, Completed, DispatchAuthorized, Preparing};
@@ -1170,6 +1170,7 @@ pub async fn save_manual_transition_job_record_if_current(
data.clone(), data.clone(),
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_match: Some(current_etag.to_string()), if_match: Some(current_etag.to_string()),
..Default::default() ..Default::default()
@@ -1242,6 +1243,7 @@ pub(crate) async fn save_manual_transition_worker_result_if_absent(
data, data,
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()), if_none_match: Some("*".to_string()),
..Default::default() ..Default::default()
@@ -1270,6 +1272,7 @@ pub(crate) async fn save_manual_transition_task_if_absent(
data, data,
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()), if_none_match: Some("*".to_string()),
..Default::default() ..Default::default()
@@ -1621,6 +1624,7 @@ pub async fn save_manual_transition_scope_admission_if_absent(
data.clone(), data.clone(),
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()), if_none_match: Some("*".to_string()),
..Default::default() ..Default::default()
@@ -1672,6 +1676,7 @@ pub async fn save_manual_transition_scope_admission_if_current(
data.clone(), data.clone(),
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_match: Some(current_etag.to_string()), if_match: Some(current_etag.to_string()),
..Default::default() ..Default::default()
@@ -24,6 +24,10 @@ pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs, g
mod object_handlers_common; mod object_handlers_common;
mod object_lock_boundary; mod object_lock_boundary;
pub use self::core as lifecycle; pub use self::core as lifecycle;
pub mod recovery_control;
pub mod recovery_disposition;
pub(crate) mod recovery_disposition_runtime;
pub mod recovery_export;
mod replication_sink; mod replication_sink;
pub mod rule; pub mod rule;
mod runtime_boundary; mod runtime_boundary;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,840 @@
// 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.
use std::{collections::HashSet, sync::Arc};
use rustfs_utils::crypto::{hex_sha256, is_sha256_checksum};
use serde::{Deserialize, Serialize};
use super::config_boundary;
use super::recovery_control::{
IlmRecoveryClassification, IlmRecoveryControl, IlmRecoveryProtocol, IlmRecoverySourceCopy, IlmRecoverySourceGeneration,
MAX_ILM_RECOVERY_CONTROL_SIZE, ObservedIlmRecoveryControl, ObservedIlmRecoverySource, recovery_control_record_object_name,
};
use super::tier_delete_journal::{
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, validate_legacy_tier_delete_recovery_source,
};
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result};
use crate::object_api::{ObjectOptions, WriteCompletion};
use crate::services::notification_sys::{
acquire_ilm_recovery_export_fleet_proof, ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_member_epochs_sha256,
ilm_recovery_export_topology_generation,
};
use crate::storage_api_contracts::{list::ListOperations as _, namespace::NamespaceLocking as _, object::HTTPPreconditions};
use crate::store::ECStore;
pub const ILM_RECOVERY_EXPORT_SCHEMA: &str = "rustfs-ilm-recovery-export-v1";
pub const ILM_RECOVERY_EXPORT_PREFIX: &str = "ilm/recovery-exports";
pub const MAX_ILM_RECOVERY_EXPORT_SIZE: usize = 128 * 1024;
const MAX_ILM_RECOVERY_EXPORTS: usize = 10_000;
const MAX_ILM_RECOVERY_EXPORT_BYTES: u64 = 1024 * 1024 * 1024;
const MAX_ACTOR_EXPORTS_PER_MINUTE: usize = 10;
const MAX_CLUSTER_EXPORTS_PER_MINUTE: usize = 100;
const EXPORT_RETENTION_NANOS: i64 = 90 * 24 * 60 * 60 * 1_000_000_000;
const EXPORT_ADMISSION_LOCK: &str = "ilm/recovery-admission/export.lock";
const MAX_LEGACY_TIER_DELETE_SOURCE_SIZE: usize = 64 * 1024;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IlmRecoveryExportObservation {
pub control_id: String,
pub protocol: IlmRecoveryProtocol,
pub control_etag: String,
pub control_revision: u64,
pub classification: IlmRecoveryClassification,
pub canonical_source_path: String,
pub source_generation: IlmRecoverySourceGeneration,
pub topology_generation: String,
pub member_epochs_sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IlmRecoveryExport {
pub export_id: String,
pub control_id: String,
pub protocol: IlmRecoveryProtocol,
pub control_etag: String,
pub control_revision: u64,
pub classification: IlmRecoveryClassification,
pub canonical_source_path: String,
pub source_generation: IlmRecoverySourceGeneration,
pub topology_generation: String,
pub member_epochs_sha256: String,
pub creator_sha256: String,
pub created_at_unix_nanos: i64,
pub retain_until_unix_nanos: i64,
pub source_bytes_base64: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct PersistedIlmRecoveryExport {
schema: String,
content_sha256: String,
export: IlmRecoveryExport,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct IlmRecoveryExportCreated {
pub export_id: String,
pub content_sha256: String,
pub encoded: Vec<u8>,
pub replayed: bool,
}
impl IlmRecoveryExport {
fn validate(&self) -> Result<()> {
self.source_generation.validate().map_err(Error::other)?;
validate_sha256(&self.export_id, "ILM recovery export ID is invalid")?;
validate_sha256(&self.control_id, "ILM recovery export control ID is invalid")?;
validate_sha256(&self.topology_generation, "ILM recovery export topology generation is invalid")?;
validate_sha256(&self.member_epochs_sha256, "ILM recovery export member epoch digest is invalid")?;
validate_sha256(&self.creator_sha256, "ILM recovery export creator digest is invalid")?;
if self.protocol != IlmRecoveryProtocol::TierDeleteJournal
|| self.classification != IlmRecoveryClassification::RetainedAmbiguous
|| !is_legacy_export_schema(&self.source_generation.source_schema)
{
return Err(Error::other("ILM recovery export source is not an exportable legacy journal"));
}
if self.control_etag.trim().is_empty() || self.control_revision == 0 {
return Err(Error::other("ILM recovery export control generation is invalid"));
}
if self.canonical_source_path.is_empty()
|| self.canonical_source_path.starts_with('/')
|| self.canonical_source_path.ends_with('/')
|| self.canonical_source_path.split('/').any(str::is_empty)
{
return Err(Error::other("ILM recovery export source path is invalid"));
}
if self.created_at_unix_nanos <= 0
|| self.retain_until_unix_nanos < self.created_at_unix_nanos.saturating_add(EXPORT_RETENTION_NANOS)
{
return Err(Error::other("ILM recovery export retention is invalid"));
}
let source = base64_simd::STANDARD
.decode_to_vec(self.source_bytes_base64.as_bytes())
.map_err(|_| Error::other("ILM recovery export source encoding is invalid"))?;
validate_legacy_tier_delete_recovery_source(&self.canonical_source_path, &self.source_generation.source_schema, &source)?;
let encoded_len = u64::try_from(source.len()).map_err(|_| Error::other("ILM recovery export source length overflow"))?;
if source.is_empty()
|| source.len() > MAX_LEGACY_TIER_DELETE_SOURCE_SIZE
|| hex_sha256(&source, ToOwned::to_owned) != self.source_generation.content_sha256
|| self.source_generation.copies.iter().any(|copy| {
copy.canonical_path != self.canonical_source_path
|| copy.etag != self.source_generation.source_etag
|| copy.content_sha256 != self.source_generation.content_sha256
|| copy.encoded_len != encoded_len
})
{
return Err(Error::other("ILM recovery export source bytes do not match the observed generation"));
}
if recovery_export_id(&self.control_id, &self.source_generation)? != self.export_id {
return Err(Error::other("ILM recovery export ID does not match its source generation"));
}
Ok(())
}
pub fn encode(&self) -> Result<Vec<u8>> {
self.validate()?;
let export_bytes = serde_json::to_vec(self).map_err(Error::other)?;
let persisted = PersistedIlmRecoveryExport {
schema: ILM_RECOVERY_EXPORT_SCHEMA.to_string(),
content_sha256: hex_sha256(&export_bytes, ToOwned::to_owned),
export: self.clone(),
};
let encoded = serde_json::to_vec(&persisted).map_err(Error::other)?;
if encoded.len() > MAX_ILM_RECOVERY_EXPORT_SIZE {
return Err(Error::other("encoded ILM recovery export exceeds maximum size"));
}
Ok(encoded)
}
pub fn decode(expected_export_id: &str, data: &[u8]) -> Result<Self> {
validate_sha256(expected_export_id, "ILM recovery export ID is invalid")?;
if data.len() > MAX_ILM_RECOVERY_EXPORT_SIZE {
return Err(Error::other("encoded ILM recovery export exceeds maximum size"));
}
let persisted: PersistedIlmRecoveryExport = serde_json::from_slice(data).map_err(Error::other)?;
if persisted.schema != ILM_RECOVERY_EXPORT_SCHEMA {
return Err(Error::other("ILM recovery export schema is unsupported"));
}
validate_sha256(&persisted.content_sha256, "ILM recovery export checksum is invalid")?;
let export_bytes = serde_json::to_vec(&persisted.export).map_err(Error::other)?;
if hex_sha256(&export_bytes, ToOwned::to_owned) != persisted.content_sha256 {
return Err(Error::other("ILM recovery export checksum mismatch"));
}
persisted.export.validate()?;
if persisted.export.export_id != expected_export_id {
return Err(Error::other("ILM recovery export ID does not match record key"));
}
Ok(persisted.export)
}
}
pub fn recovery_export_record_object_name(protocol: IlmRecoveryProtocol, export_id: &str) -> Result<String> {
validate_sha256(export_id, "ILM recovery export ID is invalid")?;
Ok(format!(
"{}/{}/{}/{}/{}.json",
ILM_RECOVERY_EXPORT_PREFIX,
protocol.as_str(),
&export_id[..2],
&export_id[2..4],
export_id
))
}
pub fn recovery_export_id_from_record_object_name(object: &str) -> Result<(IlmRecoveryProtocol, String)> {
let suffix = object
.strip_prefix(ILM_RECOVERY_EXPORT_PREFIX)
.and_then(|suffix| suffix.strip_prefix('/'))
.ok_or_else(|| Error::other("ILM recovery export path has wrong prefix"))?;
let mut parts = suffix.split('/');
let protocol = match parts.next() {
Some("tier_delete_journal") => IlmRecoveryProtocol::TierDeleteJournal,
_ => return Err(Error::other("ILM recovery export protocol is invalid")),
};
let shard_a = parts
.next()
.ok_or_else(|| Error::other("ILM recovery export path is incomplete"))?;
let shard_b = parts
.next()
.ok_or_else(|| Error::other("ILM recovery export path is incomplete"))?;
let export_id = parts
.next()
.and_then(|name| name.strip_suffix(".json"))
.ok_or_else(|| Error::other("ILM recovery export suffix is invalid"))?;
if parts.next().is_some() {
return Err(Error::other("ILM recovery export path is not canonical"));
}
validate_sha256(export_id, "ILM recovery export ID is invalid")?;
if shard_a != &export_id[..2] || shard_b != &export_id[2..4] {
return Err(Error::other("ILM recovery export shard does not match export ID"));
}
Ok((protocol, export_id.to_string()))
}
pub async fn inspect_recovery_export_observation(api: Arc<ECStore>, control_id: &str) -> Result<IlmRecoveryExportObservation> {
let proof = acquire_ilm_recovery_export_fleet_proof()
.await
.ok_or_else(|| Error::other("ILM recovery export fleet proof is unavailable"))?;
let observed_control = load_exportable_control(api.clone(), control_id).await?;
let observed_source = observe_export_source(
api,
&observed_control.control.identity.canonical_source_path,
&observed_control.control.observed_source_generation.source_schema,
)
.await?;
if !observed_source.is_consistent()
|| observed_source.generation != observed_control.control.observed_source_generation
|| !ilm_recovery_export_fleet_proof_matches(&proof).await
{
return Err(Error::other("ILM recovery export observation changed or is incomplete"));
}
Ok(IlmRecoveryExportObservation {
control_id: control_id.to_string(),
protocol: observed_control.control.identity.protocol,
control_etag: observed_control.etag,
control_revision: observed_control.control.revision,
classification: observed_control.control.classification,
canonical_source_path: observed_control.control.identity.canonical_source_path,
source_generation: observed_source.generation,
topology_generation: ilm_recovery_export_topology_generation(&proof),
member_epochs_sha256: ilm_recovery_export_member_epochs_sha256(&proof),
})
}
pub async fn create_recovery_export(
api: Arc<ECStore>,
observation: &IlmRecoveryExportObservation,
creator_sha256: &str,
) -> Result<IlmRecoveryExportCreated> {
validate_sha256(creator_sha256, "ILM recovery export creator digest is invalid")?;
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, EXPORT_ADMISSION_LOCK).await?;
let admission_guard = lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
let proof = acquire_ilm_recovery_export_fleet_proof()
.await
.ok_or_else(|| Error::other("ILM recovery export fleet proof is unavailable"))?;
if ilm_recovery_export_topology_generation(&proof) != observation.topology_generation
|| ilm_recovery_export_member_epochs_sha256(&proof) != observation.member_epochs_sha256
{
return Err(Error::PreconditionFailed);
}
let control_object = recovery_control_record_object_name(IlmRecoveryProtocol::TierDeleteJournal, &observation.control_id)
.map_err(Error::other)?;
let control_lock = api.new_ns_lock(RUSTFS_META_BUCKET, &control_object).await?;
let control_guard = control_lock
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
.await?;
let source_lock = api
.new_ns_lock(RUSTFS_META_BUCKET, &observation.canonical_source_path)
.await?;
let source_guard = source_lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
let locks_current = || !admission_guard.is_lock_lost() && !control_guard.is_lock_lost() && !source_guard.is_lock_lost();
let (current, current_source_bytes) = current_observation_under_proof_no_lock(api.clone(), observation, &proof).await?;
if &current != observation || !locks_current() {
return Err(Error::PreconditionFailed);
}
let current_source_base64 = base64_simd::STANDARD.encode_to_string(current_source_bytes);
let candidate_export_id = recovery_export_id(&current.control_id, &current.source_generation)?;
let object = recovery_export_record_object_name(current.protocol, &candidate_export_id)?;
match load_recovery_export_decoded(api.clone(), &candidate_export_id).await {
Ok((existing, export)) if export_matches_observation(&export, observation) => {
if !locks_current() || !ilm_recovery_export_fleet_proof_matches(&proof).await {
return Err(Error::PreconditionFailed);
}
api.record_durable_ilm_decommission_progress(&object, &existing.encoded)
.await?;
if !locks_current() {
return Err(Error::PreconditionFailed);
}
return Ok(existing.with_replayed());
}
Ok(_) => return Err(Error::PreconditionFailed),
Err(Error::ConfigNotFound) => {}
Err(err) => return Err(err),
}
let inventory = collect_export_inventory(api.clone()).await?;
if !locks_current() || !ilm_recovery_export_fleet_proof_matches(&proof).await {
return Err(Error::PreconditionFailed);
}
let created_at_unix_nanos = now_unix_nanos()?;
let export = build_export_from_source(&current, creator_sha256, created_at_unix_nanos, &current_source_base64)?;
let encoded = export.encode()?;
inventory.check(creator_sha256, encoded.len(), created_at_unix_nanos)?;
let mut write_options = ObjectOptions {
max_parity: true,
write_completion: WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()),
..Default::default()
}),
..Default::default()
};
write_options.add_namespace_lock_guard(&admission_guard);
write_options.add_namespace_lock_guard(&control_guard);
write_options.add_namespace_lock_guard(&source_guard);
if !locks_current() {
return Err(Error::PreconditionFailed);
}
let write_result = config_boundary::save_config_with_opts(api.clone(), &object, encoded.clone(), &write_options).await;
let stored = match load_recovery_export(api.clone(), &export.export_id).await {
Ok(stored) if stored.encoded == encoded => stored,
Ok(_) => return Err(Error::PreconditionFailed),
Err(read_err) => return Err(write_result.err().unwrap_or(read_err)),
};
if !locks_current() || !ilm_recovery_export_fleet_proof_matches(&proof).await {
return Err(Error::PreconditionFailed);
}
api.record_durable_ilm_decommission_progress(&object, &encoded).await?;
if !locks_current() {
return Err(Error::PreconditionFailed);
}
Ok(stored)
}
pub async fn load_recovery_export(api: Arc<ECStore>, export_id: &str) -> Result<IlmRecoveryExportCreated> {
let (created, _) = load_recovery_export_decoded(api, export_id).await?;
Ok(created)
}
async fn load_recovery_export_decoded(
api: Arc<ECStore>,
export_id: &str,
) -> Result<(IlmRecoveryExportCreated, IlmRecoveryExport)> {
let object = recovery_export_record_object_name(IlmRecoveryProtocol::TierDeleteJournal, export_id)?;
let encoded = config_boundary::read_config_limited_preserve_empty(api, &object, MAX_ILM_RECOVERY_EXPORT_SIZE).await?;
let export = IlmRecoveryExport::decode(export_id, &encoded)?;
let content_sha256 = hex_sha256(&encoded, ToOwned::to_owned);
Ok((
IlmRecoveryExportCreated {
export_id: export.export_id.clone(),
content_sha256,
encoded,
replayed: false,
},
export,
))
}
impl IlmRecoveryExportCreated {
fn with_replayed(mut self) -> Self {
self.replayed = true;
self
}
}
async fn load_exportable_control(api: Arc<ECStore>, control_id: &str) -> Result<ObservedIlmRecoveryControl> {
load_exportable_control_with_options(api, control_id, &ObjectOptions::default()).await
}
async fn load_exportable_control_no_lock(api: Arc<ECStore>, control_id: &str) -> Result<ObservedIlmRecoveryControl> {
load_exportable_control_with_options(
api,
control_id,
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
}
async fn load_exportable_control_with_options(
api: Arc<ECStore>,
control_id: &str,
options: &ObjectOptions,
) -> Result<ObservedIlmRecoveryControl> {
let object = recovery_control_record_object_name(IlmRecoveryProtocol::TierDeleteJournal, control_id).map_err(Error::other)?;
let (data, metadata) =
config_boundary::read_config_limited_preserve_empty_with_metadata(api, &object, options, MAX_ILM_RECOVERY_CONTROL_SIZE)
.await?;
let etag = metadata
.etag
.filter(|etag| !etag.trim().is_empty())
.ok_or_else(|| Error::other("ILM recovery control is missing an ETag"))?;
let control = IlmRecoveryControl::decode(control_id, &data).map_err(Error::other)?;
if control.identity.protocol != IlmRecoveryProtocol::TierDeleteJournal
|| control.classification != IlmRecoveryClassification::RetainedAmbiguous
|| !is_legacy_export_schema(&control.observed_source_generation.source_schema)
{
return Err(Error::other("ILM recovery control is not exportable"));
}
Ok(ObservedIlmRecoveryControl { control, etag })
}
async fn current_observation_under_proof_no_lock(
api: Arc<ECStore>,
expected: &IlmRecoveryExportObservation,
proof: &crate::services::notification_sys::IlmRecoveryExportFleetProofToken,
) -> Result<(IlmRecoveryExportObservation, Vec<u8>)> {
let observed_control = load_exportable_control_no_lock(api.clone(), &expected.control_id).await?;
let observed_source = observe_export_source_no_lock(
api,
&observed_control.control.identity.canonical_source_path,
&observed_control.control.observed_source_generation.source_schema,
)
.await?;
let source_bytes = observed_source
.canonical_data
.clone()
.ok_or_else(|| Error::other("ILM recovery export source copies diverge"))?;
if !observed_source.is_consistent()
|| observed_source.generation != observed_control.control.observed_source_generation
|| !ilm_recovery_export_fleet_proof_matches(proof).await
{
return Err(Error::PreconditionFailed);
}
Ok((
IlmRecoveryExportObservation {
control_id: expected.control_id.clone(),
protocol: observed_control.control.identity.protocol,
control_etag: observed_control.etag,
control_revision: observed_control.control.revision,
classification: observed_control.control.classification,
canonical_source_path: observed_control.control.identity.canonical_source_path,
source_generation: observed_source.generation,
topology_generation: ilm_recovery_export_topology_generation(proof),
member_epochs_sha256: ilm_recovery_export_member_epochs_sha256(proof),
},
source_bytes,
))
}
async fn observe_export_source(
api: Arc<ECStore>,
canonical_path: &str,
source_schema: &str,
) -> Result<ObservedIlmRecoverySource> {
if canonical_path.is_empty()
|| canonical_path.starts_with('/')
|| canonical_path.ends_with('/')
|| canonical_path.split('/').any(str::is_empty)
|| !is_legacy_export_schema(source_schema)
{
return Err(Error::other("ILM recovery export source identity is invalid"));
}
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, canonical_path).await?;
let _guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
observe_export_source_no_lock(api, canonical_path, source_schema).await
}
async fn observe_export_source_no_lock(
api: Arc<ECStore>,
canonical_path: &str,
source_schema: &str,
) -> Result<ObservedIlmRecoverySource> {
let mut copies = Vec::new();
let mut canonical: Option<(String, String, Vec<u8>)> = None;
let mut consistent = true;
for set in api.all_set_disks() {
let authority = format!("pool-{}/set-{}", set.pool_index, set.set_index);
let result = config_boundary::read_config_limited_preserve_empty_with_metadata(
set,
canonical_path,
&ObjectOptions {
no_lock: true,
..Default::default()
},
MAX_LEGACY_TIER_DELETE_SOURCE_SIZE,
)
.await;
match result {
Ok((data, metadata)) => {
if data.is_empty() || data.len() > MAX_LEGACY_TIER_DELETE_SOURCE_SIZE {
return Err(Error::other("ILM recovery export source exceeds its protocol size limit"));
}
validate_legacy_tier_delete_recovery_source(canonical_path, source_schema, &data)?;
let etag = metadata
.etag
.filter(|etag| !etag.trim().is_empty())
.ok_or_else(|| Error::other("ILM recovery export source copy is missing an ETag"))?;
let content_sha256 = hex_sha256(&data, ToOwned::to_owned);
let encoded_len =
u64::try_from(data.len()).map_err(|_| Error::other("ILM recovery export source length does not fit u64"))?;
copies.push(IlmRecoverySourceCopy {
authority,
canonical_path: canonical_path.to_string(),
etag: etag.clone(),
encoded_len,
content_sha256: content_sha256.clone(),
});
match canonical.as_ref() {
Some((first_etag, first_digest, first_data)) => {
consistent &= first_etag == &etag && first_digest == &content_sha256 && first_data == &data;
}
None => canonical = Some((etag, content_sha256, data)),
}
}
Err(err) if export_source_is_missing(&err) => {}
Err(err) => return Err(err),
}
}
let Some((source_etag, content_sha256, source_bytes)) = canonical else {
return Err(Error::ConfigNotFound);
};
let generation =
IlmRecoverySourceGeneration::new(source_schema, source_etag, content_sha256, copies).map_err(Error::other)?;
Ok(ObservedIlmRecoverySource {
generation,
canonical_data: consistent.then_some(source_bytes),
})
}
fn export_source_is_missing(err: &Error) -> bool {
matches!(
err,
Error::ConfigNotFound | Error::FileNotFound | Error::ObjectNotFound(_, _) | Error::VersionNotFound(_, _, _)
)
}
fn build_export_from_source(
observation: &IlmRecoveryExportObservation,
creator_sha256: &str,
created_at_unix_nanos: i64,
source_bytes_base64: &str,
) -> Result<IlmRecoveryExport> {
let retain_until_unix_nanos = created_at_unix_nanos
.checked_add(EXPORT_RETENTION_NANOS)
.ok_or_else(|| Error::other("ILM recovery export retention timestamp overflow"))?;
let export = IlmRecoveryExport {
export_id: recovery_export_id(&observation.control_id, &observation.source_generation)?,
control_id: observation.control_id.clone(),
protocol: observation.protocol,
control_etag: observation.control_etag.clone(),
control_revision: observation.control_revision,
classification: observation.classification,
canonical_source_path: observation.canonical_source_path.clone(),
source_generation: observation.source_generation.clone(),
topology_generation: observation.topology_generation.clone(),
member_epochs_sha256: observation.member_epochs_sha256.clone(),
creator_sha256: creator_sha256.to_string(),
created_at_unix_nanos,
retain_until_unix_nanos,
source_bytes_base64: source_bytes_base64.to_string(),
};
export.validate()?;
Ok(export)
}
pub(crate) fn recovery_export_id(control_id: &str, generation: &IlmRecoverySourceGeneration) -> Result<String> {
validate_sha256(control_id, "ILM recovery export control ID is invalid")?;
validate_sha256(&generation.content_sha256, "ILM recovery export source checksum is invalid")?;
validate_sha256(&generation.copy_set_sha256, "ILM recovery export copy-set checksum is invalid")?;
let mut data = Vec::new();
for part in [control_id, &generation.content_sha256, &generation.copy_set_sha256] {
data.extend_from_slice(&(part.len() as u64).to_be_bytes());
data.extend_from_slice(part.as_bytes());
}
Ok(hex_sha256(&data, ToOwned::to_owned))
}
fn export_matches_observation(export: &IlmRecoveryExport, observation: &IlmRecoveryExportObservation) -> bool {
export.control_id == observation.control_id
&& export.protocol == observation.protocol
&& export.classification == observation.classification
&& export.canonical_source_path == observation.canonical_source_path
&& export.source_generation == observation.source_generation
}
#[derive(Debug, Default)]
struct IlmRecoveryExportInventory {
count: usize,
bytes: u64,
creations: Vec<(i64, String)>,
}
impl IlmRecoveryExportInventory {
fn check(&self, creator_sha256: &str, candidate_len: usize, now: i64) -> Result<()> {
let recent_after = now.saturating_sub(60 * 1_000_000_000);
let cluster_recent = self
.creations
.iter()
.filter(|(created_at, _)| *created_at > recent_after)
.count();
let actor_recent = self
.creations
.iter()
.filter(|(created_at, creator)| *created_at > recent_after && creator == creator_sha256)
.count();
check_export_admission(self.count, self.bytes, actor_recent, cluster_recent, candidate_len)
}
}
async fn collect_export_inventory(api: Arc<ECStore>) -> Result<IlmRecoveryExportInventory> {
let mut marker = None;
let mut seen_markers = HashSet::new();
let mut inventory = IlmRecoveryExportInventory::default();
loop {
let page = api
.clone()
.list_objects_v2(
RUSTFS_META_BUCKET,
&format!("{ILM_RECOVERY_EXPORT_PREFIX}/"),
marker.clone(),
None,
1_000,
false,
None,
false,
)
.await?;
for object in page.objects {
let (_, export_id) = recovery_export_id_from_record_object_name(&object.name)?;
let (stored, export) = load_recovery_export_decoded(api.clone(), &export_id).await?;
inventory.count = inventory
.count
.checked_add(1)
.ok_or_else(|| Error::other("ILM recovery export count overflow"))?;
inventory.bytes = inventory
.bytes
.checked_add(u64::try_from(stored.encoded.len()).map_err(|_| Error::other("ILM recovery export size overflow"))?)
.ok_or_else(|| Error::other("ILM recovery export byte total overflow"))?;
inventory
.creations
.push((export.created_at_unix_nanos, export.creator_sha256));
}
if !page.is_truncated {
break;
}
let next = page
.next_continuation_token
.ok_or_else(|| Error::other("ILM recovery export inventory omitted its continuation marker"))?;
marker = Some(record_export_inventory_marker(&mut seen_markers, next)?);
}
Ok(inventory)
}
fn record_export_inventory_marker(seen_markers: &mut HashSet<String>, next: String) -> Result<String> {
if !seen_markers.insert(next.clone()) {
return Err(Error::other("ILM recovery export inventory repeated its continuation marker"));
}
Ok(next)
}
fn check_export_admission(
count: usize,
bytes: u64,
actor_recent: usize,
cluster_recent: usize,
candidate_len: usize,
) -> Result<()> {
let candidate_len = u64::try_from(candidate_len).map_err(|_| Error::other("ILM recovery export size does not fit u64"))?;
if count >= MAX_ILM_RECOVERY_EXPORTS
|| bytes
.checked_add(candidate_len)
.is_none_or(|total| total > MAX_ILM_RECOVERY_EXPORT_BYTES)
|| actor_recent >= MAX_ACTOR_EXPORTS_PER_MINUTE
|| cluster_recent >= MAX_CLUSTER_EXPORTS_PER_MINUTE
{
return Err(Error::SlowDown);
}
Ok(())
}
fn is_legacy_export_schema(schema: &str) -> bool {
matches!(schema, TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA | TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA)
}
fn validate_sha256(value: &str, message: &'static str) -> Result<()> {
if !is_sha256_checksum(value)
|| value
.bytes()
.any(|byte| byte.is_ascii_hexdigit() && byte.is_ascii_uppercase())
{
return Err(Error::other(message));
}
Ok(())
}
fn now_unix_nanos() -> Result<i64> {
i64::try_from(time::OffsetDateTime::now_utc().unix_timestamp_nanos())
.map_err(|_| Error::other("ILM recovery export timestamp does not fit i64"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bucket::lifecycle::recovery_control::IlmRecoverySourceCopy;
const PINNED_V1_EXPORT: &[u8] = br#"{"schema":"rustfs-ilm-recovery-export-v1","content_sha256":"3dfb3ec3892256e909de1211c1a963ca7008963ff32b3a869f7161a7b9b44028","export":{"export_id":"2b78e7a825bfc2edbf7f773d0b6ed3bf93e360ff1702d73a449109c11bfaa105","control_id":"0fcd568a5cb9bdb4677b69354b11ee415af8f784519cff3da49a26f84eaee7f2","protocol":"tier_delete_journal","control_etag":"control-etag","control_revision":1,"classification":"retained_ambiguous","canonical_source_path":"ilm/tier-delete-journal/872072554f66ab326f10ce7adbae11422b7a4b0663aa7112d6061a8f6ed41b94.json","source_generation":{"source_schema":"rustfs-tier-delete-journal-v1","source_etag":"etag-a","content_sha256":"0e0b010ebdeeb7b41473fe8575e989d6bb1303c0ca551dd984e9400f0ae306bd","copy_set_sha256":"5a7406115b6c3923ffe79dcd1f43ccae7beed786e557163f019dd10ec409a653","copies":[{"authority":"pool-0/set-0","canonical_path":"ilm/tier-delete-journal/872072554f66ab326f10ce7adbae11422b7a4b0663aa7112d6061a8f6ed41b94.json","etag":"etag-a","encoded_len":81,"content_sha256":"0e0b010ebdeeb7b41473fe8575e989d6bb1303c0ca551dd984e9400f0ae306bd"}]},"topology_generation":"e6e2b826e31fca5c36125c48f130dcb6f961e698ff8a8776a1f290cf0892e8e6","member_epochs_sha256":"612dd8a861161819a4ad8f6f3e2a0567602877c043a2353ca933a13c78dc0ed4","creator_sha256":"50c9c4aeb40b5b206b6d98f516f8b8c0efd29ce2e56a76b345fb9240c225a1b7","created_at_unix_nanos":1000000000,"retain_until_unix_nanos":7776001000000000,"source_bytes_base64":"eyJ2ZXJzaW9uIjoxLCJvYmpfbmFtZSI6ImxlZ2FjeS9yZW1vdGUiLCJ2ZXJzaW9uX2lkIjoib3BhcXVlIiwidGllcl9uYW1lIjoiV0FSTSJ9"}}"#;
fn legacy_source() -> Vec<u8> {
br#"{"version":1,"obj_name":"legacy/remote","version_id":"opaque","tier_name":"WARM"}"#.to_vec()
}
fn observation() -> IlmRecoveryExportObservation {
let source = legacy_source();
let source_path = super::super::tier_delete_journal::tier_delete_journal_object_name(
&super::super::tier_delete_journal::decode_tier_delete_journal_entry(&source).expect("legacy fixture should decode"),
);
let source_sha256 = hex_sha256(&source, ToOwned::to_owned);
let generation = IlmRecoverySourceGeneration::new(
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA,
"etag-a",
source_sha256.clone(),
vec![IlmRecoverySourceCopy {
authority: "pool-0/set-0".to_string(),
canonical_path: source_path.clone(),
etag: "etag-a".to_string(),
encoded_len: source.len() as u64,
content_sha256: source_sha256,
}],
)
.expect("generation should be valid");
IlmRecoveryExportObservation {
control_id: hex_sha256(b"control", ToOwned::to_owned),
protocol: IlmRecoveryProtocol::TierDeleteJournal,
control_etag: "control-etag".to_string(),
control_revision: 1,
classification: IlmRecoveryClassification::RetainedAmbiguous,
canonical_source_path: source_path,
source_generation: generation,
topology_generation: hex_sha256(b"topology", ToOwned::to_owned),
member_epochs_sha256: hex_sha256(b"epochs", ToOwned::to_owned),
}
}
#[test]
fn recovery_export_round_trip_is_strict_and_deterministic() {
let observed = observation();
let creator = hex_sha256(b"actor", ToOwned::to_owned);
let export = build_export_from_source(
&observed,
&creator,
1_000_000_000,
&base64_simd::STANDARD.encode_to_string(legacy_source()),
)
.expect("export should be valid");
assert_eq!(
export.export_id,
recovery_export_id(&observed.control_id, &observed.source_generation).unwrap()
);
let encoded = export.encode().expect("export should encode");
assert_eq!(encoded, PINNED_V1_EXPORT, "v1 export wire format must remain pinned");
assert_eq!(IlmRecoveryExport::decode(&export.export_id, &encoded).unwrap(), export);
assert_eq!(
IlmRecoveryExport::decode("2b78e7a825bfc2edbf7f773d0b6ed3bf93e360ff1702d73a449109c11bfaa105", PINNED_V1_EXPORT)
.unwrap(),
export,
);
let path = recovery_export_record_object_name(export.protocol, &export.export_id).unwrap();
let durable = super::super::durable_namespace::validate_durable_ilm_record(&path, &encoded)
.expect("export should be registered as a durable ILM record");
assert_eq!(durable.namespace, "recovery-export");
assert_eq!(durable.id_kind, "export_id");
assert_eq!(durable.id, export.export_id);
let mut wrong_source = export.clone();
wrong_source.source_bytes_base64 = base64_simd::STANDARD.encode_to_string(b"changed");
assert!(wrong_source.encode().is_err());
let mut persisted: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
persisted["unknown"] = serde_json::json!(true);
assert!(IlmRecoveryExport::decode(&export.export_id, &serde_json::to_vec(&persisted).unwrap()).is_err());
}
#[test]
fn export_inventory_rejects_non_adjacent_continuation_cycles() {
let mut seen = HashSet::new();
assert_eq!(record_export_inventory_marker(&mut seen, "a".to_string()).unwrap(), "a");
assert_eq!(record_export_inventory_marker(&mut seen, "b".to_string()).unwrap(), "b");
record_export_inventory_marker(&mut seen, "a".to_string())
.expect_err("a non-adjacent continuation marker cycle must fail closed");
}
#[test]
fn recovery_export_path_rejects_noncanonical_shards() {
let id = hex_sha256(b"export", ToOwned::to_owned);
let path = recovery_export_record_object_name(IlmRecoveryProtocol::TierDeleteJournal, &id).unwrap();
assert_eq!(recovery_export_id_from_record_object_name(&path).unwrap().1, id);
let wrong_shard = path.replacen(&format!("/{}/", &id[..2]), "/zz/", 1);
assert!(recovery_export_id_from_record_object_name(&wrong_shard).is_err());
}
#[test]
fn canonical_replay_survives_fleet_rotation_but_not_source_change() {
let observed = observation();
let creator = hex_sha256(b"actor", ToOwned::to_owned);
let export = build_export_from_source(
&observed,
&creator,
1_000_000_000,
&base64_simd::STANDARD.encode_to_string(legacy_source()),
)
.unwrap();
let mut rotated = observed;
rotated.control_etag = "new-control-etag".to_string();
rotated.control_revision += 1;
rotated.topology_generation = hex_sha256(b"new-topology", ToOwned::to_owned);
rotated.member_epochs_sha256 = hex_sha256(b"new-members", ToOwned::to_owned);
assert!(export_matches_observation(&export, &rotated));
rotated.source_generation.content_sha256 = hex_sha256(b"changed", ToOwned::to_owned);
assert!(!export_matches_observation(&export, &rotated));
}
#[test]
fn export_admission_enforces_exact_count_byte_and_rate_boundaries() {
assert!(check_export_admission(9_999, MAX_ILM_RECOVERY_EXPORT_BYTES - 1, 9, 99, 1).is_ok());
assert!(check_export_admission(10_000, 0, 0, 0, 1).is_err());
assert!(check_export_admission(0, MAX_ILM_RECOVERY_EXPORT_BYTES, 0, 0, 1).is_err());
assert!(check_export_admission(0, 0, 10, 0, 1).is_err());
assert!(check_export_admission(0, 0, 0, 100, 1).is_err());
}
}
@@ -35,6 +35,10 @@ use crate::bucket::lifecycle::config_boundary;
use crate::bucket::lifecycle::durable_namespace::{ use crate::bucket::lifecycle::durable_namespace::{
TIER_DELETE_JOURNAL_NAMESPACE, TIER_DELETE_JOURNAL_V6_NAMESPACE, validate_durable_ilm_record, TIER_DELETE_JOURNAL_NAMESPACE, TIER_DELETE_JOURNAL_V6_NAMESPACE, validate_durable_ilm_record,
}; };
use crate::bucket::lifecycle::recovery_control::{
IlmRecoveryClassification, IlmRecoveryControl, IlmRecoveryControlIdentity, IlmRecoveryErrorCode, IlmRecoveryProtocol,
load_recovery_control, observe_recovery_source, save_recovery_control_if_absent,
};
use crate::bucket::lifecycle::runtime_boundary; use crate::bucket::lifecycle::runtime_boundary;
use crate::bucket::lifecycle::tier_sweeper::{ use crate::bucket::lifecycle::tier_sweeper::{
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity, Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
@@ -78,6 +82,13 @@ const TIER_DELETE_DISPATCH_MEMBER_DELETE_CONCURRENCY: usize = 32;
const TIER_DELETE_DISPATCH_PREPARE_CONCURRENCY: usize = 16; const TIER_DELETE_DISPATCH_PREPARE_CONCURRENCY: usize = 16;
const TIER_DELETE_DISPATCH_CAS_CONCURRENCY: usize = 32; const TIER_DELETE_DISPATCH_CAS_CONCURRENCY: usize = 32;
const TIER_DELETE_JOURNAL_VERSION: u8 = 2; const TIER_DELETE_JOURNAL_VERSION: u8 = 2;
pub(crate) const TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA: &str = "rustfs-tier-delete-journal-v1";
pub(crate) const TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA: &str = "rustfs-tier-delete-journal-v2";
const TIER_DELETE_JOURNAL_UNKNOWN_RECOVERY_SCHEMA: &str = "rustfs-tier-delete-journal-unknown";
const TIER_DELETE_JOURNAL_V1_RECOVERY_CLASS: &str = "tier_delete_journal_v1";
const TIER_DELETE_JOURNAL_V2_RECOVERY_CLASS: &str = "tier_delete_journal_v2";
const TIER_DELETE_JOURNAL_CORRUPT_RECOVERY_CLASS: &str = "tier_delete_journal_corrupt";
const CORRUPT_TIER_DELETE_JOURNAL_IDENTITY: &str = "corrupt";
const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3; const TIER_DELETE_JOURNAL_EXACT_VERSION: u8 = 3;
const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4; const TIER_DELETE_JOURNAL_STATE_VERSION: u8 = 4;
const TIER_DELETE_JOURNAL_TRANSACTION_VERSION: u8 = 5; const TIER_DELETE_JOURNAL_TRANSACTION_VERSION: u8 = 5;
@@ -873,6 +884,23 @@ struct PersistedTierDeleteJournalEntry {
} }
impl PersistedTierDeleteJournalEntry { impl PersistedTierDeleteJournalEntry {
fn validate_legacy_recovery_shape(&self) -> Result<()> {
let has_later_version_fields = self.version_id_exact.is_some()
|| self.version_state.is_some()
|| self.state.is_some()
|| self.source.is_some()
|| self.dispatch.is_some();
match self.version {
1 if self.backend_identity.is_none() && !has_later_version_fields => Ok(()),
TIER_DELETE_JOURNAL_VERSION if self.backend_identity.is_some() && !has_later_version_fields => Ok(()),
1 => Err(Error::other("tier delete journal v1 entry contains fields from a later version")),
TIER_DELETE_JOURNAL_VERSION => Err(Error::other(
"tier delete journal v2 entry is missing its identity or contains fields from a later version",
)),
_ => Err(Error::other("tier delete journal is not an exportable legacy version")),
}
}
fn from_jentry(je: &Jentry) -> Result<Self> { fn from_jentry(je: &Jentry) -> Result<Self> {
validate_version_state(je.version_state, &je.version_id, je.version_id_exact)?; validate_version_state(je.version_state, &je.version_id, je.version_id_exact)?;
let legacy_unknown = je.version_state == rustfs_filemeta::TransitionVersionState::Unknown; let legacy_unknown = je.version_state == rustfs_filemeta::TransitionVersionState::Unknown;
@@ -1733,6 +1761,7 @@ async fn save_config_if_none_fenced(
data.clone(), data.clone(),
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()), if_none_match: Some("*".to_string()),
..Default::default() ..Default::default()
@@ -1832,6 +1861,7 @@ async fn save_decommission_manifest_checkpoint_if_match(
let mut opts = ObjectOptions { let mut opts = ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
no_lock: true, no_lock: true,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_match: Some(observed_etag), if_match: Some(observed_etag),
@@ -1960,6 +1990,7 @@ async fn save_config_if_match_fenced(
data.clone(), data.clone(),
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_match: Some(etag.to_string()), if_match: Some(etag.to_string()),
..Default::default() ..Default::default()
@@ -3780,6 +3811,7 @@ where
data.clone(), data.clone(),
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()), if_none_match: Some("*".to_string()),
..Default::default() ..Default::default()
@@ -3869,6 +3901,7 @@ where
data.clone(), data.clone(),
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_match: Some(etag), if_match: Some(etag),
..Default::default() ..Default::default()
@@ -3893,6 +3926,7 @@ where
data.clone(), data.clone(),
&ObjectOptions { &ObjectOptions {
max_parity: true, max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
http_preconditions: Some(HTTPPreconditions { http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()), if_none_match: Some("*".to_string()),
..Default::default() ..Default::default()
@@ -5503,6 +5537,146 @@ enum TierDeleteJournalEntryRecoveryOutcome {
Failed, Failed,
} }
fn canonical_legacy_tier_delete_journal_identity(object_name: &str) -> Option<&str> {
let identity = object_name
.strip_prefix(TIER_DELETE_JOURNAL_LEGACY_PREFIX)?
.strip_suffix(".json")?;
(rustfs_utils::crypto::is_sha256_checksum(identity)
&& !identity
.bytes()
.any(|byte| byte.is_ascii_hexdigit() && byte.is_ascii_uppercase()))
.then_some(identity)
}
pub(crate) fn validate_legacy_tier_delete_recovery_path(object_name: &str) -> Result<()> {
canonical_legacy_tier_delete_journal_identity(object_name)
.map(|_| ())
.ok_or_else(|| Error::other("legacy tier delete journal path is not canonical"))
}
fn legacy_tier_delete_recovery_descriptor(entry: &Jentry) -> Option<(&'static str, &'static str)> {
match entry.persisted_version {
1 => Some((TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V1_RECOVERY_CLASS)),
TIER_DELETE_JOURNAL_VERSION => Some((TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_CLASS)),
_ => None,
}
}
pub(crate) fn validate_legacy_tier_delete_recovery_source(object_name: &str, source_schema: &str, data: &[u8]) -> Result<()> {
validate_legacy_tier_delete_recovery_path(object_name)?;
let persisted: PersistedTierDeleteJournalEntry =
serde_json::from_slice(data).map_err(|err| Error::other_with_context("decode tier delete journal failed", err))?;
persisted.validate_legacy_recovery_shape()?;
let entry = persisted.into_jentry()?;
let Some((decoded_schema, _)) = legacy_tier_delete_recovery_descriptor(&entry) else {
return Err(Error::other("tier delete journal is not an exportable legacy version"));
};
if decoded_schema != source_schema || tier_delete_journal_object_name(&entry) != object_name {
return Err(Error::other("legacy tier delete journal identity does not match its recovery source"));
}
Ok(())
}
fn legacy_tier_delete_control_matches(
control: &IlmRecoveryControl,
identity: &IlmRecoveryControlIdentity,
generation: &crate::bucket::lifecycle::recovery_control::IlmRecoverySourceGeneration,
classification: IlmRecoveryClassification,
error_code: IlmRecoveryErrorCode,
) -> bool {
control.identity == *identity
&& control.observed_source_generation == *generation
&& control.classification == classification
&& control.last_error_code == error_code
&& control.owner.is_none()
&& control.attempt_count == 0
&& control.consecutive_failure_count == 0
}
fn legacy_tier_delete_control_is_scheduler_fence(control: &IlmRecoveryControl, identity: &IlmRecoveryControlIdentity) -> bool {
control.identity == *identity && control.owner.is_none() && !control.classification.permits_automatic_attempt()
}
async fn persist_legacy_tier_delete_recovery_control(
api: Arc<ECStore>,
object_name: &str,
observed_data: &[u8],
stable_operation_identity: String,
(source_schema, record_class): (&'static str, &'static str),
intended_classification: IlmRecoveryClassification,
intended_error_code: IlmRecoveryErrorCode,
) -> Result<()> {
let identity = IlmRecoveryControlIdentity {
protocol: IlmRecoveryProtocol::TierDeleteJournal,
canonical_source_path: object_name.to_string(),
stable_operation_identity,
record_class: record_class.to_string(),
};
let control_id = identity.source_operation_digest().map_err(Error::other)?;
match load_recovery_control(api.clone(), IlmRecoveryProtocol::TierDeleteJournal, &control_id).await {
Ok(observed) if legacy_tier_delete_control_is_scheduler_fence(&observed.control, &identity) => return Ok(()),
Ok(_) => return Err(Error::PreconditionFailed),
Err(Error::ConfigNotFound) => {}
Err(err) => return Err(err),
}
let source = observe_recovery_source(api.clone(), object_name, source_schema).await?;
let exact_source = source.is_consistent() && source.canonical_data.as_deref() == Some(observed_data);
let (classification, error_code) = if exact_source {
(intended_classification, intended_error_code)
} else {
(IlmRecoveryClassification::Corrupt, IlmRecoveryErrorCode::SourceDivergent)
};
let candidate = IlmRecoveryControl::new(
identity.clone(),
source.generation.clone(),
classification,
i64::try_from(time::OffsetDateTime::now_utc().unix_timestamp_nanos())
.map_err(|_| Error::other("tier delete journal recovery timestamp does not fit i64"))?,
error_code,
)
.map_err(Error::other)?;
match save_recovery_control_if_absent(api.clone(), &candidate).await {
Ok(()) | Err(Error::PreconditionFailed) => {}
Err(save_error) => match load_recovery_control(api.clone(), IlmRecoveryProtocol::TierDeleteJournal, &control_id).await {
Ok(observed)
if legacy_tier_delete_control_matches(
&observed.control,
&identity,
&source.generation,
classification,
error_code,
) =>
{
return Ok(());
}
Ok(_) | Err(_) => return Err(save_error),
},
}
let observed = load_recovery_control(api, IlmRecoveryProtocol::TierDeleteJournal, &control_id).await?;
if !legacy_tier_delete_control_matches(&observed.control, &identity, &source.generation, classification, error_code) {
return Err(Error::PreconditionFailed);
}
Ok(())
}
async fn retain_corrupt_legacy_tier_delete_journal(api: Arc<ECStore>, object_name: &str, data: &[u8]) -> Result<()> {
canonical_legacy_tier_delete_journal_identity(object_name)
.ok_or_else(|| Error::other("tier delete journal path is not canonical"))?;
persist_legacy_tier_delete_recovery_control(
api,
object_name,
data,
CORRUPT_TIER_DELETE_JOURNAL_IDENTITY.to_string(),
(TIER_DELETE_JOURNAL_UNKNOWN_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_CORRUPT_RECOVERY_CLASS),
IlmRecoveryClassification::Corrupt,
IlmRecoveryErrorCode::SourceCorrupt,
)
.await
}
async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: String) -> TierDeleteJournalEntryRecoveryOutcome { async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: String) -> TierDeleteJournalEntryRecoveryOutcome {
let data = match config_boundary::read_config(api.clone(), &object_name).await { let data = match config_boundary::read_config(api.clone(), &object_name).await {
Ok(data) => data, Ok(data) => data,
@@ -5523,6 +5697,22 @@ async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: Strin
let je = match decode_tier_delete_journal_entry(&data) { let je = match decode_tier_delete_journal_entry(&data) {
Ok(je) => je, Ok(je) => je,
Err(err) => { Err(err) => {
if canonical_legacy_tier_delete_journal_identity(&object_name).is_some() {
return match retain_corrupt_legacy_tier_delete_journal(api, &object_name, &data).await {
Ok(()) => TierDeleteJournalEntryRecoveryOutcome::Retained,
Err(control_error) => {
warn!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
journal_object = %object_name,
error = ?control_error,
"Failed to retain corrupt tier delete journal recovery control"
);
TierDeleteJournalEntryRecoveryOutcome::Failed
}
};
}
warn!( warn!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL, event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE, component = LOG_COMPONENT_ECSTORE,
@@ -5536,6 +5726,22 @@ async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: Strin
}; };
if tier_delete_journal_object_name(&je) != object_name { if tier_delete_journal_object_name(&je) != object_name {
if canonical_legacy_tier_delete_journal_identity(&object_name).is_some() {
return match retain_corrupt_legacy_tier_delete_journal(api, &object_name, &data).await {
Ok(()) => TierDeleteJournalEntryRecoveryOutcome::Retained,
Err(err) => {
warn!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
journal_object = %object_name,
error = ?err,
"Failed to retain mismatched tier delete journal recovery control"
);
TierDeleteJournalEntryRecoveryOutcome::Failed
}
};
}
warn!( warn!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL, event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE, component = LOG_COMPONENT_ECSTORE,
@@ -5546,6 +5752,36 @@ async fn recover_tier_delete_journal_entry(api: Arc<ECStore>, object_name: Strin
return TierDeleteJournalEntryRecoveryOutcome::Failed; return TierDeleteJournalEntryRecoveryOutcome::Failed;
} }
if let Some((source_schema, record_class)) = legacy_tier_delete_recovery_descriptor(&je) {
let stable_operation_identity = canonical_legacy_tier_delete_journal_identity(&object_name)
.expect("decoded legacy journal path was validated against its canonical object name")
.to_string();
return match persist_legacy_tier_delete_recovery_control(
api,
&object_name,
&data,
stable_operation_identity,
(source_schema, record_class),
IlmRecoveryClassification::RetainedAmbiguous,
IlmRecoveryErrorCode::RemoteVersionUnknown,
)
.await
{
Ok(()) => TierDeleteJournalEntryRecoveryOutcome::Retained,
Err(err) => {
warn!(
event = EVENT_LIFECYCLE_TIER_DELETE_JOURNAL,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
journal_object = %object_name,
error = ?err,
"Failed to retain legacy tier delete journal recovery control"
);
TierDeleteJournalEntryRecoveryOutcome::Failed
}
};
}
match api match api
.durable_ilm_terminal_receipt_covers_active_source(&object_name, &data) .durable_ilm_terminal_receipt_covers_active_source(&object_name, &data)
.await .await
@@ -5942,17 +6178,18 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE, TIER_DELETE_DISPATCH_PARENT_VERSION, PersistedTierDeleteJournalEntry, TIER_DELETE_DISPATCH_MANIFEST_VERSION, TIER_DELETE_DISPATCH_PARENT_RECORD_TYPE,
TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX, TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_DISPATCH_PARENT_VERSION, TIER_DELETE_JOURNAL_EXACT_VERSION, TIER_DELETE_JOURNAL_LEGACY_PREFIX,
TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION, TIER_DELETE_JOURNAL_V6_PREFIX, TIER_DELETE_JOURNAL_SOLE_OWNER_VERSION, TIER_DELETE_JOURNAL_STATE_VERSION, TIER_DELETE_JOURNAL_TRANSACTION_VERSION,
TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState, TierDeleteDispatchParent, TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA, TIER_DELETE_JOURNAL_V6_PREFIX,
TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery, TIER_DELETE_JOURNAL_VERSION, TierDeleteDispatchChunkBinding, TierDeleteDispatchManifest, TierDeleteDispatchManifestState,
TierDeleteDispatchParent, TierDeleteDispatchParentState, TierDeleteDispatchRecord, await_tier_delete_journal_recovery,
decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest, decode_tier_delete_dispatch_record, decode_tier_delete_journal_entry, encode_tier_delete_dispatch_manifest,
encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete, encode_tier_delete_dispatch_parent, encode_tier_delete_journal_entry, object_info_references_tier_delete,
record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity, record_tier_delete_journal_backend_identity, same_tier_delete_authorization_identity, same_tier_delete_journal_identity,
tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name, tier_delete_dispatch_child_matches_parent, tier_delete_dispatch_chunk_manifest_object_name,
tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name, tier_delete_dispatch_journal_set_digest, tier_delete_dispatch_manifest_object_name, tier_delete_journal_object_name,
tier_delete_source_matches_dispatch_scope, tier_delete_source_matches_dispatch_scope, validate_legacy_tier_delete_recovery_source,
}; };
use crate::bucket::lifecycle::tier_sweeper::{ use crate::bucket::lifecycle::tier_sweeper::{
Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity, Jentry, TierDeleteDispatchBinding, TierDeleteJournalState, TierDeleteSourceIdentity,
@@ -6411,6 +6648,72 @@ mod tests {
} }
} }
#[test]
fn legacy_recovery_export_rejects_fields_from_later_journal_versions() {
let later = bound_v6_journal_entry(TierDeleteJournalState::Prepared);
let v1 = PersistedTierDeleteJournalEntry {
version: 1,
obj_name: "remote/object".to_string(),
version_id: "opaque".to_string(),
tier_name: "WARM".to_string(),
backend_identity: None,
version_id_exact: None,
version_state: None,
state: None,
source: None,
dispatch: None,
};
let mut v2 = v1.clone();
v2.version = TIER_DELETE_JOURNAL_VERSION;
v2.backend_identity = Some([7; 32]);
let assert_rejected = |persisted: PersistedTierDeleteJournalEntry, schema: &str| {
let normalized = persisted
.clone()
.into_jentry()
.expect("the generic compatibility decoder should demonstrate the discarded field");
let object_name = tier_delete_journal_object_name(&normalized);
let encoded = serde_json::to_vec(&persisted).expect("mixed-version journal fixture should encode");
let err = validate_legacy_tier_delete_recovery_source(&object_name, schema, &encoded)
.expect_err("legacy recovery export must reject fields from later versions");
assert!(err.to_string().contains("later version"));
};
let mut invalid_v1 = Vec::new();
let mut with_backend = v1.clone();
with_backend.backend_identity = Some([7; 32]);
invalid_v1.push(with_backend);
for persisted in [&v1, &v2] {
let schema = if persisted.version == 1 {
TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA
} else {
TIER_DELETE_JOURNAL_V2_RECOVERY_SCHEMA
};
let mut invalid = Vec::new();
let mut with_exact = persisted.clone();
with_exact.version_id_exact = Some(false);
invalid.push(with_exact);
let mut with_version_state = persisted.clone();
with_version_state.version_state = Some(rustfs_filemeta::TransitionVersionState::Unknown);
invalid.push(with_version_state);
let mut with_state = persisted.clone();
with_state.state = Some(TierDeleteJournalState::Committed);
invalid.push(with_state);
let mut with_source = persisted.clone();
with_source.source = later.source.clone();
invalid.push(with_source);
let mut with_dispatch = persisted.clone();
with_dispatch.dispatch = later.dispatch.clone();
invalid.push(with_dispatch);
for record in invalid {
assert_rejected(record, schema);
}
}
for record in invalid_v1 {
assert_rejected(record, TIER_DELETE_JOURNAL_V1_RECOVERY_SCHEMA);
}
}
#[test] #[test]
fn tier_delete_journal_path_is_stable_and_sanitized() { fn tier_delete_journal_path_is_stable_and_sanitized() {
let je = journal_entry(); let je = journal_entry();
@@ -15,8 +15,6 @@
#![allow(unused_variables)] #![allow(unused_variables)]
#![allow(unused_mut)] #![allow(unused_mut)]
#![allow(unused_assignments)] #![allow(unused_assignments)]
#![allow(unused_must_use)]
#![allow(clippy::all)]
use super::runtime_boundary as runtime_sources; use super::runtime_boundary as runtime_sources;
use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp; use crate::bucket::lifecycle::bucket_lifecycle_ops::ExpiryOp;
@@ -72,9 +70,11 @@ static REMOTE_DELETE_BREAKER: LazyLock<Mutex<RemoteDeleteBreaker>> = LazyLock::n
}); });
#[cfg(test)] #[cfg(test)]
static REMOTE_TIER_DELETE_TEST_HOOK: std::sync::LazyLock< type RemoteTierDeleteTestHook = Box<dyn Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync>;
std::sync::Mutex<Option<Box<dyn Fn(&str, &str, &str) -> std::io::Result<()> + Send + Sync>>>,
> = std::sync::LazyLock::new(|| std::sync::Mutex::new(None)); #[cfg(test)]
static REMOTE_TIER_DELETE_TEST_HOOK: std::sync::LazyLock<std::sync::Mutex<Option<RemoteTierDeleteTestHook>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
#[derive(Debug)] #[derive(Debug)]
struct RemoteDeleteBreaker { struct RemoteDeleteBreaker {
@@ -107,7 +107,7 @@ impl RemoteDeleteBreaker {
fn prune(&mut self, now: Instant) { fn prune(&mut self, now: Instant) {
while let Some(ts) = self.failures.front().copied() { while let Some(ts) = self.failures.front().copied() {
if now.duration_since(ts) > self.window { if now.duration_since(ts) > self.window {
self.failures.pop_front(); let _ = self.failures.pop_front();
} else { } else {
break; break;
} }
@@ -137,11 +137,11 @@ fn is_signer_header_error(err: &std::io::Error) -> bool {
return false; return false;
} }
if let Some(source) = err.get_ref() { if let Some(source) = err.get_ref()
if error_chain_contains_signer_header_marker(source) { && error_chain_contains_signer_header_marker(source)
{
return true; return true;
} }
}
let message = err.to_string().to_ascii_lowercase(); let message = err.to_string().to_ascii_lowercase();
message.contains("invalid utf-8 header value") message.contains("invalid utf-8 header value")
@@ -205,7 +205,7 @@ impl ObjSweeper {
#[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")] #[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")]
pub fn with_version(&mut self, vid: Option<Uuid>) -> &Self { pub fn with_version(&mut self, vid: Option<Uuid>) -> &Self {
self.version_id = vid.clone(); self.version_id = vid;
self self
} }
@@ -219,7 +219,7 @@ impl ObjSweeper {
#[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")] #[allow(dead_code, reason = "MinIO-parity surface with no caller in this port (backlog#1823)")]
pub fn get_opts(&self) -> lifecycle::ObjectOpts { pub fn get_opts(&self) -> lifecycle::ObjectOpts {
let mut opts = ObjectOpts { let mut opts = ObjectOpts {
version_id: self.version_id.clone(), version_id: self.version_id,
versioned: self.versioned, versioned: self.versioned,
version_suspended: self.suspended, version_suspended: self.suspended,
..Default::default() ..Default::default()
@@ -388,8 +388,8 @@ impl Jentry {
impl ExpiryOp for Jentry { impl ExpiryOp for Jentry {
fn op_hash(&self) -> u64 { fn op_hash(&self) -> u64 {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(format!("{}", self.tier_name).as_bytes()); hasher.update(self.tier_name.as_bytes());
hasher.update(format!("{}", self.obj_name).as_bytes()); hasher.update(self.obj_name.as_bytes());
xxh64::xxh64(hasher.finalize().as_slice(), XXHASH_SEED) xxh64::xxh64(hasher.finalize().as_slice(), XXHASH_SEED)
} }
@@ -436,7 +436,7 @@ async fn delete_object_from_remote_tier_raw_with_manager(
tier_name: &str, tier_name: &str,
tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>, tier_config_mgr: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
) -> Result<(), std::io::Error> { ) -> Result<(), std::io::Error> {
let lease = TierConfigMgr::acquire_operation_lease(&tier_config_mgr, tier_name) let lease = TierConfigMgr::acquire_operation_lease(tier_config_mgr, tier_name)
.await .await
.map_err(std::io::Error::other)?; .map_err(std::io::Error::other)?;
delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false, true).await delete_object_from_remote_tier_raw_with_lease(obj_name, rv_id, &lease, false, true).await
@@ -575,7 +575,7 @@ pub(crate) async fn delete_confirmed_transition_candidate_exact_with_lease_idemp
#[cfg(test)] #[cfg(test)]
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)] #[cfg(all(test, feature = "test-util"))]
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity( pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
obj_name: &str, obj_name: &str,
rv_id: &str, rv_id: &str,
@@ -706,15 +706,16 @@ pub(crate) fn transitioned_delete_journal_entry_for_source(
#[cfg(test)] #[cfg(test)]
mod test { mod test {
#[cfg(feature = "test-util")]
use super::delete_confirmed_transition_candidate_exact_with_manager_and_identity;
use rustfs_s3_client::signer_error::invalid_utf8_header_error; use rustfs_s3_client::signer_error::invalid_utf8_header_error;
use super::{ use super::{
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, Jentry, CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, Jentry,
RemoteDeleteBreaker, RemoteTierDeleteOutcome, TierDeleteJournalState, TierDeleteSourceIdentity, RemoteDeleteBreaker, RemoteTierDeleteOutcome, TierDeleteJournalState, TierDeleteSourceIdentity,
delete_confirmed_transition_candidate_exact_with_manager_and_identity, delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity,
delete_object_from_remote_tier_idempotent_with_manager_and_identity, is_remote_tier_not_found_error, is_remote_tier_not_found_error, is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook,
is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook, should_record_remote_delete_failure, should_record_remote_delete_failure, transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
}; };
use crate::storage_api_contracts::lifecycle::TransitionedObject; use crate::storage_api_contracts::lifecycle::TransitionedObject;
use rustfs_filemeta::TransitionVersionState; use rustfs_filemeta::TransitionVersionState;
File diff suppressed because it is too large Load Diff
+261 -72
View File
@@ -477,28 +477,29 @@ impl BucketMetadata {
!self.table_bucket_config_json.is_empty() !self.table_bucket_config_json.is_empty()
} }
/// Parsed per-bucket durability override, if a valid one is stored. /// `bucket-targets.json` is stored for this bucket but this build cannot
/// decode it.
/// ///
/// Absent/empty/unparsable payloads all mean "no override" (the bucket /// Keeps "no replication targets configured" and "the target
/// follows the global durability mode); a parse failure is logged so a /// configuration cannot be read" apart, the same distinction the
/// corrupted entry cannot silently change fsync behavior. /// `fabricated` marker draws for the bucket metadata as a whole. Only
/// Parsed on-demand migration config, if one is stored. /// meaningful after [`Self::parse_all_configs`] has run; readers must fail
/// /// closed on `true` instead of serving an empty target set.
/// `Ok(None)` means no config (absent or cleared). A stored payload that pub fn bucket_targets_unreadable(&self) -> bool {
/// does not parse is an error, never a default: the runtime must not !self.bucket_targets_config_json.is_empty() && self.bucket_target_config.is_none()
/// pull from a source it cannot describe.
pub fn on_demand_migration_config(
&self,
) -> std::result::Result<
Option<super::on_demand_migration::OnDemandMigrationConfig>,
super::on_demand_migration::OnDemandMigrationConfigError,
> {
if self.on_demand_migration_config_json.is_empty() {
return Ok(None);
}
super::on_demand_migration::OnDemandMigrationConfig::from_json(&self.on_demand_migration_config_json).map(Some)
} }
/// Opaque application-owned configuration with its persisted update time.
/// Empty bytes mean absent or cleared; decoding belongs to the consumer.
pub fn on_demand_migration_config(&self) -> Option<(&[u8], OffsetDateTime)> {
(!self.on_demand_migration_config_json.is_empty()).then_some((
self.on_demand_migration_config_json.as_slice(),
self.on_demand_migration_config_updated_at,
))
}
/// Parsed per-bucket durability override, if a valid one is stored.
/// Invalid payloads follow the global mode after logging a parse failure.
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> { pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
if self.durability_config_json.is_empty() { if self.durability_config_json.is_empty() {
return None; return None;
@@ -790,9 +791,22 @@ impl BucketMetadata {
} }
} }
/// Replace one config payload and stamp its `*_config_updated_at` with the
/// local clock. This is the entry for edits that originate here: the
/// local write time is the edit's source time.
pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> { pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
let updated = OffsetDateTime::now_utc(); self.update_config_at(config_file, data, OffsetDateTime::now_utc())
}
/// [`Self::update_config`] with an explicit `updated_at` stamp.
///
/// For a config replicated from another site the edit's source time is
/// the peer's `updated_at`, not the moment it lands here: staleness of
/// the next incoming item is judged against the stored stamp, so stamping
/// the local apply time would reject a newer source edit that was merely
/// delivered late (backlog#2292). Only replication receivers should pass
/// a foreign time; local edits keep [`Self::update_config`].
pub fn update_config_at(&mut self, config_file: &str, data: Vec<u8>, updated: OffsetDateTime) -> Result<OffsetDateTime> {
match config_file { match config_file {
BUCKET_POLICY_CONFIG => { BUCKET_POLICY_CONFIG => {
self.policy_config_json = data; self.policy_config_json = data;
@@ -904,13 +918,6 @@ impl BucketMetadata {
self.durability_config_updated_at = updated; self.durability_config_updated_at = updated;
} }
BUCKET_ON_DEMAND_MIGRATION_CONFIG => { BUCKET_ON_DEMAND_MIGRATION_CONFIG => {
// Structural check only (shape, unknown fields); the
// deployment-relative rules run in the admin handler with a
// `ValidationContext`. A blob this build cannot read must not
// be persisted for every later reader to trip over.
if !data.is_empty() {
super::on_demand_migration::OnDemandMigrationConfig::from_json(&data).map_err(Error::other)?;
}
self.on_demand_migration_config_json = data; self.on_demand_migration_config_json = data;
self.on_demand_migration_config_updated_at = updated; self.on_demand_migration_config_updated_at = updated;
} }
@@ -964,7 +971,32 @@ impl BucketMetadata {
Ok(()) Ok(())
} }
fn parse_all_configs(&mut self) -> Result<()> { /// Decode every stored sub-configuration into its typed field.
///
/// A decode failure never fails the whole load: this runs on every bucket
/// metadata read, including startup and peer reload, so one bucket's
/// corrupt sub-configuration must not make the bucket — or the node —
/// unloadable. Instead the failure is *retained*: the raw bytes stay
/// untouched and the typed field stays `None`, so `!raw.is_empty() &&
/// typed.is_none()` is the durable "exists but cannot be read" signal that
/// each accessor keys off. Which accessors must fail closed on it:
///
/// | Config | Verdict |
/// |---|---|
/// | policy | Fails closed: `get_bucket_policy` re-parses the raw JSON and propagates the error; `get_bucket_policy_raw` returns the stored bytes. |
/// | object lock | Fails closed in `object_lock_config_state_from_authoritative_metadata`; a retention decision may never be taken on a guess. |
/// | versioning | Fails closed in `get_versioning_config`; guessing Unversioned would make delete markers and version ids diverge from what is on disk. |
/// | replication | Fails closed in `get_replication_config`. |
/// | bucket targets | Fails closed in `get_bucket_targets_config`, and `sync_bucket_target_sys` marks the bucket unreadable in `BucketTargetSys` instead of publishing an empty target set (rustfs/backlog#2282). |
/// | encryption | Fails closed in `get_sse_config`: degrading to "no default encryption" stores plaintext objects the operator required to be encrypted. |
/// | public access block | Fails closed in `get_public_access_block_config`: degrading grants the anonymous access the operator asked to block. |
/// | quota | Fails closed in `get_quota_config`; the enforcement path in `quota::checker` already re-parses the raw JSON and refuses on error. |
/// | lifecycle | Safe to degrade: no rules means no expiration and no transition, so nothing is deleted or moved on the strength of an unreadable rule set. The bucket keeps serving reads and writes. |
/// | notification | Safe to degrade: events are an outbound side channel; no consumer draws a durability or authorization conclusion from their absence. |
/// | tagging | Safe to degrade: bucket tags are cost-allocation labels here; object-level tag conditions come from object metadata, not this blob. |
/// | CORS | Safe to degrade: an absent CORS configuration rejects cross-origin browser requests, which is already the restrictive direction. |
/// | logging, website, accelerate, request payment, bucket ACL | Safe to degrade: each only shapes an optional response or an optional side channel, and none of them authorizes an action or decides whether data is retained. |
pub(super) fn parse_all_configs(&mut self) -> Result<()> {
if let Err(e) = self.parse_policy_config() { if let Err(e) = self.parse_policy_config() {
tracing::warn!( tracing::warn!(
event = "bucket_metadata_parse_failed", event = "bucket_metadata_parse_failed",
@@ -1088,20 +1120,26 @@ impl BucketMetadata {
"Failed to parse bucket metadata config" "Failed to parse bucket metadata config"
); );
} }
// A stored targets blob that cannot be decoded must not collapse into
// the empty target set: that is indistinguishable from "no replication
// configured", so replication stops and no caller ever sees an error
// (rustfs/backlog#2282). Leaving the typed field `None` while the raw
// bytes stay non-empty is the retained parse failure every targets
// reader keys off; the bytes are preserved so the configuration is
// still recoverable.
self.bucket_target_config = None;
if !self.bucket_targets_config_json.is_empty() { if !self.bucket_targets_config_json.is_empty() {
if let Err(e) = serde_json::from_slice::<BucketTargets>(&self.bucket_targets_config_json) match serde_json::from_slice::<BucketTargets>(&self.bucket_targets_config_json) {
.map(|t| self.bucket_target_config = Some(t)) Ok(targets) => self.bucket_target_config = Some(targets),
{ Err(e) => tracing::error!(
tracing::warn!(
event = "bucket_metadata_parse_failed", event = "bucket_metadata_parse_failed",
component = "ecstore", component = "ecstore",
subsystem = "bucket_metadata", subsystem = "bucket_metadata",
bucket = %self.name, bucket = %self.name,
config = "bucket_targets", config = "bucket_targets",
error = %e, error = %e,
"Failed to parse bucket metadata config" "Bucket replication targets are unreadable; replication for this bucket fails closed"
); ),
self.bucket_target_config = Some(BucketTargets::default());
} }
} else { } else {
self.bucket_target_config = Some(BucketTargets::default()); self.bucket_target_config = Some(BucketTargets::default());
@@ -1500,6 +1538,39 @@ mod test {
assert_eq!(metadata.bucket_incarnation_id, incarnation); assert_eq!(metadata.bucket_incarnation_id, incarnation);
} }
/// backlog#2292: a replicated config is stamped with the source
/// `updated_at` it was given, not the local clock, while the plain
/// `update_config` entry keeps stamping the local clock.
#[test]
fn update_config_at_stamps_the_given_time_and_update_config_stamps_now() {
let source_time = OffsetDateTime::now_utc() - time::Duration::hours(3);
let mut metadata = BucketMetadata::new("source-stamped");
let stamped = metadata
.update_config_at(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec(), source_time)
.unwrap();
assert_eq!(stamped, source_time);
assert_eq!(metadata.policy_config_updated_at, source_time);
let tagging = b"<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag></TagSet></Tagging>".to_vec();
let stamped = metadata
.update_config_at(BUCKET_TAGGING_CONFIG, tagging, source_time)
.unwrap();
assert_eq!(stamped, source_time);
assert_eq!(metadata.tagging_config_updated_at, source_time);
let before = OffsetDateTime::now_utc();
let stamped = metadata
.update_config(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec())
.unwrap();
assert!(stamped >= before, "a local edit is stamped with the local clock");
assert_eq!(metadata.policy_config_updated_at, stamped);
assert_eq!(
metadata.tagging_config_updated_at, source_time,
"restamping one config must not move another config's stamp"
);
}
#[test] #[test]
fn object_locking_requires_lock_metadata_not_plain_versioning() { fn object_locking_requires_lock_metadata_not_plain_versioning() {
use s3s::dto::ObjectLockEnabled; use s3s::dto::ObjectLockEnabled;
@@ -1535,6 +1606,145 @@ mod test {
assert_eq!(bucket_targets.targets[0].target_bucket, "target-bucket"); assert_eq!(bucket_targets.targets[0].target_bucket, "target-bucket");
} }
/// rustfs/backlog#2282: a stored targets blob this build cannot decode
/// must not become the empty target set, and must stay distinguishable
/// from a bucket that never configured a target.
#[test]
fn unreadable_bucket_targets_never_degrade_to_an_empty_target_set() {
let truncated = br#"{"targets":[{"endpoint":"s3.example.com","#.to_vec();
let mut corrupt = BucketMetadata::new("corrupt-targets");
corrupt.bucket_targets_config_json = truncated.clone();
corrupt
.parse_all_configs()
.expect("one unreadable sub-config must not fail the whole metadata load");
assert!(
corrupt.bucket_target_config.is_none(),
"an undecodable targets blob must not produce a target set at all"
);
assert!(corrupt.bucket_targets_unreadable());
assert_eq!(
corrupt.bucket_targets_config_json, truncated,
"the raw bytes must survive so the configuration stays recoverable"
);
// The genuinely-absent case is unchanged, and the two now diverge.
let mut absent = BucketMetadata::new("no-targets");
absent.parse_all_configs().expect("absent targets parse");
assert!(
absent.bucket_target_config.as_ref().is_some_and(BucketTargets::is_empty),
"a bucket that configured no target still reads as an empty target set"
);
assert!(!absent.bucket_targets_unreadable());
}
/// `Credentials` carries no struct-level `serde(default)`, so one target
/// missing `secretKey` is a hard parse error for the whole document. That
/// must surface as "unreadable", never as "no targets configured".
#[test]
fn bucket_targets_missing_secret_key_are_unreadable_not_empty() {
let mut bm = BucketMetadata::new("missing-secret-key");
bm.bucket_targets_config_json = br#"{"targets":[{"endpoint":"s3.example.com","targetbucket":"remote","arn":"arn:rustfs:replication:us-east-1:src:1","credentials":{"accessKey":"AKIAEXAMPLE"}}]}"#.to_vec();
bm.parse_all_configs()
.expect("a rejected targets document must not fail the whole metadata load");
assert!(
bm.bucket_targets_unreadable(),
"a targets document rejected for a missing secretKey is unreadable, not empty"
);
assert!(bm.bucket_target_config.is_none());
}
/// rustfs/backlog#2309: the MinIO-origin `.metadata.bin` this repository
/// already carries as a compatibility fixture stores
/// `BucketTargetsConfigJSON` as a bare JSON array, which `BucketTargets`
/// (a `{"targets":[…]}` struct with no array fallback) cannot decode. The
/// bytes below are the exact payload the fixture in
/// `metadata_test.rs::TEST_BUCKET_METADATA_HEX` decodes to, so if RustFS
/// ever grows the array-shaped compatibility parse, this test is where the
/// upgrade break is pinned and where the decision has to be recorded.
#[test]
fn minio_array_shaped_bucket_targets_are_unreadable() {
let minio_array = br#"[{"endpoint":"http://target.example.com","targetBucket":"tb","region":"us-east-1"}]"#.to_vec();
let mut bm = BucketMetadata::new("minio-array-targets");
bm.bucket_targets_config_json = minio_array.clone();
bm.parse_all_configs()
.expect("a MinIO-shaped targets blob must not fail the whole metadata load");
assert!(
bm.bucket_targets_unreadable(),
"an array-shaped MinIO targets blob is unreadable, not an empty target set"
);
assert!(bm.bucket_target_config.is_none());
assert_eq!(
bm.bucket_targets_config_json, minio_array,
"the raw MinIO bytes must survive so the configuration stays recoverable"
);
}
/// The invariant every branch of `parse_all_configs` shares: a stored but
/// undecodable payload keeps its raw bytes and leaves the typed field
/// `None`, so no branch fabricates a value. What a reader may then do with
/// that state is decided per config; see the table on `parse_all_configs`.
#[test]
fn every_config_branch_retains_its_parse_failure_instead_of_defaulting() {
let malformed_xml = b"<not-a-valid-document".to_vec();
let malformed_json = b"{not-json".to_vec();
let mut bm = BucketMetadata::new("all-configs-malformed");
bm.policy_config_json = malformed_json.clone();
bm.quota_config_json = malformed_json.clone();
bm.bucket_targets_config_json = malformed_json.clone();
bm.notification_config_xml = malformed_xml.clone();
bm.lifecycle_config_xml = malformed_xml.clone();
bm.object_lock_config_xml = malformed_xml.clone();
bm.versioning_config_xml = malformed_xml.clone();
bm.encryption_config_xml = malformed_xml.clone();
bm.tagging_config_xml = malformed_xml.clone();
bm.replication_config_xml = malformed_xml.clone();
bm.cors_config_xml = malformed_xml.clone();
bm.logging_config_xml = malformed_xml.clone();
bm.website_config_xml = malformed_xml.clone();
bm.accelerate_config_xml = malformed_xml.clone();
bm.request_payment_config_xml = malformed_xml.clone();
bm.public_access_block_config_xml = malformed_xml.clone();
// `bucket_acl_config_json` is only checked for UTF-8, so only invalid
// UTF-8 exercises its failure branch.
bm.bucket_acl_config_json = vec![0xff, 0xfe];
bm.parse_all_configs()
.expect("a bucket whose every config is corrupt must still load its metadata");
let cleared: [(&str, bool); 17] = [
("policy", bm.policy_config.is_none()),
("quota", bm.quota_config.is_none()),
("bucket_targets", bm.bucket_target_config.is_none()),
("notification", bm.notification_config.is_none()),
("lifecycle", bm.lifecycle_config.is_none()),
("object_lock", bm.object_lock_config.is_none()),
("versioning", bm.versioning_config.is_none()),
("encryption", bm.sse_config.is_none()),
("tagging", bm.tagging_config.is_none()),
("replication", bm.replication_config.is_none()),
("cors", bm.cors_config.is_none()),
("logging", bm.logging_config.is_none()),
("website", bm.website_config.is_none()),
("accelerate", bm.accelerate_config.is_none()),
("request_payment", bm.request_payment_config.is_none()),
("public_access_block", bm.public_access_block_config.is_none()),
("bucket_acl", bm.bucket_acl_config.is_none()),
];
for (config, is_cleared) in cleared {
assert!(is_cleared, "{config}: a corrupt payload must not be replaced by a default");
}
assert_eq!(bm.bucket_targets_config_json, malformed_json, "raw bytes are retained");
assert_eq!(bm.lifecycle_config_xml, malformed_xml, "raw bytes are retained");
}
#[test] #[test]
fn lifecycle_update_config_clears_parsed_config_on_delete() { fn lifecycle_update_config_clears_parsed_config_on_delete() {
let mut bm = BucketMetadata::new("test-bucket"); let mut bm = BucketMetadata::new("test-bucket");
@@ -1824,51 +2034,30 @@ mod test {
const ODM_JSON: &[u8] = br#"{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#; const ODM_JSON: &[u8] = br#"{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
/// rustfs/backlog#2148: the on-demand migration config is a RustFS /// The metadata codec preserves application-owned bytes and timestamps.
/// extension entry that round-trips through `update_config` and the
/// msgpack codec, clears on delete, and never parses corruption into a
/// default.
#[test] #[test]
fn on_demand_migration_config_round_trips_and_tracks_updates() { fn on_demand_migration_config_round_trips_and_tracks_updates() {
use crate::bucket::on_demand_migration::{OnDemandMigrationConfig, OnDemandMigrationConfigError};
let mut bm = BucketMetadata::new("odm-bucket"); let mut bm = BucketMetadata::new("odm-bucket");
assert_eq!(bm.on_demand_migration_config(), Ok(None), "fresh metadata carries no config"); assert_eq!(bm.on_demand_migration_config(), None, "fresh metadata carries no config");
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec()) bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.expect("valid config is accepted"); .expect("opaque config is accepted");
assert_ne!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH); let stamped = bm.on_demand_migration_config_updated_at;
assert_eq!(bm.on_demand_migration_config(), Ok(Some(expected.clone()))); assert_ne!(stamped, OffsetDateTime::UNIX_EPOCH);
assert_eq!(bm.on_demand_migration_config(), Some((ODM_JSON, stamped)));
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap(); let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
assert_eq!(back.on_demand_migration_config_json, bm.on_demand_migration_config_json); assert_eq!(back.on_demand_migration_config_json, bm.on_demand_migration_config_json);
assert_eq!( assert_eq!(back.on_demand_migration_config_updated_at.unix_timestamp(), stamped.unix_timestamp());
back.on_demand_migration_config_updated_at.unix_timestamp(),
bm.on_demand_migration_config_updated_at.unix_timestamp()
);
assert_eq!(back.on_demand_migration_config(), Ok(Some(expected)));
// A blob this build cannot read is rejected at the write boundary
// rather than persisted for every reader to trip over.
let before = bm.on_demand_migration_config_json.clone();
assert!(
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec())
.is_err()
);
assert_eq!(bm.on_demand_migration_config_json, before, "a rejected update leaves the blob untouched");
// Delete clears the entry.
let stamped = bm.on_demand_migration_config_updated_at;
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap(); bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
assert!(bm.on_demand_migration_config_json.is_empty()); assert!(bm.on_demand_migration_config_json.is_empty());
assert_eq!(bm.on_demand_migration_config(), Ok(None)); assert_eq!(bm.on_demand_migration_config(), None);
assert!(bm.on_demand_migration_config_updated_at >= stamped); assert!(bm.on_demand_migration_config_updated_at >= stamped);
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, b"not-json".to_vec())
// Corruption that bypassed `update_config` (disk, another writer) .unwrap();
// is a typed error, never a default. let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
bm.on_demand_migration_config_json = b"not-json".to_vec(); assert_eq!(
assert!(matches!(bm.on_demand_migration_config(), Err(OnDemandMigrationConfigError::Malformed(_)))); back.on_demand_migration_config_json, b"not-json",
"metadata must not reinterpret application bytes"
);
} }
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand /// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
@@ -1880,7 +2069,7 @@ mod test {
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata"); let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
assert!(bm.on_demand_migration_config_json.is_empty()); assert!(bm.on_demand_migration_config_json.is_empty());
assert_eq!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH); assert_eq!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
assert_eq!(bm.on_demand_migration_config(), Ok(None)); assert_eq!(bm.on_demand_migration_config(), None);
bm.default_timestamps(); bm.default_timestamps();
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time"); assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
+548 -116
View File
@@ -19,7 +19,6 @@ use super::quota::BucketQuota;
use super::target::BucketTargets; use super::target::BucketTargets;
use crate::bucket::bucket_target_sys::BucketTargetSys; use crate::bucket::bucket_target_sys::BucketTargetSys;
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence}; use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
use crate::bucket::on_demand_migration::{ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig};
use crate::bucket::utils::is_meta_bucketname; use crate::bucket::utils::is_meta_bucketname;
use crate::disk::RUSTFS_META_BUCKET; use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found}; use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
@@ -49,6 +48,11 @@ use tokio_util::sync::CancellationToken;
use tracing::{error, warn}; use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
/// Opaque bucket configuration notifications for application-owned services.
/// `None` withdraws a configuration; consumers validate nonempty bytes.
pub type BucketConfigPublishHook = Box<dyn Fn(&str, &str, Option<(&[u8], OffsetDateTime, Uuid)>) + Send + Sync>;
pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new();
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60); const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
#[cfg(any(test, feature = "test-util"))] #[cfg(any(test, feature = "test-util"))]
@@ -360,6 +364,16 @@ async fn refresh_buckets_metadata_once(sys: Arc<RwLock<BucketMetadataSys>>) {
} }
async fn sync_bucket_target_sys(bucket: &str, bm: &BucketMetadata) { async fn sync_bucket_target_sys(bucket: &str, bm: &BucketMetadata) {
if bm.bucket_targets_unreadable() {
// "The configuration cannot be read" is not "no targets configured".
// Publishing an empty snapshot here is what silently stopped
// replication (rustfs/backlog#2282): mark the bucket instead, so every
// targets reader gets a typed error, and leave any snapshot from an
// earlier readable load in place rather than withdrawing it.
BucketTargetSys::get().mark_targets_unreadable(bucket).await;
return;
}
BucketTargetSys::get() BucketTargetSys::get()
.update_all_targets(bucket, bm.bucket_target_config.as_ref()) .update_all_targets(bucket, bm.bucket_target_config.as_ref())
.await; .await;
@@ -385,39 +399,21 @@ fn clear_bucket_durability(bucket: &str) {
crate::disk::local::bucket_durability::set(bucket, None); crate::disk::local::bucket_durability::set(bucket, None);
} }
/// Publish the bucket's on-demand migration config (or its absence) to the /// Publish application-owned bytes on every cache install path.
/// runtime registered in `ON_DEMAND_MIGRATION_CONFIG_HOOK`.
///
/// Called from the same five cache-install paths as
/// [`sync_bucket_durability`]. A stored payload this build cannot parse is
/// published as `None`: the runtime must stop pulling for that bucket rather
/// than keep an older config or guess.
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) { fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() else { if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
return; hook(
}; bucket,
match bm.on_demand_migration_config() { super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG,
Ok(config) => hook(bucket, config.as_ref()), bm.on_demand_migration_config()
Err(err) => { .map(|(bytes, stamp)| (bytes, stamp, bm.bucket_incarnation_id)),
warn!(
event = "bucket_metadata_parse_failed",
component = "ecstore",
subsystem = "bucket_metadata",
bucket = %bucket,
config = "on_demand_migration",
error = %err,
"Failed to parse bucket metadata config"
); );
hook(bucket, None);
}
} }
} }
/// Withdraw a bucket's on-demand migration config when its metadata leaves
/// the cache.
fn clear_on_demand_migration(bucket: &str) { fn clear_on_demand_migration(bucket: &str) {
if let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() { if let Some(hook) = BUCKET_CONFIG_PUBLISH_HOOK.get() {
hook(bucket, None); hook(bucket, super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, None);
} }
} }
@@ -571,6 +567,32 @@ pub async fn update_if_incarnation(
config_file, config_file,
data, data,
Some(expected_incarnation_id), Some(expected_incarnation_id),
None,
))
.await
}
/// [`update_if_incarnation`] stamping the config with `updated_at` instead of
/// the local clock.
///
/// For a site-replication receiver the edit's source time is the peer's
/// `updated_at`; persisting it keeps the stored `*_config_updated_at` on the
/// source clock so the next item's staleness is judged source-time against
/// source-time (backlog#2292). See [`BucketMetadata::update_config_at`].
pub async fn update_if_incarnation_at(
bucket: &str,
config_file: &str,
data: Vec<u8>,
expected_incarnation_id: Uuid,
updated_at: OffsetDateTime,
) -> Result<OffsetDateTime> {
Box::pin(update_with_sys_expected(
get_bucket_metadata_sys()?,
bucket,
config_file,
data,
Some(expected_incarnation_id),
Some(updated_at),
)) ))
.await .await
} }
@@ -581,6 +603,30 @@ pub async fn delete_if_incarnation(bucket: &str, config_file: &str, expected_inc
bucket, bucket,
config_file, config_file,
Some(expected_incarnation_id), Some(expected_incarnation_id),
None,
))
.await
}
/// [`delete_if_incarnation`] stamping the cleared config with `updated_at`
/// (a replicated deletion's source time) instead of the local clock.
///
/// The stamp survives the deletion as the config's `*_config_updated_at`, and
/// that is what the next incoming item is judged against: a local stamp on
/// the delete would reject a newer source re-create that was merely delivered
/// later (backlog#2292). See [`update_if_incarnation_at`].
pub async fn delete_if_incarnation_at(
bucket: &str,
config_file: &str,
expected_incarnation_id: Uuid,
updated_at: OffsetDateTime,
) -> Result<OffsetDateTime> {
Box::pin(delete_with_sys_expected(
get_bucket_metadata_sys()?,
bucket,
config_file,
Some(expected_incarnation_id),
Some(updated_at),
)) ))
.await .await
} }
@@ -602,34 +648,41 @@ async fn update_with_sys(
config_file: &str, config_file: &str,
data: Vec<u8>, data: Vec<u8>,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
update_with_sys_expected(sys, bucket, config_file, data, None).await update_with_sys_expected(sys, bucket, config_file, data, None, None).await
} }
/// `updated_at` is the stamp persisted on the config; `None` uses the local
/// clock (the edit originates here), `Some` carries a replicated edit's
/// source time (backlog#2292).
async fn update_with_sys_expected( async fn update_with_sys_expected(
sys: Arc<RwLock<BucketMetadataSys>>, sys: Arc<RwLock<BucketMetadataSys>>,
bucket: &str, bucket: &str,
config_file: &str, config_file: &str,
data: Vec<u8>, data: Vec<u8>,
expected_incarnation_id: Option<Uuid>, expected_incarnation_id: Option<Uuid>,
updated_at: Option<OffsetDateTime>,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?; let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
update_under_config_write_guard(sys, &guard, config_file, data).await update_under_config_write_guard(sys, &guard, config_file, data, updated_at).await
} }
/// [`delete`] against an explicitly supplied metadata system. See /// [`delete`] against an explicitly supplied metadata system. See
/// [`update_with_sys`]. /// [`update_with_sys`].
async fn delete_with_sys(sys: Arc<RwLock<BucketMetadataSys>>, bucket: &str, config_file: &str) -> Result<OffsetDateTime> { async fn delete_with_sys(sys: Arc<RwLock<BucketMetadataSys>>, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
delete_with_sys_expected(sys, bucket, config_file, None).await delete_with_sys_expected(sys, bucket, config_file, None, None).await
} }
/// `updated_at`: `None` stamps the local clock; `Some` persists a replicated
/// deletion's source time (backlog#2292).
async fn delete_with_sys_expected( async fn delete_with_sys_expected(
sys: Arc<RwLock<BucketMetadataSys>>, sys: Arc<RwLock<BucketMetadataSys>>,
bucket: &str, bucket: &str,
config_file: &str, config_file: &str,
expected_incarnation_id: Option<Uuid>, expected_incarnation_id: Option<Uuid>,
updated_at: Option<OffsetDateTime>,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?; let guard = acquire_config_write_guard_for_incarnation(sys.clone(), bucket, expected_incarnation_id).await?;
delete_under_config_write_guard(sys, &guard, config_file).await delete_under_config_write_guard(sys, &guard, config_file, updated_at).await
} }
/// Owns the complete bucket-config mutation fence. /// Owns the complete bucket-config mutation fence.
@@ -645,6 +698,12 @@ pub struct BucketMetadataMutationGuard {
} }
impl BucketMetadataMutationGuard { impl BucketMetadataMutationGuard {
/// Returns the storage-verified identity while both incarnation fences remain valid.
pub fn checked_bucket_incarnation(&self) -> Result<(&str, Uuid)> {
self.ensure_valid(&self.bucket)?;
Ok((&self.bucket, self.incarnation_id))
}
fn ensure_valid(&self, bucket: &str) -> Result<()> { fn ensure_valid(&self, bucket: &str) -> Result<()> {
if self.bucket != bucket { if self.bucket != bucket {
return Err(Error::other("bucket metadata mutation guard does not match bucket")); return Err(Error::other("bucket metadata mutation guard does not match bucket"));
@@ -664,6 +723,29 @@ async fn acquire_config_write_guard_for_incarnation(
sys: Arc<RwLock<BucketMetadataSys>>, sys: Arc<RwLock<BucketMetadataSys>>,
bucket: &str, bucket: &str,
expected_incarnation_id: Option<Uuid>, expected_incarnation_id: Option<Uuid>,
) -> Result<BucketMetadataMutationGuard> {
acquire_config_write_guard_with_migration(sys, bucket, expected_incarnation_id, true).await
}
/// Scanner probes must not create an incarnation to make a capability available.
pub async fn acquire_scanner_bucket_incarnation_fence(
bucket: &str,
expected_incarnation_id: Uuid,
expected_owner_id: Uuid,
) -> Result<BucketMetadataMutationGuard> {
super::utils::check_valid_bucket_name(bucket)?;
let sys = get_bucket_metadata_sys()?;
if expected_owner_id.is_nil() || sys.read().await.api.id != expected_owner_id || expected_incarnation_id.is_nil() {
return Err(Error::other("scanner bucket incarnation owner does not match"));
}
acquire_config_write_guard_with_migration(sys, bucket, Some(expected_incarnation_id), false).await
}
async fn acquire_config_write_guard_with_migration(
sys: Arc<RwLock<BucketMetadataSys>>,
bucket: &str,
expected_incarnation_id: Option<Uuid>,
migrate: bool,
) -> Result<BucketMetadataMutationGuard> { ) -> Result<BucketMetadataMutationGuard> {
let metadata_sys = sys.read().await.clone(); let metadata_sys = sys.read().await.clone();
let lifecycle_guard = metadata_sys.api.acquire_bucket_lifecycle_read_lock(bucket).await?; let lifecycle_guard = metadata_sys.api.acquire_bucket_lifecycle_read_lock(bucket).await?;
@@ -671,6 +753,7 @@ async fn acquire_config_write_guard_for_incarnation(
// Legacy buckets are migrated while the lifecycle fence prevents a // Legacy buckets are migrated while the lifecycle fence prevents a
// same-name replacement. The second read under the write transaction is // same-name replacement. The second read under the write transaction is
// the CAS source of truth for the actual rewrite. // the CAS source of truth for the actual rewrite.
if migrate {
await_bucket_namespace_operation( await_bucket_namespace_operation(
Some(&lifecycle_guard), Some(&lifecycle_guard),
bucket, bucket,
@@ -678,6 +761,7 @@ async fn acquire_config_write_guard_for_incarnation(
metadata_sys.get_bucket_incarnation_id(bucket), metadata_sys.get_bucket_incarnation_id(bucket),
) )
.await?; .await?;
}
let transaction_guard = await_bucket_namespace_operation( let transaction_guard = await_bucket_namespace_operation(
Some(&lifecycle_guard), Some(&lifecycle_guard),
bucket, bucket,
@@ -745,7 +829,21 @@ pub async fn update_under_transaction_lock(
data: Vec<u8>, data: Vec<u8>,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
guard.ensure_valid(bucket)?; guard.ensure_valid(bucket)?;
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data).await update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data, None).await
}
/// [`update_under_transaction_lock`] stamping the config with `updated_at`
/// (a replicated edit's source time) instead of the local clock; see
/// [`update_if_incarnation_at`] (backlog#2292).
pub async fn update_under_transaction_lock_at(
guard: &BucketMetadataMutationGuard,
bucket: &str,
config_file: &str,
data: Vec<u8>,
updated_at: OffsetDateTime,
) -> Result<OffsetDateTime> {
guard.ensure_valid(bucket)?;
update_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, data, Some(updated_at)).await
} }
/// Clear one config file while the caller holds this bucket's transaction lock. /// Clear one config file while the caller holds this bucket's transaction lock.
@@ -755,7 +853,7 @@ pub async fn delete_under_transaction_lock(
config_file: &str, config_file: &str,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
guard.ensure_valid(bucket)?; guard.ensure_valid(bucket)?;
delete_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file).await delete_under_config_write_guard(get_bucket_metadata_sys()?, guard, config_file, None).await
} }
pub async fn update_quota_if_incarnation( pub async fn update_quota_if_incarnation(
@@ -763,6 +861,29 @@ pub async fn update_quota_if_incarnation(
data: Vec<u8>, data: Vec<u8>,
expected_incarnation_id: Uuid, expected_incarnation_id: Uuid,
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken, proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
) -> Result<OffsetDateTime> {
update_quota_if_incarnation_stamped(bucket, data, expected_incarnation_id, proof, None).await
}
/// [`update_quota_if_incarnation`] stamping the quota config with
/// `updated_at` (a replicated edit's source time) instead of the local
/// clock; see [`update_if_incarnation_at`] (backlog#2292).
pub async fn update_quota_if_incarnation_at(
bucket: &str,
data: Vec<u8>,
expected_incarnation_id: Uuid,
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
updated_at: OffsetDateTime,
) -> Result<OffsetDateTime> {
update_quota_if_incarnation_stamped(bucket, data, expected_incarnation_id, proof, Some(updated_at)).await
}
async fn update_quota_if_incarnation_stamped(
bucket: &str,
data: Vec<u8>,
expected_incarnation_id: Uuid,
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
updated_at: Option<OffsetDateTime>,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
let sys = get_bucket_metadata_sys()?; let sys = get_bucket_metadata_sys()?;
let guard = Box::pin(acquire_config_write_guard_for_incarnation( let guard = Box::pin(acquire_config_write_guard_for_incarnation(
@@ -780,7 +901,7 @@ pub async fn update_quota_if_incarnation(
achieved: 0, achieved: 0,
}); });
} }
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data).await update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data, updated_at).await
} }
pub async fn update_bucket_targets_under_transaction_lock( pub async fn update_bucket_targets_under_transaction_lock(
@@ -796,6 +917,7 @@ async fn update_under_config_write_guard(
guard: &BucketMetadataMutationGuard, guard: &BucketMetadataMutationGuard,
config_file: &str, config_file: &str,
data: Vec<u8>, data: Vec<u8>,
updated_at: Option<OffsetDateTime>,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
guard.ensure_valid(&guard.bucket)?; guard.ensure_valid(&guard.bucket)?;
let metadata_sys = sys.read().await.clone(); let metadata_sys = sys.read().await.clone();
@@ -807,7 +929,7 @@ async fn update_under_config_write_guard(
Some(&guard.transaction_guard), Some(&guard.transaction_guard),
&guard.bucket, &guard.bucket,
"bucket config transaction", "bucket config transaction",
metadata_sys.update_checked(&guard.bucket, config_file, data, true, guard.incarnation_id), metadata_sys.update_checked(&guard.bucket, config_file, data, true, guard.incarnation_id, updated_at),
), ),
) )
.await?; .await?;
@@ -819,6 +941,7 @@ async fn delete_under_config_write_guard(
sys: Arc<RwLock<BucketMetadataSys>>, sys: Arc<RwLock<BucketMetadataSys>>,
guard: &BucketMetadataMutationGuard, guard: &BucketMetadataMutationGuard,
config_file: &str, config_file: &str,
updated_at: Option<OffsetDateTime>,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
guard.ensure_valid(&guard.bucket)?; guard.ensure_valid(&guard.bucket)?;
let metadata_sys = sys.read().await.clone(); let metadata_sys = sys.read().await.clone();
@@ -830,7 +953,7 @@ async fn delete_under_config_write_guard(
Some(&guard.transaction_guard), Some(&guard.transaction_guard),
&guard.bucket, &guard.bucket,
"bucket config deletion transaction", "bucket config deletion transaction",
metadata_sys.update_checked(&guard.bucket, config_file, Vec::new(), false, guard.incarnation_id), metadata_sys.update_checked(&guard.bucket, config_file, Vec::new(), false, guard.incarnation_id, updated_at),
), ),
) )
.await?; .await?;
@@ -1008,15 +1131,21 @@ pub async fn get_durability_config(
} }
/// The bucket's on-demand migration config with its update time, or /// The bucket's on-demand migration config with its update time, or
/// `Ok(None)` when the bucket has none. A stored payload that does not parse /// `Ok(None)` when the bucket has none. Bytes are opaque to the metadata owner.
/// is a typed error (`OnDemandMigrationConfigError` inside `Error::Io`). pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?; let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await; let bucket_meta_sys = bucket_meta_sys_lock.read().await;
bucket_meta_sys.get_on_demand_migration_config(bucket).await bucket_meta_sys.get_on_demand_migration_config(bucket).await
} }
/// Resolve opaque configuration from the store's own metadata system.
pub async fn get_on_demand_migration_config_in(api: &ECStore, bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
let sys = bucket_metadata_sys_of(&api.ctx)?;
let lock = sys.read().await;
lock.get_on_demand_migration_config(bucket).await
}
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> { pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?; let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await; let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -1729,15 +1858,17 @@ impl BucketMetadataSys {
/// `update` and the config read alone). Keep these boxed. /// `update` and the config read alone). Keep these boxed.
pub async fn update(&self, bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> { pub async fn update(&self, bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
let incarnation_id = Box::pin(self.get_bucket_incarnation_id(bucket)).await?; let incarnation_id = Box::pin(self.get_bucket_incarnation_id(bucket)).await?;
Box::pin(self.update_checked(bucket, config_file, data, true, incarnation_id)).await Box::pin(self.update_checked(bucket, config_file, data, true, incarnation_id, None)).await
} }
pub async fn delete(&self, bucket: &str, config_file: &str) -> Result<OffsetDateTime> { pub async fn delete(&self, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
let incarnation_id = self.get_bucket_incarnation_id(bucket).await?; let incarnation_id = self.get_bucket_incarnation_id(bucket).await?;
self.update_checked(bucket, config_file, Vec::new(), false, incarnation_id) self.update_checked(bucket, config_file, Vec::new(), false, incarnation_id, None)
.await .await
} }
/// `updated_at`: `None` stamps the local clock; `Some` persists a
/// replicated edit's source time (backlog#2292).
async fn update_checked( async fn update_checked(
&self, &self,
bucket: &str, bucket: &str,
@@ -1745,6 +1876,7 @@ impl BucketMetadataSys {
data: Vec<u8>, data: Vec<u8>,
parse: bool, parse: bool,
expected_incarnation_id: Uuid, expected_incarnation_id: Uuid,
updated_at: Option<OffsetDateTime>,
) -> Result<OffsetDateTime> { ) -> Result<OffsetDateTime> {
// Load through this system's own store, the one `save` persists to // Load through this system's own store, the one `save` persists to
// (backlog#1052 S7). Reading from the ambient handle instead made the // (backlog#1052 S7). Reading from the ambient handle instead made the
@@ -1755,7 +1887,10 @@ impl BucketMetadataSys {
return Err(Error::BucketNotFound(bucket.to_string())); return Err(Error::BucketNotFound(bucket.to_string()));
} }
let updated = bm.update_config(config_file, data)?; let updated = match updated_at {
Some(updated_at) => bm.update_config_at(config_file, data, updated_at)?,
None => bm.update_config(config_file, data)?,
};
Box::pin(self.save(bm)).await?; Box::pin(self.save(bm)).await?;
@@ -2118,7 +2253,9 @@ impl BucketMetadataSys {
pub async fn get_public_access_block_config(&self, bucket: &str) -> Result<(PublicAccessBlockConfiguration, OffsetDateTime)> { pub async fn get_public_access_block_config(&self, bucket: &str) -> Result<(PublicAccessBlockConfiguration, OffsetDateTime)> {
let (bm, _) = self.get_config(bucket).await?; let (bm, _) = self.get_config(bucket).await?;
if let Some(config) = &bm.public_access_block_config { if !bm.public_access_block_config_xml.is_empty() && bm.public_access_block_config.is_none() {
Err(Error::other("persisted bucket public access block configuration is invalid"))
} else if let Some(config) = &bm.public_access_block_config {
Ok((config.clone(), bm.public_access_block_config_updated_at)) Ok((config.clone(), bm.public_access_block_config_updated_at))
} else { } else {
Err(Error::ConfigNotFound) Err(Error::ConfigNotFound)
@@ -2429,7 +2566,9 @@ impl BucketMetadataSys {
pub async fn get_sse_config(&self, bucket: &str) -> Result<(ServerSideEncryptionConfiguration, OffsetDateTime)> { pub async fn get_sse_config(&self, bucket: &str) -> Result<(ServerSideEncryptionConfiguration, OffsetDateTime)> {
let (bm, _) = self.get_config(bucket).await?; let (bm, _) = self.get_config(bucket).await?;
if let Some(config) = &bm.sse_config { if !bm.encryption_config_xml.is_empty() && bm.sse_config.is_none() {
Err(Error::other("persisted bucket encryption configuration is invalid"))
} else if let Some(config) = &bm.sse_config {
Ok((config.clone(), bm.encryption_config_updated_at)) Ok((config.clone(), bm.encryption_config_updated_at))
} else { } else {
Err(Error::ConfigNotFound) Err(Error::ConfigNotFound)
@@ -2500,7 +2639,9 @@ impl BucketMetadataSys {
pub async fn get_quota_config(&self, bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> { pub async fn get_quota_config(&self, bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
let (bm, _) = self.get_config(bucket).await?; let (bm, _) = self.get_config(bucket).await?;
if let Some(config) = &bm.quota_config { if !bm.quota_config_json.is_empty() && bm.quota_config.is_none() {
Err(Error::other("persisted bucket quota configuration is invalid"))
} else if let Some(config) = &bm.quota_config {
Ok((config.clone(), bm.quota_config_updated_at)) Ok((config.clone(), bm.quota_config_updated_at))
} else { } else {
Err(Error::ConfigNotFound) Err(Error::ConfigNotFound)
@@ -2522,7 +2663,9 @@ impl BucketMetadataSys {
pub async fn get_bucket_targets_config(&self, bucket: &str) -> Result<BucketTargets> { pub async fn get_bucket_targets_config(&self, bucket: &str) -> Result<BucketTargets> {
let (bm, _) = self.get_config(bucket).await?; let (bm, _) = self.get_config(bucket).await?;
if let Some(config) = &bm.bucket_target_config { if bm.bucket_targets_unreadable() {
Err(Error::other("persisted bucket replication target configuration is invalid"))
} else if let Some(config) = &bm.bucket_target_config {
Ok(config.clone()) Ok(config.clone())
} else { } else {
Err(Error::ConfigNotFound) Err(Error::ConfigNotFound)
@@ -2530,29 +2673,27 @@ impl BucketMetadataSys {
} }
/// See [`get_on_demand_migration_config`]. /// See [`get_on_demand_migration_config`].
pub async fn get_on_demand_migration_config( pub async fn get_on_demand_migration_config(&self, bucket: &str) -> Result<Option<(Vec<u8>, OffsetDateTime)>> {
&self,
bucket: &str,
) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
let (bm, _) = self.get_config(bucket).await?; let (bm, _) = self.get_config(bucket).await?;
let config = bm.on_demand_migration_config().map_err(Error::other)?; Ok(bm
Ok(config.map(|config| (config, bm.on_demand_migration_config_updated_at))) .on_demand_migration_config()
.map(|(bytes, updated_at)| (bytes.to_vec(), updated_at)))
} }
} }
/// Test-only fixture shared with sibling modules (e.g. the quota checker /// Test-only fixture shared with sibling modules (e.g. the quota checker
/// tests): a 4-disk `ECStore` on an isolated instance context, so tests /// tests): a 4-disk `ECStore` on an isolated instance context, so tests
/// exercising the metadata system never touch ambient process state. /// exercising the metadata system never touch ambient process state.
#[cfg(test)] #[cfg(any(test, feature = "test-util"))]
pub(crate) mod test_support { pub mod test_support {
use super::*; use super::*;
use crate::disk::endpoint::Endpoint; use crate::disk::endpoint::Endpoint;
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints}; use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
use crate::runtime::instance::InstanceContext; use crate::runtime::instance::InstanceContext;
use crate::store::init_local_disks_with_instance_ctx; use crate::store::init_local_disks_with_instance_ctx;
pub(crate) async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) { pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
let mut dirs = Vec::with_capacity(4); let mut dirs = Vec::with_capacity(4);
let mut endpoints = Vec::with_capacity(4); let mut endpoints = Vec::with_capacity(4);
for disk_idx in 0..4 { for disk_idx in 0..4 {
@@ -2593,6 +2734,7 @@ pub(crate) mod test_support {
mod tests { mod tests {
use super::test_support::isolated_store_over_temp_disks; use super::test_support::isolated_store_over_temp_disks;
use super::*; use super::*;
use crate::bucket::bucket_target_sys::BucketTargetError;
use crate::bucket::metadata::{ use crate::bucket::metadata::{
BUCKET_ACCELERATE_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_LOGGING_CONFIG, BUCKET_NOTIFICATION_CONFIG, BUCKET_ACCELERATE_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_LOGGING_CONFIG, BUCKET_NOTIFICATION_CONFIG,
BUCKET_POLICY_CONFIG, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_REPLICATION_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG, BUCKET_POLICY_CONFIG, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_REPLICATION_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG,
@@ -2788,6 +2930,36 @@ mod tests {
); );
} }
/// The `parse_all_configs` audit (rustfs/backlog#2282): every accessor
/// whose configuration grants something — plaintext storage, anonymous
/// access, capacity, replication targets — reports a corrupt payload as
/// invalid rather than as absent, because "absent" is what grants it.
#[tokio::test]
async fn malformed_permissive_configs_are_not_reported_as_absent() {
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore);
let bucket = "malformed-permissive-config";
let mut metadata = BucketMetadata::new(bucket);
metadata.encryption_config_xml = b"<ServerSideEncryptionConfiguration".to_vec();
metadata.public_access_block_config_xml = b"<PublicAccessBlockConfiguration".to_vec();
metadata.quota_config_json = b"{not-json".to_vec();
metadata.bucket_targets_config_json = b"{not-json".to_vec();
metadata
.parse_all_configs()
.expect("a corrupt sub-config must not fail the load");
sys.set(bucket.to_string(), Arc::new(metadata)).await;
for (config, result) in [
("encryption", sys.get_sse_config(bucket).await.err()),
("public access block", sys.get_public_access_block_config(bucket).await.err()),
("quota", sys.get_quota_config(bucket).await.err()),
("bucket targets", sys.get_bucket_targets_config(bucket).await.err()),
] {
let err = result.unwrap_or_else(|| panic!("malformed {config} metadata must not read as a value"));
assert_ne!(err, Error::ConfigNotFound, "malformed {config} metadata must not be reported as absent");
}
}
#[tokio::test] #[tokio::test]
async fn config_states_distinguish_authoritative_absence_from_fabricated_metadata() { async fn config_states_distinguish_authoritative_absence_from_fabricated_metadata() {
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@@ -3127,6 +3299,82 @@ mod tests {
); );
} }
#[tokio::test]
async fn scoped_dirty_usage_incarnation_probe_does_not_migrate_legacy_metadata() {
let (dirs, store) = isolated_store_over_temp_disks().await;
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(store.clone())));
let bucket = "scoped-ack-legacy";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).expect("create legacy bucket");
}
let mut metadata = BucketMetadata::new(bucket);
metadata.bucket_incarnation_id = Uuid::nil();
sys.read()
.await
.persist_and_set(metadata)
.await
.expect("persist legacy metadata");
assert!(
acquire_config_write_guard_with_migration(sys.clone(), bucket, Some(Uuid::new_v4()), false)
.await
.is_err()
);
assert!(load_bucket_incarnation(store, bucket).await.expect("read sidecar").is_none());
assert!(
sys.read()
.await
.get_config_from_disk(bucket)
.await
.expect("read metadata")
.bucket_incarnation_id
.is_nil()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn scoped_dirty_usage_incarnation_rejects_deleted_and_recreated_bucket() {
let (_dirs, store) = isolated_store_over_temp_disks().await;
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let sys = bucket_metadata_sys_of(&store.ctx).expect("metadata owner");
let bucket = "scoped-ack-recreated";
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create bucket");
let old = store.bucket_incarnation_id_from_disk(bucket).await.expect("old incarnation");
let guard = acquire_config_write_guard_with_migration(sys.clone(), bucket, Some(old), false)
.await
.expect("trusted incarnation fence");
assert_eq!(guard.checked_bucket_incarnation().expect("valid fences"), (bucket, old));
drop(guard);
store
.delete_bucket(bucket, &DeleteBucketOptions::default())
.await
.expect("delete bucket");
assert!(
acquire_config_write_guard_with_migration(sys.clone(), bucket, Some(old), false)
.await
.is_err()
);
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("recreate bucket");
let new = store.bucket_incarnation_id_from_disk(bucket).await.expect("new incarnation");
assert_ne!(old, new);
assert!(
acquire_config_write_guard_with_migration(sys.clone(), bucket, Some(old), false)
.await
.is_err()
);
assert!(
acquire_config_write_guard_with_migration(sys, bucket, Some(new), false)
.await
.is_ok()
);
}
#[tokio::test] #[tokio::test]
async fn old_node_metadata_rewrite_cannot_replace_bucket_incarnation_sidecar() { async fn old_node_metadata_rewrite_cannot_replace_bucket_incarnation_sidecar() {
let (dirs, ecstore) = isolated_store_over_temp_disks().await; let (dirs, ecstore) = isolated_store_over_temp_disks().await;
@@ -3609,6 +3857,106 @@ mod tests {
); );
} }
/// backlog#2292: the explicit-stamp write path persists the given source
/// time as the config's `*_config_updated_at` — through the incarnation
/// path and through an already-held transaction guard — and survives a
/// reload from disk, while the plain path keeps stamping the local clock.
#[tokio::test]
async fn explicit_updated_at_is_persisted_as_the_config_stamp() {
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let bucket = "source-stamped-config";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).expect("bucket volume should be created");
}
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
let source_time = OffsetDateTime::now_utc() - Duration::from_secs(3 * 3600);
let policy = br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec();
let tagging = b"<Tagging><TagSet><Tag><Key>k</Key><Value>v</Value></Tag></TagSet></Tagging>".to_vec();
// Incarnation path (`update_if_incarnation_at` minus the ambient lookup).
let stamped =
update_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy.clone(), None, Some(source_time))
.await
.expect("source-stamped policy write should persist");
assert_eq!(stamped, source_time);
// Held-guard path (`update_under_transaction_lock_at` minus the ambient lookup).
let guard = acquire_config_write_guard(sys.clone(), bucket).await.expect("write guard");
let stamped = update_under_config_write_guard(sys.clone(), &guard, BUCKET_TAGGING_CONFIG, tagging, Some(source_time))
.await
.expect("source-stamped tagging write should persist");
drop(guard);
assert_eq!(stamped, source_time);
let metadata_sys = sys.read().await.clone();
metadata_sys.metadata_map.write().await.clear();
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
assert_eq!(reloaded.policy_config_updated_at, source_time);
assert_eq!(reloaded.tagging_config_updated_at, source_time);
// The plain path is unchanged: a local edit is stamped with the local clock.
let before = OffsetDateTime::now_utc();
let stamped = update_with_sys(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy)
.await
.expect("locally stamped policy write should persist");
assert!(stamped >= before, "the plain write path must keep stamping the local clock");
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
assert_eq!(reloaded.policy_config_updated_at, stamped);
assert_eq!(
reloaded.tagging_config_updated_at, source_time,
"an unrelated config keeps its source stamp"
);
}
/// backlog#2292: a replicated delete persists the source time as the
/// cleared config's `*_config_updated_at`, so the receive-side gate
/// (source time against stored stamp) lets a newer source re-create land
/// even when the delete was applied later than the re-create's source
/// time; the plain delete keeps stamping the local clock.
#[tokio::test]
async fn explicit_updated_at_is_persisted_by_a_delete() {
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let bucket = "source-stamped-delete";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).expect("bucket volume should be created");
}
let sys = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
let policy = br#"{"Version":"2012-10-17","Statement":[]}"#.to_vec();
let created_at = OffsetDateTime::now_utc() - Duration::from_secs(3 * 3600);
let deleted_at = created_at + Duration::from_secs(60);
let recreated_at = deleted_at + Duration::from_secs(60);
update_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy.clone(), None, Some(created_at))
.await
.expect("source-stamped policy write should persist");
let stamped = delete_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, None, Some(deleted_at))
.await
.expect("source-stamped policy delete should persist");
assert_eq!(stamped, deleted_at);
let metadata_sys = sys.read().await.clone();
metadata_sys.metadata_map.write().await.clear();
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
assert!(reloaded.policy_config_json.is_empty(), "the delete cleared the payload");
assert_eq!(reloaded.policy_config_updated_at, deleted_at, "the delete kept the source stamp");
assert!(
recreated_at >= reloaded.policy_config_updated_at,
"a re-create newer than the delete's source time is not stale against the stored stamp"
);
// The plain delete path is unchanged: stamped with the local clock.
update_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, policy, None, Some(recreated_at))
.await
.expect("re-create should persist");
let before = OffsetDateTime::now_utc();
let stamped = delete_with_sys_expected(sys.clone(), bucket, BUCKET_POLICY_CONFIG, None, None)
.await
.expect("locally stamped delete should persist");
assert!(stamped >= before, "the plain delete path must keep stamping the local clock");
let reloaded = metadata_sys.get_config_from_disk(bucket).await.expect("reload from disk");
assert_eq!(reloaded.policy_config_updated_at, stamped);
}
/// The load and the persisted write share one write guard, so concurrent /// The load and the persisted write share one write guard, so concurrent
/// rewrites of the same config compose instead of clobbering each other. /// rewrites of the same config compose instead of clobbering each other.
/// Moving the load outside that guard loses all but the last tag. /// Moving the load outside that guard loses all but the last tag.
@@ -3825,8 +4173,14 @@ mod tests {
let new_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.unwrap(); let new_incarnation = store.bucket_incarnation_id_from_disk(bucket).await.unwrap();
assert_ne!(old_incarnation, new_incarnation); assert_ne!(old_incarnation, new_incarnation);
let err = let err = update_with_sys_expected(
update_with_sys_expected(sys.clone(), bucket, BUCKET_TAGGING_CONFIG, b"<Tagging/>".to_vec(), Some(old_incarnation)) sys.clone(),
bucket,
BUCKET_TAGGING_CONFIG,
b"<Tagging/>".to_vec(),
Some(old_incarnation),
None,
)
.await .await
.expect_err("a request authorized for the deleted incarnation must fail closed"); .expect_err("a request authorized for the deleted incarnation must fail closed");
assert!(matches!(err, Error::BucketNotFound(name) if name == bucket)); assert!(matches!(err, Error::BucketNotFound(name) if name == bucket));
@@ -3863,7 +4217,7 @@ mod tests {
}], }],
}) })
.unwrap(); .unwrap();
update_under_config_write_guard(sys, &guard, BUCKET_TAGGING_CONFIG, tagging) update_under_config_write_guard(sys, &guard, BUCKET_TAGGING_CONFIG, tagging, None)
.await .await
.unwrap(); .unwrap();
assert!(!delete.is_finished()); assert!(!delete.is_finished());
@@ -4066,6 +4420,114 @@ mod tests {
target_sys.delete(bucket).await; target_sys.delete(bucket).await;
} }
/// rustfs/backlog#2282: an unreadable `bucket-targets.json` reaches every
/// targets reader as a typed error; it neither withdraws a snapshot a
/// previous readable load published, nor collapses into the "no targets
/// configured" state that a bucket with an absent configuration reports.
#[tokio::test]
#[serial]
async fn unreadable_bucket_targets_fail_closed_and_stay_distinct_from_absent() {
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore);
let target_sys = BucketTargetSys::get();
let unreadable = "targets-unreadable";
let absent = "targets-absent";
target_sys.delete(unreadable).await;
target_sys.delete(absent).await;
// A readable load publishes this bucket's targets.
let mut readable = BucketMetadata::new(unreadable);
readable.bucket_target_config = Some(BucketTargets {
targets: vec![target(unreadable, "live")],
});
sync_bucket_target_sys(unreadable, &readable).await;
assert_eq!(
target_sys
.list_bucket_targets(unreadable)
.await
.expect("readable targets publish")
.targets
.len(),
1
);
// The same bucket reloaded with a blob that cannot be decoded.
let mut corrupt = BucketMetadata::new(unreadable);
corrupt.bucket_targets_config_json = br#"{"targets":[{"endpoint":"#.to_vec();
corrupt
.parse_all_configs()
.expect("an unreadable targets blob must not fail the metadata load");
sys.set(unreadable.to_string(), Arc::new(corrupt)).await;
assert!(
matches!(
target_sys.list_bucket_targets(unreadable).await,
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. })
),
"an unreadable configuration must not read as an empty or a missing target set"
);
assert!(
target_sys.list_targets(unreadable, "").await.is_err(),
"the admin listing must surface the fault instead of an empty list"
);
let err = sys
.get_bucket_targets_config(unreadable)
.await
.expect_err("an unreadable targets configuration must not read as a value");
assert_ne!(err, Error::ConfigNotFound, "unreadable must not be reported as absent");
// A bucket that never configured a target keeps its previous behavior.
let mut no_targets = BucketMetadata::new(absent);
no_targets.parse_all_configs().expect("absent targets parse");
sys.set(absent.to_string(), Arc::new(no_targets)).await;
assert!(
matches!(
target_sys.list_bucket_targets(absent).await,
Err(BucketTargetError::BucketRemoteTargetNotFound { .. })
),
"an absent configuration must still report as a missing target set"
);
assert!(
target_sys
.list_targets(absent, "")
.await
.expect("an absent configuration lists no targets")
.is_empty()
);
assert!(
sys.get_bucket_targets_config(absent)
.await
.expect("an absent targets configuration still reads as an empty set")
.is_empty(),
"the absent path must keep returning an empty target set, exactly as before"
);
// One bucket's unreadable configuration does not reach another bucket.
assert!(!matches!(
target_sys.list_bucket_targets(absent).await,
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. })
));
// A repaired configuration takes effect on the next load, no restart.
let mut repaired = BucketMetadata::new(unreadable);
repaired.bucket_target_config = Some(BucketTargets {
targets: vec![target(unreadable, "repaired")],
});
sync_bucket_target_sys(unreadable, &repaired).await;
assert_eq!(
target_sys
.list_bucket_targets(unreadable)
.await
.expect("a repaired configuration clears the unreadable marker")
.targets
.len(),
1
);
target_sys.delete(unreadable).await;
target_sys.delete(absent).await;
}
#[tokio::test] #[tokio::test]
#[serial] #[serial]
async fn metadata_reload_clears_stale_bucket_targets_when_config_is_removed() { async fn metadata_reload_clears_stale_bucket_targets_when_config_is_removed() {
@@ -4121,19 +4583,26 @@ mod tests {
const ODM_JSON: &[u8] = br#"{"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#; const ODM_JSON: &[u8] = br#"{"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
type RecordedOdmConfig = Option<(Vec<u8>, OffsetDateTime, Uuid)>;
type RecordedOdmHookCall = (String, RecordedOdmConfig);
/// Every `(bucket, config)` the recording hook has seen. Tests filter by /// Every `(bucket, config)` the recording hook has seen. Tests filter by
/// their own bucket name; the hook is process-wide and set once. /// their own bucket name; the hook is process-wide and set once.
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<(String, Option<OnDemandMigrationConfig>)>> = std::sync::Mutex::new(Vec::new()); static ODM_HOOK_CALLS: std::sync::Mutex<Vec<RecordedOdmHookCall>> = std::sync::Mutex::new(Vec::new());
fn install_recording_odm_hook() { fn install_recording_odm_hook() {
ON_DEMAND_MIGRATION_CONFIG_HOOK.get_or_init(|| { BUCKET_CONFIG_PUBLISH_HOOK.get_or_init(|| {
Box::new(|bucket, config| { Box::new(|bucket, config_file, config| {
ODM_HOOK_CALLS.lock().unwrap().push((bucket.to_string(), config.cloned())); assert_eq!(config_file, super::super::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG);
ODM_HOOK_CALLS.lock().unwrap().push((
bucket.to_string(),
config.map(|(bytes, stamp, incarnation)| (bytes.to_vec(), stamp, incarnation)),
));
}) })
}); });
} }
fn odm_hook_calls(bucket: &str) -> Vec<Option<OnDemandMigrationConfig>> { fn odm_hook_calls(bucket: &str) -> Vec<RecordedOdmConfig> {
ODM_HOOK_CALLS ODM_HOOK_CALLS
.lock() .lock()
.unwrap() .unwrap()
@@ -4143,54 +4612,6 @@ mod tests {
.collect() .collect()
} }
/// rustfs/backlog#2148: the accessor reports absence as `Ok(None)` and a
/// stored payload it cannot parse as a typed error, never as a default
/// and never as `ConfigNotFound`.
#[tokio::test]
async fn get_on_demand_migration_config_distinguishes_absent_from_corrupt() {
use crate::bucket::on_demand_migration::OnDemandMigrationConfigError;
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore);
let bucket = "odm-accessor";
sys.set(bucket.to_string(), Arc::new(BucketMetadata::new(bucket))).await;
assert_eq!(sys.get_on_demand_migration_config(bucket).await.unwrap(), None);
let mut corrupt = BucketMetadata::new(bucket);
corrupt.on_demand_migration_config_json = br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec();
sys.set(bucket.to_string(), Arc::new(corrupt)).await;
let err = sys
.get_on_demand_migration_config(bucket)
.await
.expect_err("corrupt config must not read as a default");
assert_ne!(err, Error::ConfigNotFound, "corruption must not be reported as absence");
let typed = match &err {
Error::Io(io) => io
.get_ref()
.and_then(|source| source.downcast_ref::<OnDemandMigrationConfigError>()),
_ => None,
};
assert!(
matches!(typed, Some(OnDemandMigrationConfigError::Malformed(_))),
"typed parse error must survive the Result boundary, got: {err:?}"
);
let mut valid = BucketMetadata::new(bucket);
valid
.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.unwrap();
let stamped = valid.on_demand_migration_config_updated_at;
sys.set(bucket.to_string(), Arc::new(valid)).await;
let (config, updated_at) = sys
.get_on_demand_migration_config(bucket)
.await
.unwrap()
.expect("stored config is returned");
assert_eq!(config, OnDemandMigrationConfig::from_json(ODM_JSON).unwrap());
assert_eq!(updated_at, stamped);
}
/// rustfs/backlog#2148: the publish hook fires on every path that /// rustfs/backlog#2148: the publish hook fires on every path that
/// installs bucket metadata into the cache (set, initial load, peer /// installs bucket metadata into the cache (set, initial load, peer
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring /// reload, refresh loop, lazy load) and withdraws on removal, mirroring
@@ -4204,15 +4625,22 @@ mod tests {
for dir in &dirs { for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist"); std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
} }
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
let incarnation = Uuid::new_v4();
let expect_publish = |before: usize, label: &str| { let expect_publish = |before: usize, label: &str| {
let calls = odm_hook_calls(bucket); let calls = odm_hook_calls(bucket);
assert_eq!(calls.len(), before + 1, "{label} must publish exactly once"); assert_eq!(calls.len(), before + 1, "{label} must publish exactly once");
assert_eq!(calls.last().unwrap().as_ref(), Some(&expected), "{label} must publish the stored config"); assert_eq!(
calls.last().unwrap().as_ref().map(|(bytes, _, _)| bytes.as_slice()),
Some(ODM_JSON),
"{label} must publish the stored bytes"
);
assert_eq!(calls.last().unwrap().as_ref().map(|(_, _, id)| *id), Some(incarnation));
}; };
// set (via persist_new_and_set, which installs through `set`). // set (via persist_new_and_set, which installs through `set`).
let mut bm = BucketMetadata::new(bucket); let mut bm = BucketMetadata::new(bucket);
bm.bucket_incarnation_id = incarnation;
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec()) bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.unwrap(); .unwrap();
let writer = BucketMetadataSys::new(ecstore.clone()); let writer = BucketMetadataSys::new(ecstore.clone());
@@ -4254,14 +4682,18 @@ mod tests {
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once"); assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
assert_eq!(calls.last().unwrap(), &None); assert_eq!(calls.last().unwrap(), &None);
// A corrupt payload is withdrawn, never published as a config. // Opaque bytes reach the application even if they are not valid JSON.
let mut corrupt = BucketMetadata::new(bucket); let mut corrupt = BucketMetadata::new(bucket);
corrupt.on_demand_migration_config_json = b"not-json".to_vec(); corrupt.on_demand_migration_config_json = b"not-json".to_vec();
let before = odm_hook_calls(bucket).len(); let before = odm_hook_calls(bucket).len();
lazy.set(bucket.to_string(), Arc::new(corrupt)).await; lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
let calls = odm_hook_calls(bucket); let calls = odm_hook_calls(bucket);
assert_eq!(calls.len(), before + 1); assert_eq!(calls.len(), before + 1);
assert_eq!(calls.last().unwrap(), &None, "unreadable config must publish absence"); assert_eq!(
calls.last().unwrap().as_ref().map(|(bytes, _, _)| bytes.as_slice()),
Some(b"not-json".as_slice()),
"the application validates opaque config bytes"
);
} }
#[tokio::test] #[tokio::test]
-1
View File
@@ -26,7 +26,6 @@ mod metadata_test;
pub mod migration; pub mod migration;
mod msgp_decode; mod msgp_decode;
pub mod object_lock; pub mod object_lock;
pub mod on_demand_migration;
pub mod policy_sys; pub mod policy_sys;
pub mod quota; pub mod quota;
pub mod remote_s3_client; pub mod remote_s3_client;
@@ -1,824 +0,0 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Optional `ListObjectsV2` list-through (`policy.list_through`,
//! rustfs/backlog#2164): the local listing and the source listing are merged
//! into one ordered page so clients see the whole namespace while a bucket is
//! migrating.
//!
//! Everything here is pure. The handler owns the I/O and the payloads; this
//! module owns the ordering, the page boundary, and the opaque continuation
//! token that carries both cursors.
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
/// The only continuation-token envelope version this build reads and writes.
pub const LIST_THROUGH_TOKEN_VERSION: u32 = 1;
/// Envelope marker. A bucket that is *not* merging hands out the local
/// listing's own marker, so the decoder needs a positive signal before it
/// treats an opaque token as a merged one.
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
/// Pages fetched per side per request: the first page, plus at most one refill
/// when the first one was mostly consumed by the previous page. Two pages of
/// `max_keys` always cover a full merged page, so this is a bound, not a
/// heuristic.
pub const MAX_LIST_FETCHES_PER_SIDE: usize = 2;
/// Per-bucket ceiling on source `ListObjectsV2` calls, in calls per second.
pub const SOURCE_LIST_RATE_PER_SEC: u32 = 10;
/// How long a listing may wait for a source rate-limit slot before it gives up
/// and answers from local state alone.
pub const SOURCE_LIST_MAX_RATE_WAIT: Duration = Duration::from_secs(1);
/// One listing entry as the merge orders it: an object key, or — under a
/// delimiter — a rolled-up common prefix. Both sort by `name` alone, which is
/// how S3 interleaves `Contents` and `CommonPrefixes` on the wire.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListEntryKey {
pub name: String,
pub is_prefix: bool,
}
impl ListEntryKey {
pub fn object(name: impl Into<String>) -> Self {
Self {
name: name.into(),
is_prefix: false,
}
}
pub fn prefix(name: impl Into<String>) -> Self {
Self {
name: name.into(),
is_prefix: true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MergeSide {
Local,
Source,
}
/// One entry of the merged page: the side it came from and its index in that
/// side's buffer, in push order. The caller keeps the payloads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MergePick {
pub side: MergeSide,
pub index: usize,
}
/// The continuation-token envelope. Opaque to clients: it is serialized as
/// JSON and then base64-encoded by the same helper that encodes a plain local
/// marker, so the wire shape is `base64(json)`.
///
/// A `null` cursor with `done = false` means "list that side from the start";
/// `done = true` means the side is finished and must not be listed again.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListThroughToken {
/// Envelope marker, always [`LIST_THROUGH_TOKEN_TAG`].
pub t: String,
pub v: u32,
#[serde(default)]
pub local: Option<String>,
#[serde(default)]
pub local_done: bool,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub source_done: bool,
/// Last entry the previous page consumed. A side whose page was only
/// partially consumed is re-listed from the same cursor and everything at
/// or below this key is dropped, which is delimiter-safe: a rolled-up
/// common prefix compares as itself, never as its members.
#[serde(default)]
pub last_key: Option<String>,
}
impl ListThroughToken {
fn new(local: SideCursor, source: SideCursor, last_key: Option<String>) -> Self {
Self {
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: local.token,
local_done: local.done,
source: source.token,
source_done: source.done,
last_key,
}
}
pub fn encode(&self) -> String {
// The envelope is built here from owned strings, so serialization
// cannot fail; the fallback keeps the signature infallible.
serde_json::to_string(self).unwrap_or_default()
}
}
/// What a decoded (base64-stripped) continuation token turned out to be.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ListThroughCursor {
/// A plain local listing marker: the bucket was not merging when the token
/// was issued, or the client is paginating a non-merged listing.
Local(String),
Merged(Box<ListThroughToken>),
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum ListThroughTokenError {
#[error("continuation token version {0} is not supported")]
UnsupportedVersion(u32),
/// The message never echoes the token: it is client-controlled input.
#[error("continuation token is malformed")]
Malformed,
}
/// Classifies an already base64-decoded continuation token.
///
/// Only a JSON object carrying the envelope marker is read as a merged token;
/// anything else is a local marker, so a bucket that turns `list_through` off
/// keeps paginating with the tokens it handed out. A token that *is* an
/// envelope but was tampered with (unknown version, unknown field, truncated
/// JSON) is an error, never a silent fallback.
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
if !decoded.starts_with('{') {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(decoded) else {
// Not JSON at all: an object key may legitimately start with '{'.
return Ok(ListThroughCursor::Local(decoded.to_string()));
};
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
match value.get("v").and_then(serde_json::Value::as_u64) {
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {}
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
None => return Err(ListThroughTokenError::Malformed),
}
serde_json::from_value::<ListThroughToken>(value)
.map(|token| ListThroughCursor::Merged(Box::new(token)))
.map_err(|_| ListThroughTokenError::Malformed)
}
/// How the source must be listed for a request, given `filter.prefix`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SourceListPlan {
/// The request prefix and `filter.prefix` are disjoint: the source holds
/// nothing this listing could show.
Skip,
/// Ordinary paged listing under `prefix`, rolled up with the request's
/// delimiter — the source's own roll-up boundary matches the request's.
Page { prefix: String },
/// `filter.prefix` reaches past a delimiter, so every key the source could
/// contribute rolls into this one common prefix. One bounded probe listing
/// decides whether it exists; there is nothing to paginate.
Folded { probe_prefix: String, common_prefix: String },
}
/// Intersects the request prefix with `filter.prefix` and decides how (or
/// whether) the source is listed.
pub fn source_list_plan(request_prefix: &str, filter_prefix: Option<&str>, delimiter: Option<&str>) -> SourceListPlan {
let filter = filter_prefix.unwrap_or_default();
let source_prefix = if filter.starts_with(request_prefix) {
filter
} else if request_prefix.starts_with(filter) {
request_prefix
} else {
return SourceListPlan::Skip;
};
let Some(delimiter) = delimiter.filter(|delimiter| !delimiter.is_empty()) else {
return SourceListPlan::Page {
prefix: source_prefix.to_string(),
};
};
// `source_prefix` always starts with `request_prefix`, so this slice is on
// a character boundary.
let extra = &source_prefix[request_prefix.len()..];
match extra.find(delimiter) {
Some(at) => SourceListPlan::Folded {
probe_prefix: source_prefix.to_string(),
common_prefix: format!("{request_prefix}{}", &extra[..at + delimiter.len()]),
},
None => SourceListPlan::Page {
prefix: source_prefix.to_string(),
},
}
}
/// Where one side resumes.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SideCursor {
pub token: Option<String>,
pub done: bool,
}
/// One page a side actually fetched this round.
#[derive(Clone, Debug, PartialEq, Eq)]
struct FetchedPage {
/// Token it was fetched with; `None` means from the start of the listing.
token: Option<String>,
/// Entries it contributed to the buffer, after the `last_key` filter.
count: usize,
/// Cursor for the page after it, `None` when it was the last one.
next_token: Option<String>,
}
/// Where a side resumes after `consumed` of its buffered entries were taken.
///
/// A fully consumed page advances to its successor; a partially consumed one
/// is re-listed from the same cursor next time and re-filtered by `last_key`.
fn advance_cursor(pages: &[FetchedPage], consumed: usize) -> SideCursor {
let mut remaining = consumed;
let mut cursor = SideCursor { token: None, done: true };
for page in pages {
if remaining >= page.count {
remaining -= page.count;
cursor = match &page.next_token {
Some(next) => SideCursor {
token: Some(next.clone()),
done: false,
},
None => SideCursor { token: None, done: true },
};
} else {
cursor = SideCursor {
token: page.token.clone(),
done: false,
};
break;
}
}
cursor
}
/// A page the merge driver still needs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FetchRequest {
pub side: MergeSide,
pub token: Option<String>,
}
#[derive(Debug, Default)]
struct SideState {
start: SideCursor,
pages: Vec<FetchedPage>,
entries: Vec<ListEntryKey>,
more: bool,
disabled: bool,
}
impl SideState {
fn from_cursor(token: Option<String>, done: bool) -> Self {
Self {
start: SideCursor { token, done },
..Default::default()
}
}
fn needs_page(&self, max_keys: usize) -> Option<Option<String>> {
if self.disabled || self.start.done {
return None;
}
match self.pages.last() {
None => Some(self.start.token.clone()),
Some(last) => {
let room = self.entries.len() < max_keys;
let capped = self.pages.len() >= MAX_LIST_FETCHES_PER_SIDE;
(self.more && room && !capped).then(|| last.next_token.clone())
}
}
}
}
/// The merged page, once both sides have handed over everything they will.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MergeOutcome {
/// Entries of the merged page, in wire order; indices point into each
/// side's buffer in push order.
pub picks: Vec<MergePick>,
pub is_truncated: bool,
/// `Some` exactly when `is_truncated`.
pub next_token: Option<ListThroughToken>,
}
/// Drives one merged page: the caller asks [`Self::next_fetch`] what to list,
/// hands the page back with [`Self::push_page`], and finishes with
/// [`Self::finish`]. Nothing here does I/O, so the same driver is exercised by
/// the property test and by the handler.
#[derive(Debug)]
pub struct ListThroughMerger {
max_keys: usize,
last_key: Option<String>,
local: SideState,
source: SideState,
}
impl ListThroughMerger {
/// `token` is the envelope from the client's continuation token, absent on
/// the first page of a listing.
pub fn new(max_keys: usize, token: Option<&ListThroughToken>) -> Self {
let (local, source, last_key) = match token {
Some(token) => (
SideState::from_cursor(token.local.clone(), token.local_done),
SideState::from_cursor(token.source.clone(), token.source_done),
token.last_key.clone(),
),
None => (SideState::default(), SideState::default(), None),
};
Self {
max_keys,
last_key,
local,
source,
}
}
/// Whether an entry the listing returned still belongs to this page: a
/// re-listed page repeats what the previous page already consumed.
pub fn accepts(&self, name: &str) -> bool {
self.last_key.as_deref().is_none_or(|bound| name > bound)
}
/// The source contributes nothing to this page: it failed, is rate-limited,
/// or `filter.prefix` excludes it.
pub fn disable_source(&mut self) {
self.source.disabled = true;
}
pub fn next_fetch(&self) -> Option<FetchRequest> {
for (side, state) in [(MergeSide::Local, &self.local), (MergeSide::Source, &self.source)] {
if let Some(token) = state.needs_page(self.max_keys) {
return Some(FetchRequest { side, token });
}
}
None
}
/// Records one fetched page. `entries` must be sorted by `name` and already
/// filtered with [`Self::accepts`]; the caller keeps the matching payloads
/// in the same order.
pub fn push_page(&mut self, side: MergeSide, entries: Vec<ListEntryKey>, is_truncated: bool, next_token: Option<String>) {
let state = match side {
MergeSide::Local => &mut self.local,
MergeSide::Source => &mut self.source,
};
let token = match state.pages.last() {
Some(last) => last.next_token.clone(),
None => state.start.token.clone(),
};
// A truncated page without a cursor cannot be continued; treating the
// side as finished is the only alternative to looping on it forever.
state.more = is_truncated && next_token.is_some();
state.pages.push(FetchedPage {
token,
count: entries.len(),
next_token: is_truncated.then_some(next_token).flatten(),
});
state.entries.extend(entries);
}
pub fn finish(self) -> MergeOutcome {
let Self {
max_keys,
last_key,
local,
source,
} = self;
// A side with more pages behind it can only be trusted up to the last
// key it handed over: past that horizon the other side's entries could
// still be deduplicated by one we have not seen, which is what keeps
// "local wins on equal keys" true across page boundaries.
let horizon = [
local
.more
.then(|| local.entries.last().map_or("", |entry| entry.name.as_str())),
source
.more
.then(|| source.entries.last().map_or("", |entry| entry.name.as_str())),
]
.into_iter()
.flatten()
.min();
let mut picks = Vec::with_capacity(max_keys.min(local.entries.len() + source.entries.len()));
let mut consumed_local = 0usize;
let mut consumed_source = 0usize;
let mut consumed_key: Option<String> = None;
while picks.len() < max_keys {
let next_local = local.entries.get(consumed_local).map(|entry| entry.name.as_str());
let next_source = source.entries.get(consumed_source).map(|entry| entry.name.as_str());
let name = match (next_local, next_source) {
(None, None) => break,
(Some(name), None) | (None, Some(name)) => name,
(Some(left), Some(right)) => left.min(right),
};
if horizon.is_some_and(|horizon| name > horizon) {
break;
}
let take_local = next_local == Some(name);
let take_source = next_source == Some(name);
consumed_key = Some(name.to_string());
if take_local {
picks.push(MergePick {
side: MergeSide::Local,
index: consumed_local,
});
consumed_local += 1;
} else {
picks.push(MergePick {
side: MergeSide::Source,
index: consumed_source,
});
}
if take_source {
consumed_source += 1;
}
}
let local_cursor = advance_cursor(&local.pages, consumed_local);
let source_cursor = if source.disabled {
// Keep the source where it was so a recovered source resumes there;
// this page is answered from local state alone.
source.start.clone()
} else {
advance_cursor(&source.pages, consumed_source)
};
let local_left = !local_cursor.done || consumed_local < local.entries.len();
let source_left = !source.disabled && (!source_cursor.done || consumed_source < source.entries.len());
let is_truncated = local_left || source_left;
let last_key = consumed_key.or(last_key);
MergeOutcome {
picks,
is_truncated,
next_token: is_truncated.then(|| ListThroughToken::new(local_cursor, source_cursor, last_key)),
}
}
}
/// Token bucket capping source `ListObjectsV2` calls for one bucket.
///
/// A caller that cannot be served inside its budget is refused rather than
/// queued: a listing degrades to local state instead of holding the request
/// open behind other tenants' listings.
#[derive(Debug)]
pub struct SourceListRateLimiter {
rate_per_sec: f64,
burst: f64,
state: Mutex<RateLimiterState>,
}
#[derive(Debug)]
struct RateLimiterState {
tokens: f64,
updated_at: Instant,
}
impl SourceListRateLimiter {
pub fn new(rate_per_sec: u32) -> Self {
let rate_per_sec = f64::from(rate_per_sec.max(1));
Self {
rate_per_sec,
burst: rate_per_sec,
state: Mutex::new(RateLimiterState {
tokens: rate_per_sec,
updated_at: Instant::now(),
}),
}
}
/// Reserves one call, returning how long the caller must wait before making
/// it, or `None` when that wait would exceed `max_wait` (nothing is
/// reserved then).
pub fn reserve(&self, max_wait: Duration) -> Option<Duration> {
self.reserve_at(Instant::now(), max_wait)
}
pub fn reserve_at(&self, now: Instant, max_wait: Duration) -> Option<Duration> {
let mut state = self.state.lock();
let elapsed = now.saturating_duration_since(state.updated_at).as_secs_f64();
state.tokens = (state.tokens + elapsed * self.rate_per_sec).min(self.burst);
state.updated_at = now;
if state.tokens >= 1.0 {
state.tokens -= 1.0;
return Some(Duration::ZERO);
}
let wait = Duration::from_secs_f64((1.0 - state.tokens) / self.rate_per_sec);
if wait > max_wait {
return None;
}
state.tokens -= 1.0;
Some(wait)
}
}
impl Default for SourceListRateLimiter {
fn default() -> Self {
Self::new(SOURCE_LIST_RATE_PER_SEC)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::collections::BTreeSet;
/// One `ListObjectsV2` page over a sorted key set, with the S3 rules the
/// merge relies on: delimiter roll-up, `max_keys`, and a continuation
/// token that resumes after the last entry the page returned.
fn reference_page(
keys: &[String],
prefix: &str,
delimiter: Option<&str>,
after: Option<&str>,
max_keys: usize,
) -> (Vec<ListEntryKey>, bool, Option<String>) {
let mut entries: Vec<ListEntryKey> = Vec::new();
for key in keys.iter().filter(|key| key.starts_with(prefix)) {
let entry = match delimiter.and_then(|delimiter| key[prefix.len()..].find(delimiter).map(|at| (delimiter, at))) {
Some((delimiter, at)) => ListEntryKey::prefix(&key[..prefix.len() + at + delimiter.len()]),
None => ListEntryKey::object(key.clone()),
};
if entries.last().is_none_or(|last| last.name != entry.name) {
entries.push(entry);
}
}
if let Some(after) = after {
entries.retain(|entry| entry.name.as_str() > after);
}
let truncated = entries.len() > max_keys;
entries.truncate(max_keys);
let next = truncated.then(|| entries.last().map(|entry| entry.name.clone())).flatten();
(entries, truncated && next.is_some(), next)
}
/// Full pagination through the merger, returning every entry it emitted and
/// the page sizes it produced.
fn walk(
local: &[String],
source: &[String],
prefix: &str,
delimiter: Option<&str>,
max_keys: usize,
) -> (Vec<(ListEntryKey, MergeSide)>, Vec<usize>) {
let mut emitted = Vec::new();
let mut page_sizes = Vec::new();
let mut token: Option<ListThroughToken> = None;
for _ in 0..10_000 {
let mut merger = ListThroughMerger::new(max_keys, token.as_ref());
let mut buffers = [Vec::<ListEntryKey>::new(), Vec::<ListEntryKey>::new()];
while let Some(fetch) = merger.next_fetch() {
let keys = match fetch.side {
MergeSide::Local => local,
MergeSide::Source => source,
};
let (entries, truncated, next) = reference_page(keys, prefix, delimiter, fetch.token.as_deref(), max_keys);
let kept: Vec<ListEntryKey> = entries.into_iter().filter(|entry| merger.accepts(&entry.name)).collect();
buffers[usize::from(fetch.side == MergeSide::Source)].extend(kept.iter().cloned());
merger.push_page(fetch.side, kept, truncated, next);
}
let outcome = merger.finish();
page_sizes.push(outcome.picks.len());
for pick in &outcome.picks {
let entry = buffers[usize::from(pick.side == MergeSide::Source)][pick.index].clone();
emitted.push((entry, pick.side));
}
if !outcome.is_truncated {
return (emitted, page_sizes);
}
token = outcome.next_token;
}
panic!("merged pagination did not terminate");
}
fn expected(local: &[String], source: &[String], prefix: &str, delimiter: Option<&str>) -> Vec<ListEntryKey> {
let mut all: Vec<String> = local.iter().chain(source.iter()).cloned().collect();
all.sort();
all.dedup();
let (entries, _, _) = reference_page(&all, prefix, delimiter, None, usize::MAX);
entries
}
#[test]
fn reference_page_rolls_up_and_paginates() {
let keys = vec!["a/1".to_string(), "a/2".to_string(), "b".to_string(), "c/1".to_string()];
let (entries, truncated, next) = reference_page(&keys, "", Some("/"), None, 2);
assert_eq!(entries, vec![ListEntryKey::prefix("a/"), ListEntryKey::object("b")]);
assert!(truncated);
assert_eq!(next.as_deref(), Some("b"));
}
#[test]
fn merged_pages_are_ordered_and_local_wins_on_equal_keys() {
let local = vec!["a".to_string(), "c".to_string()];
let source = vec!["b".to_string(), "c".to_string(), "d".to_string()];
let (emitted, sizes) = walk(&local, &source, "", None, 2);
let names: Vec<&str> = emitted.iter().map(|(entry, _)| entry.name.as_str()).collect();
assert_eq!(names, vec!["a", "b", "c", "d"]);
assert_eq!(emitted[2].1, MergeSide::Local, "the shared key must come from local");
assert!(sizes.iter().all(|size| *size <= 2), "{sizes:?}");
}
#[test]
fn source_only_listing_paginates_without_a_local_side() {
let source: Vec<String> = (0..7).map(|index| format!("k{index}")).collect();
let (emitted, _) = walk(&[], &source, "", None, 3);
assert_eq!(emitted.len(), 7);
assert!(emitted.iter().all(|(_, side)| *side == MergeSide::Source));
}
#[test]
fn a_disabled_source_answers_from_local_alone() {
let mut merger = ListThroughMerger::new(10, None);
merger.disable_source();
assert_eq!(
merger.next_fetch(),
Some(FetchRequest {
side: MergeSide::Local,
token: None
})
);
merger.push_page(MergeSide::Local, vec![ListEntryKey::object("a")], false, None);
assert_eq!(merger.next_fetch(), None);
let outcome = merger.finish();
assert_eq!(outcome.picks.len(), 1);
assert!(!outcome.is_truncated);
assert!(outcome.next_token.is_none());
}
#[test]
fn a_degraded_page_keeps_the_source_cursor_for_the_next_one() {
let resume = ListThroughToken {
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: Some("local-1".to_string()),
local_done: false,
source: Some("source-1".to_string()),
source_done: false,
last_key: Some("a".to_string()),
};
let mut merger = ListThroughMerger::new(1, Some(&resume));
merger.disable_source();
merger.push_page(
MergeSide::Local,
vec![ListEntryKey::object("b"), ListEntryKey::object("c")],
true,
Some("local-2".to_string()),
);
let outcome = merger.finish();
assert!(outcome.is_truncated);
let token = outcome.next_token.expect("truncated page carries a token");
assert_eq!(token.source.as_deref(), Some("source-1"), "the source cursor must not move");
assert!(!token.source_done);
assert_eq!(token.last_key.as_deref(), Some("b"));
assert_eq!(token.local.as_deref(), Some("local-1"), "a partly read page is re-listed");
}
#[test]
fn token_round_trips_and_rejects_tampering() {
let token = ListThroughToken::new(
SideCursor {
token: Some("l".to_string()),
done: false,
},
SideCursor { token: None, done: true },
Some("k".to_string()),
);
let encoded = token.encode();
assert_eq!(decode_continuation_token(&encoded), Ok(ListThroughCursor::Merged(Box::new(token))));
let bumped = encoded.replace("\"v\":1", "\"v\":2");
assert_eq!(decode_continuation_token(&bumped), Err(ListThroughTokenError::UnsupportedVersion(2)));
let extra = encoded.replace("{", "{\"x\":1,");
assert_eq!(decode_continuation_token(&extra), Err(ListThroughTokenError::Malformed));
let truncated = &encoded[..encoded.len() - 3];
assert_eq!(decode_continuation_token(truncated), Ok(ListThroughCursor::Local(truncated.to_string())));
let no_version = "{\"t\":\"odm-list\"}";
assert_eq!(decode_continuation_token(no_version), Err(ListThroughTokenError::Malformed));
}
#[test]
fn a_plain_local_marker_stays_local() {
assert_eq!(
decode_continuation_token("photos/2024/01.jpg"),
Ok(ListThroughCursor::Local("photos/2024/01.jpg".to_string()))
);
assert_eq!(
decode_continuation_token("{not json"),
Ok(ListThroughCursor::Local("{not json".to_string()))
);
assert_eq!(
decode_continuation_token("{\"t\":\"other\"}"),
Ok(ListThroughCursor::Local("{\"t\":\"other\"}".to_string()))
);
}
#[test]
fn source_list_plan_intersects_the_filter_prefix() {
assert_eq!(source_list_plan("", None, None), SourceListPlan::Page { prefix: String::new() });
assert_eq!(
source_list_plan("photos/2024/", Some("photos/"), None),
SourceListPlan::Page {
prefix: "photos/2024/".to_string()
}
);
assert_eq!(
source_list_plan("photos/", Some("photos/2024/"), None),
SourceListPlan::Page {
prefix: "photos/2024/".to_string()
}
);
assert_eq!(source_list_plan("videos/", Some("photos/"), None), SourceListPlan::Skip);
assert_eq!(
source_list_plan("", Some("photos/2024/"), Some("/")),
SourceListPlan::Folded {
probe_prefix: "photos/2024/".to_string(),
common_prefix: "photos/".to_string(),
}
);
assert_eq!(
source_list_plan("pho", Some("photos"), Some("/")),
SourceListPlan::Page {
prefix: "photos".to_string()
},
"a filter prefix that adds no delimiter keeps the source's own roll-up"
);
}
#[test]
fn rate_limiter_spends_its_burst_then_paces_and_refuses() {
let limiter = SourceListRateLimiter::new(10);
let start = Instant::now();
for _ in 0..10 {
assert_eq!(limiter.reserve_at(start, Duration::from_secs(1)), Some(Duration::ZERO));
}
let paced = limiter.reserve_at(start, Duration::from_secs(1)).expect("within the budget");
assert!(paced > Duration::ZERO && paced <= Duration::from_millis(101), "{paced:?}");
assert_eq!(limiter.reserve_at(start, Duration::ZERO), None, "a zero budget refuses");
// A full second of refill restores the whole burst.
assert_eq!(limiter.reserve_at(start + Duration::from_secs(5), Duration::ZERO), Some(Duration::ZERO));
}
fn key_set() -> impl Strategy<Value = Vec<String>> {
proptest::collection::btree_set(
proptest::sample::select(vec!["a", "a/", "a/1", "a/2", "a/b/1", "b", "b/1", "c", "c/1", "c/2", "d", "d/e/f"])
.prop_map(str::to_string),
0..=12,
)
.prop_map(|set: BTreeSet<String>| set.into_iter().collect())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Full pagination of a merged listing equals the sorted, deduplicated
/// union of both sides, with every shared key served by local, and no
/// page longer than `max_keys`.
#[test]
fn merged_pagination_equals_the_deduplicated_union(
local in key_set(),
source in key_set(),
max_keys in 1usize..=5,
with_delimiter in any::<bool>(),
prefix in proptest::sample::select(vec!["", "a", "a/", "c/"]),
) {
let delimiter = with_delimiter.then_some("/");
let (emitted, sizes) = walk(&local, &source, prefix, delimiter, max_keys);
let got: Vec<ListEntryKey> = emitted.iter().map(|(entry, _)| entry.clone()).collect();
prop_assert_eq!(got, expected(&local, &source, prefix, delimiter));
prop_assert!(sizes.iter().all(|size| *size <= max_keys), "{:?}", sizes);
for (entry, side) in &emitted {
if !entry.is_prefix && local.iter().any(|key| key == &entry.name) {
prop_assert_eq!(*side, MergeSide::Local, "local must win for {}", entry.name);
}
}
}
}
}
+174 -5
View File
@@ -180,6 +180,8 @@ impl RemoteS3EndpointSpec {
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum RemoteS3ClientError { pub enum RemoteS3ClientError {
#[error("the {0} backend is not included in this build")]
BackendNotCompiled(&'static str),
#[error("remote endpoint requires credentials")] #[error("remote endpoint requires credentials")]
MissingCredentials, MissingCredentials,
#[error("{0}")] #[error("{0}")]
@@ -281,9 +283,7 @@ impl Intercept for UserAgentSuffixInterceptor {
/// Builds the SDK config for `spec` without finalizing it, so callers can add /// Builds the SDK config for `spec` without finalizing it, so callers can add
/// interceptors or (in tests) swap the HTTP client before `build()`. /// interceptors or (in tests) swap the HTTP client before `build()`.
pub(crate) async fn build_remote_s3_config( pub async fn build_remote_s3_config(spec: &RemoteS3EndpointSpec) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
spec: &RemoteS3EndpointSpec,
) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
let Some(credentials) = &spec.credentials else { let Some(credentials) = &spec.credentials else {
return Err(RemoteS3ClientError::MissingCredentials); return Err(RemoteS3ClientError::MissingCredentials);
}; };
@@ -523,7 +523,7 @@ fn validate_ca_pem_bundle(ca_cert_pem: &[u8]) -> Result<(), String> {
Ok(()) Ok(())
} }
pub(crate) fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> { pub fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
validate_ca_pem_bundle(ca_cert_pem.as_bytes()).map_err(RemoteS3ClientError::InvalidCaPem) validate_ca_pem_bundle(ca_cert_pem.as_bytes()).map_err(RemoteS3ClientError::InvalidCaPem)
} }
@@ -652,9 +652,10 @@ async fn build_aws_s3_http_client_from_tls_path() -> Option<SharedHttpClient> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use aws_smithy_async::time::TimeSource;
use aws_smithy_runtime_api::http::StatusCode as SmithyStatusCode; use aws_smithy_runtime_api::http::StatusCode as SmithyStatusCode;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
fn spec(endpoint: &str, secure: bool) -> RemoteS3EndpointSpec { fn spec(endpoint: &str, secure: bool) -> RemoteS3EndpointSpec {
RemoteS3EndpointSpec { RemoteS3EndpointSpec {
@@ -824,6 +825,174 @@ mod tests {
); );
} }
#[derive(Clone, Debug)]
struct ClockSkewTimeSource(Arc<AtomicU64>);
impl TimeSource for ClockSkewTimeSource {
fn now(&self) -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(self.0.load(Ordering::SeqCst))
}
}
#[derive(Clone, Debug)]
struct ClockSkewConnector {
request_headers: RecordedHeaders,
error_code: &'static str,
skew_seconds: i64,
clock: ClockSkewTimeSource,
}
fn recorded_header<'a>(headers: &'a [(String, String)], name: &str) -> &'a str {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
.unwrap_or_else(|| panic!("signed request must contain {name}"))
}
fn signing_time(headers: &[(String, String)]) -> chrono::NaiveDateTime {
chrono::NaiveDateTime::parse_from_str(recorded_header(headers, "x-amz-date"), "%Y%m%dT%H%M%SZ")
.expect("SDK signing timestamp must use the SigV4 format")
}
impl SmithyHttpConnector for ClockSkewConnector {
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
let mut headers = self.request_headers.lock().expect("clock skew request capture lock");
assert!(headers.len() < 3, "clock skew fixture must not exceed two GET attempts and one HEAD");
headers.push(
request
.headers()
.iter()
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect(),
);
let server_time = chrono::DateTime::<chrono::Utc>::from(self.clock.now()).naive_utc()
+ chrono::Duration::seconds(self.skew_seconds);
let (status, body) = if headers.len() == 1 {
(
403,
format!("<Error><Code>{}</Code><Message>Clock skew fixture</Message></Error>", self.error_code),
)
} else {
(200, String::new())
};
let response = http::Response::builder()
.status(status)
.header("date", server_time.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
.header("content-type", "application/xml")
.header("content-length", body.len())
.body(SdkBody::from(body))
.expect("clock skew fixture response");
HttpConnectorFuture::ready(Ok(HttpResponse::try_from(response).expect("Smithy fixture response")))
}
}
async fn clock_skew_client(
error_code: &'static str,
skew_seconds: i64,
retry: RemoteS3RetryPolicy,
) -> (S3Client, RecordedHeaders, ClockSkewTimeSource) {
let headers: RecordedHeaders = Arc::new(Mutex::new(Vec::new()));
let clock = ClockSkewTimeSource(Arc::new(AtomicU64::new(1_700_000_000)));
let connector = SharedHttpConnector::new(ClockSkewConnector {
request_headers: Arc::clone(&headers),
error_code,
skew_seconds,
clock: clock.clone(),
});
let mut spec = spec("s3.example.com", true);
spec.retry = retry;
let config = build_remote_s3_config(&spec)
.await
.expect("clock skew fixture uses the production outbound configuration")
.http_client(http_client_fn(move |_settings, _components| connector.clone()))
.time_source(clock.clone())
.build();
(S3Client::from_conf(config), headers, clock)
}
#[tokio::test(start_paused = true)]
async fn remote_s3_clock_skew_retries_resign_and_seed_next_operation() {
for error_code in ["RequestTimeTooSkewed", "SignatureDoesNotMatch"] {
for skew_seconds in [-600, 600] {
let (client, headers, clock) = clock_skew_client(error_code, skew_seconds, REPLICATION_TARGET_RETRY_POLICY).await;
let initial = chrono::DateTime::<chrono::Utc>::from(clock.now()).naive_utc();
client
.get_object()
.bucket("bucket")
.key("object")
.send()
.await
.expect("clock skew GET must retry successfully");
assert_eq!(
headers.lock().expect("captured requests").len(),
2,
"{error_code}: GET needs exactly one retry"
);
clock.0.fetch_add(17, Ordering::SeqCst);
// SDK signing time is independent of Tokio's retry/scheduler clock.
tokio::time::advance(Duration::from_secs(61)).await;
client
.head_bucket()
.bucket("bucket")
.send()
.await
.expect("subsequent HEAD must use the client's cached skew");
let headers = headers.lock().expect("captured signed requests");
assert_eq!(headers.len(), 3, "subsequent operation must succeed on its first attempt");
assert_eq!(signing_time(&headers[0]), initial, "the first attempt must use the injected clock");
assert_eq!(
signing_time(&headers[1]),
initial + chrono::Duration::seconds(skew_seconds),
"{error_code}: retry must apply the measured offset exactly"
);
assert_eq!(
signing_time(&headers[2]),
initial + chrono::Duration::seconds(skew_seconds + 17),
"{error_code}: the next operation must apply cached skew to the advanced signing clock"
);
let signature = |index: usize| {
recorded_header(&headers[index], "authorization")
.rsplit_once("Signature=")
.expect("SigV4 authorization contains a signature")
.1
};
assert_ne!(
signature(0),
signature(1),
"{error_code}: retry must be signed again after adjusting its date"
);
}
}
}
#[tokio::test(start_paused = true)]
async fn remote_s3_clock_skew_respects_one_attempt_policy() {
use aws_smithy_types::error::metadata::ProvideErrorMetadata;
for error_code in ["RequestTimeTooSkewed", "SignatureDoesNotMatch"] {
for retry in [
RemoteS3RetryPolicy::Disabled,
RemoteS3RetryPolicy::Standard { max_attempts: 1 },
] {
let (client, headers, _clock) = clock_skew_client(error_code, 600, retry).await;
let error = client
.get_object()
.bucket("bucket")
.key("object")
.send()
.await
.expect_err("clock skew must not override the caller's one-attempt budget");
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some(error_code));
assert_eq!(
headers.lock().expect("captured requests").len(),
1,
"{error_code}: {retry:?} must send exactly one request"
);
}
}
}
#[test] #[test]
fn path_style_auto_and_path_force_path_style() { fn path_style_auto_and_path_force_path_style() {
assert!(PathStyle::Auto.force_path_style()); assert!(PathStyle::Auto.force_path_style());
@@ -66,6 +66,7 @@ pub(crate) use replication_lifecycle_bridge::ReplicationLifecycleBridge;
pub(crate) use replication_migration_bridge::ReplicationMigrationBridge; pub(crate) use replication_migration_bridge::ReplicationMigrationBridge;
pub use replication_object_bridge::ReplicationObjectBridge; pub use replication_object_bridge::ReplicationObjectBridge;
pub use replication_object_config::{DeleteReplicationConfigSnapshot, ReplicationConfig}; pub use replication_object_config::{DeleteReplicationConfigSnapshot, ReplicationConfig};
pub(crate) use replication_object_decision_boundary::replication_etags_match;
pub use replication_object_decision_boundary::{ pub use replication_object_decision_boundary::{
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config, MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config,
delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info, delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info,
@@ -88,5 +89,6 @@ pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats}; pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage}; pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
pub use replication_target_boundary::SsecPassthroughCapability; pub use replication_target_boundary::SsecPassthroughCapability;
pub use replication_target_boundary::VersionIdentityCapability;
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity}; pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge; pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
#[cfg(test)]
pub(crate) use rustfs_filemeta::ObjectPartInfo;
pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry}; pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
pub(crate) use rustfs_replication::{ pub(crate) use rustfs_replication::{
REPLICATE_EXISTING, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction, REPLICATE_EXISTING, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction,
@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
#[cfg(test)]
pub(crate) use rustfs_replication::ReplicationMultipartPlanError;
pub use rustfs_replication::{ pub use rustfs_replication::{
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config, MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config,
delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info, delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info,
@@ -20,9 +22,9 @@ pub use rustfs_replication::{
pub(crate) use rustfs_replication::{ pub(crate) use rustfs_replication::{
ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject, delete_marker_purge_mrf_entry, ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject, delete_marker_purge_mrf_entry,
delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision, delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision,
delete_replication_object_opts, heal_uses_delete_replication_path, is_object_lock_denied_delete, delete_replication_object_opts, delete_replication_target_version_id, heal_uses_delete_replication_path,
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match, is_object_lock_denied_delete, is_retryable_delete_replication_head_error, is_version_delete_replication,
replication_multipart_complete_actual_size, replication_multipart_part_plan, replication_single_put_size_error, replicate_delete_outcome, replication_etags_match, replication_multipart_complete_actual_size,
resync_existing_delete_replication_info, resync_target_for_object, should_retry_delete_marker_purge, replication_multipart_part_plan, replication_single_put_size_error, resync_existing_delete_replication_info,
single_part_replica_etag_mismatch, target_delete_version_id, resync_target_for_object, should_retry_delete_marker_purge, single_part_replica_etag_mismatch,
}; };
@@ -46,7 +46,7 @@ use super::replication_storage_boundary::{
HTTPPreconditions, ObjectInfo, ObjectOptions, ObjectToDelete, ReplicationDeletedObject, ReplicationObjectIO, HTTPPreconditions, ObjectInfo, ObjectOptions, ObjectToDelete, ReplicationDeletedObject, ReplicationObjectIO,
ReplicationStorage, ReplicationStorage,
}; };
use super::replication_target_boundary::{ReplicationTargetStore, replication_object_is_ssec_encrypted}; use super::replication_target_boundary::{BucketTargetError, ReplicationTargetStore, replication_object_is_ssec_encrypted};
use super::replication_versioning_boundary::ReplicationVersioningStore; use super::replication_versioning_boundary::ReplicationVersioningStore;
use super::runtime_boundary as runtime_sources; use super::runtime_boundary as runtime_sources;
use futures_util::stream::{self, StreamExt}; use futures_util::stream::{self, StreamExt};
@@ -882,6 +882,20 @@ fn reconstructed_heal_delete_info(
) -> DeletedObjectReplicationInfo { ) -> DeletedObjectReplicationInfo {
let mut rstate = oi.replication_state(); let mut rstate = oi.replication_state();
rstate.replicate_decision_str = dsc.to_string(); rstate.replicate_decision_str = dsc.to_string();
// The caller hands us a blank ObjectInfo (the source marker may already be
// gone), so the state above carries no target-assigned marker version ids.
// Restore them from the journal: `delete_marker_purge_version_id` must hit
// the id the target reported, not fall back to the source marker id, which
// a target that mints its own ids answers with an idempotent 204 that would
// acknowledge the intent while the real marker stays behind (backlog#2290).
// The corrupt flag rides along so a refusal stays a refusal after restart.
for (arn, version_id) in &entry.target_delete_marker_version_ids {
rstate
.target_delete_marker_version_ids
.entry(arn.clone())
.or_insert_with(|| version_id.clone());
}
rstate.target_delete_marker_version_ids_corrupt |= entry.target_delete_marker_version_ids_corrupt;
let delete_marker_mtime = entry let delete_marker_mtime = entry
.delete_marker_mtime .delete_marker_mtime
@@ -3084,6 +3098,23 @@ pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u
let tgts = match ReplicationTargetStore::list_bucket_targets(bucket).await { let tgts = match ReplicationTargetStore::list_bucket_targets(bucket).await {
Ok(targets) => Some(targets), Ok(targets) => Some(targets),
// A bucket whose persisted target configuration cannot be decoded has
// an unknown target set, not an empty one: scheduling against `None`
// here would drop every heal for it without a trace
// (rustfs/backlog#2282). Report it missed so the object is retried
// once the configuration is readable again.
Err(BucketTargetError::BucketRemoteTargetsUnreadable { .. }) => {
warn!(
event = EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION,
bucket,
reason = "target_config_unreadable",
"Bucket replication targets are unreadable; replication heal queue fails closed"
);
return ReplicationQueueAdmission::Missed;
}
Err(err) => { Err(err) => {
debug!( debug!(
event = EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED, event = EVENT_REPLICATION_CONFIG_LOOKUP_SKIPPED,
@@ -3189,12 +3220,15 @@ pub(crate) async fn queue_replication_heal_internal(
} }
ReplicationHealQueueAction::QueueDelete(dv) => { ReplicationHealQueueAction::QueueDelete(dv) => {
// A purge the peer denied under object lock cannot succeed until // A purge the peer denied under object lock cannot succeed until
// the lock lapses (#6850); requeuing it every heal cycle only // the lock lapses (#6850), and one whose replica cannot be told
// apart on a target that mints its own version ids cannot
// succeed until the ledger or an operator resolves it
// (rustfs/backlog#2340); requeuing either every heal cycle only
// burns bandwidth and failure counters. The backoff expires on // burns bandwidth and failure counters. The backoff expires on
// its own, so the purge is probed again — and converges — once // its own, so the purge is probed again — and converges — once
// the retention window has a chance of being over. // the condition has a chance of being over.
if super::replication_object_decision_boundary::is_version_delete_replication(&dv.delete_object) if super::replication_object_decision_boundary::is_version_delete_replication(&dv.delete_object)
&& super::replication_resyncer::object_lock_denied_purge_backoff_active(&dv) && super::replication_resyncer::purge_backoff_active(&dv)
{ {
return ReplicationHealQueueResult { return ReplicationHealQueueResult {
object_info: roi, object_info: roi,
@@ -6584,4 +6618,87 @@ mod tests {
replacement_data replacement_data
); );
} }
/// backlog#2290: a delete-marker purge intent that survives a restart
/// through the MRF journal addresses the marker version the TARGET
/// assigned, exactly as the live watcher does (see the
/// `requires_delayed_purge` spawn). The journal carries the per-ARN ids
/// (`targetDeleteMarkerVersionIDs`) and replay restores them into the
/// reconstructed replication state; without that the replay would fall
/// back to the source marker id, which a target that mints its own ids
/// answers with an idempotent 204 — the entry would be acknowledged while
/// the real marker stayed behind.
#[test]
fn mrf_delete_marker_purge_replay_preserves_target_assigned_marker_version() {
use super::super::replication_object_decision_boundary::{delete_marker_purge_mrf_entry, delete_marker_purge_version_id};
let arn = "arn:minio:replication::generic-target:photos".to_string();
let source_marker = uuid::Uuid::new_v4();
let remote_marker = "remote-assigned-marker-version".to_string();
let live_oi = ObjectInfo {
bucket: "photos".to_string(),
name: "obj".to_string(),
version_id: Some(source_marker),
delete_marker: true,
..Default::default()
};
let mut live_state = live_oi.replication_state();
live_state.replicate_decision_str = replicate_decision_for_admitted_targets(std::slice::from_ref(&arn)).to_string();
live_state
.target_delete_marker_version_ids
.insert(arn.clone(), remote_marker.clone());
let live = DeletedObjectReplicationInfo {
delete_object: ReplicationDeletedObject {
object_name: "obj".to_string(),
delete_marker: true,
delete_marker_version_id: Some(source_marker),
replication_state: Some(live_state),
..Default::default()
},
bucket: "photos".to_string(),
..Default::default()
};
assert_eq!(
delete_marker_purge_version_id(live.delete_object.replication_state.as_ref(), &arn, source_marker),
Some(Some(remote_marker.clone())),
"the live purge addresses the recorded target version"
);
// Watch window exhausted: persist the intent, restart, replay it.
let entry = delete_marker_purge_mrf_entry(&live, vec![arn.clone()]);
let replay_oi = ObjectInfo {
bucket: entry.bucket.clone(),
name: entry.object.clone(),
version_id: entry.version_id,
delete_marker: entry.delete_marker,
..Default::default()
};
let dsc = replicate_decision_for_admitted_targets(&entry.target_arns);
let replayed = reconstructed_heal_delete_info(&entry, &replay_oi, &dsc);
assert_eq!(
delete_marker_purge_version_id(replayed.delete_object.replication_state.as_ref(), &arn, source_marker),
Some(Some(remote_marker)),
"the MRF replay must address the target-assigned marker version, not source marker {source_marker}"
);
// A refusal (inconsistent recorded ids) must stay a refusal across the
// journal round trip instead of degrading into the source-id fallback.
let mut refused = live;
refused
.delete_object
.replication_state
.as_mut()
.expect("state was set above")
.target_delete_marker_version_ids_corrupt = true;
let entry = delete_marker_purge_mrf_entry(&refused, vec![arn.clone()]);
assert!(entry.target_delete_marker_version_ids_corrupt);
let replayed = reconstructed_heal_delete_info(&entry, &replay_oi, &dsc);
assert_eq!(
delete_marker_purge_version_id(replayed.delete_object.replication_state.as_ref(), &arn, source_marker),
None,
"the MRF replay must keep refusing to guess when the recorded ids were inconsistent"
);
}
} }
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,8 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use crate::bucket::bucket_target_sys::{BucketTargetError, BucketTargetSys}; pub(crate) use crate::bucket::bucket_target_sys::BucketTargetError;
use crate::bucket::bucket_target_sys::BucketTargetSys;
use aws_sdk_s3::operation::head_object::HeadObjectOutput; use aws_sdk_s3::operation::head_object::HeadObjectOutput;
use aws_sdk_s3::types::{ObjectLockLegalHoldStatus, ObjectLockRetentionMode}; use aws_sdk_s3::types::{ObjectLockLegalHoldStatus, ObjectLockRetentionMode};
use http::HeaderMap; use http::HeaderMap;
@@ -37,7 +38,7 @@ use time::format_description::well_known::Rfc3339;
pub(crate) use crate::bucket::bucket_target_sys::{ pub(crate) use crate::bucket::bucket_target_sys::{
AdvancedPutOptions, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions, RemotePutObjectResponse, RemoveObjectOptions, AdvancedPutOptions, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions, RemotePutObjectResponse, RemoveObjectOptions,
S3ClientError, TargetClient, resolve_read_api_version_id, ReplicaLocation, S3ClientError, TargetClient, resolve_read_api_version_id,
}; };
#[cfg(test)] #[cfg(test)]
pub(crate) use crate::bucket::target::BucketTarget; pub(crate) use crate::bucket::target::BucketTarget;
@@ -47,6 +48,7 @@ pub use rustfs_replication::{ObjectLockIntegrity, object_lock_put_integrity};
pub(crate) use rustfs_replication::{ pub(crate) use rustfs_replication::{
SsecPassthroughGate, is_replication_target_offline_error, ssec_passthrough_gate, version_identity_drifted, SsecPassthroughGate, is_replication_target_offline_error, ssec_passthrough_gate, version_identity_drifted,
}; };
pub use rustfs_replication::{VersionIdentityCapability, version_identity_capability_from_put};
use super::replication_config_store::ReplicationConfigStore; use super::replication_config_store::ReplicationConfigStore;
use super::replication_error_boundary::{Error, Result}; use super::replication_error_boundary::{Error, Result};
@@ -191,6 +193,14 @@ impl ReplicationTargetStore {
.await .await
} }
pub(crate) fn version_identity_capability(arn: &str) -> VersionIdentityCapability {
BucketTargetSys::get().version_identity_capability(arn)
}
pub(crate) fn record_version_identity_capability(arn: &str, capability: VersionIdentityCapability) {
BucketTargetSys::get().record_version_identity_capability(arn, capability)
}
#[cfg(test)] #[cfg(test)]
pub(crate) async fn register_test_target(target_client: &Arc<TargetClient>) { pub(crate) async fn register_test_target(target_client: &Arc<TargetClient>) {
BucketTargetSys::get().arn_remotes_map.write().await.insert( BucketTargetSys::get().arn_remotes_map.write().await.insert(
@@ -247,7 +257,16 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
meta.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string()); meta.insert(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "aws:kms".to_string());
} }
let mut is_multipart = object_info.is_multipart(); // Older transformed objects can have physical parts without logical part
// lengths. Keep their existing whole-object transport: physical sizes are
// not plaintext boundaries for a multipart replication read.
let legacy_single_put = object_info.etag.as_deref().is_none_or(|etag| etag.len() == 32);
let base_is_multipart = object_info.is_multipart()
&& !(legacy_single_put
&& object_info.parts.len() > 1
&& (object_info.is_compressed() || object_info.is_encrypted())
&& object_info.parts.iter().any(|part| part.actual_size <= 0));
let mut is_multipart = base_is_multipart;
if let Some(checksum_data) = &object_info.checksum if let Some(checksum_data) = &object_info.checksum
&& !checksum_data.is_empty() && !checksum_data.is_empty()
@@ -258,8 +277,8 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
} else if object_info.is_encrypted() { } else if object_info.is_encrypted() {
// Encrypted checksums cannot be exposed as plaintext headers, and // Encrypted checksums cannot be exposed as plaintext headers, and
// decrypt_checksums reports is_multipart=false for them (a value // decrypt_checksums reports is_multipart=false for them (a value
// the response path relies on). Keep the object's own multipart // the response path relies on). Keep the transport selected from
// flag so encrypted objects stay on the multipart route. // the object's layout and readable part boundaries.
} else { } else {
let (checksum_meta, checksum_record_is_multipart) = object_info.decrypt_checksums(0, &HeaderMap::new())?; let (checksum_meta, checksum_record_is_multipart) = object_info.decrypt_checksums(0, &HeaderMap::new())?;
// The checksum record describes how the *checksum* is composed, // The checksum record describes how the *checksum* is composed,
@@ -267,23 +286,37 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
// MULTIPART flag even on a multipart upload, so trusting it here // MULTIPART flag even on a multipart upload, so trusting it here
// routed a 768-part object through a single PutObject and the // routed a 768-part object through a single PutObject and the
// target rejected the 6 GiB body with EntityTooLarge // target rejected the 6 GiB body with EntityTooLarge
// (rustfs#6825). The object's own shape is the authority: the // (rustfs#6825). The usable part layout is the authority: the
// record may only add multipart-ness, never take it away. // record may only add multipart-ness, never take it away.
is_multipart = object_info.is_multipart() || checksum_record_is_multipart; is_multipart = base_is_multipart || checksum_record_is_multipart;
for (key, value) in checksum_meta.iter() { if !base_is_multipart
if key != AMZ_CHECKSUM_TYPE {
meta.insert(key.clone(), value.clone());
}
}
if !object_info.is_multipart()
&& checksum_meta && checksum_meta
.get(AMZ_CHECKSUM_TYPE) .get(AMZ_CHECKSUM_TYPE)
.is_some_and(|value| value == AMZ_CHECKSUM_TYPE_FULL_OBJECT) .is_some_and(|value| value == AMZ_CHECKSUM_TYPE_FULL_OBJECT)
{ {
is_multipart = false; is_multipart = false;
} }
// The record keys each checksum by algorithm name ("CRC32"); the
// target only reads `x-amz-checksum-<algorithm>`. Inserting the bare
// name here made `PutObjectOptions::header()` send it as user
// metadata (`x-amz-meta-crc32`), so no replica ever carried the
// source checksum (rustfs/backlog#2340). The object-level record
// describes one PUT body: a multipart replica is rebuilt part by
// part, and its CreateMultipartUpload must not announce a checksum
// the parts do not carry, so the record is forwarded on the
// single-PUT route only (MinIO `getCRCMeta` parity).
if !is_multipart {
for (key, value) in checksum_meta.iter() {
if key == AMZ_CHECKSUM_TYPE {
continue;
}
if let Some(header) = rustfs_rio::ChecksumType::from_string(key).key() {
meta.insert(header.to_string(), value.clone());
}
}
}
} }
} }
@@ -515,6 +548,7 @@ fn is_standard_header(key: &str) -> bool {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::super::replication_filemeta_boundary::ObjectPartInfo;
use super::*; use super::*;
use aws_smithy_types::DateTime; use aws_smithy_types::DateTime;
use rustfs_replication::content_matches_by_etag; use rustfs_replication::content_matches_by_etag;
@@ -549,6 +583,109 @@ mod tests {
checksum.to_bytes(&combined) checksum.to_bytes(&combined)
} }
fn replication_route_metadata() -> [(&'static str, Arc<HashMap<String, String>>); 4] {
let mut compressed = HashMap::new();
rustfs_utils::http::insert_str(&mut compressed, rustfs_utils::http::SUFFIX_COMPRESSION, "zstd".to_string());
[
("plain", Arc::new(HashMap::new())),
("compressed", Arc::new(compressed)),
(
"encrypted",
Arc::new(HashMap::from([(AMZ_SERVER_SIDE_ENCRYPTION.to_string(), "AES256".to_string())])),
),
(
"ssec",
Arc::new(HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())])),
),
]
}
fn replication_route_object(
etag: Option<&str>,
actual_sizes: [i64; 3],
metadata: Arc<HashMap<String, String>>,
) -> ObjectInfo {
ObjectInfo {
etag: etag.map(str::to_string),
size: 48,
actual_size: 12,
user_defined: metadata,
parts: Arc::new(
actual_sizes
.into_iter()
.enumerate()
.map(|(index, actual_size)| ObjectPartInfo {
number: index + 1,
size: 16,
actual_size,
..Default::default()
})
.collect(),
),
..Default::default()
}
}
#[test]
fn legacy_transformed_single_put_parts_keep_the_previous_replication_route() {
let [_, (_, compressed), (_, encrypted), (_, ssec)] = replication_route_metadata();
let cases = [
(
"compressed middle zero",
compressed.clone(),
Some("0123456789abcdef0123456789abcdef"),
[4, 0, 4],
),
("compressed tail unknown", compressed, None, [4, 4, -1]),
(
"encrypted middle unknown",
encrypted,
Some("gggggggggggggggggggggggggggggggg"),
[4, -1, 4],
),
("ssec tail zero", ssec.clone(), None, [4, 4, 0]),
("ssec middle unknown", ssec, Some("gggggggggggggggggggggggggggggggg"), [4, -1, 4]),
];
for (name, metadata, etag, actual_sizes) in cases {
for checksum in [None, Some(full_object_multipart_checksum_record())] {
let mut object_info = replication_route_object(etag, actual_sizes, metadata.clone());
object_info.checksum = checksum;
assert!(object_info.is_multipart(), "{name}: physical parts remain visible to metadata APIs");
assert!(object_info.is_compressed() || object_info.is_encrypted());
let (options, is_multipart) =
replication_put_object_options("STANDARD", &object_info).expect("legacy transformed put options");
assert!(
!is_multipart,
"{name}: unknown logical part sizes must preserve the old whole-object route"
);
assert_eq!(options.internal.source_etag, etag.unwrap_or_default());
if metadata.contains_key(SSEC_ALGORITHM_HEADER) {
assert_eq!(
get_header_map(&options.user_metadata, SUFFIX_REPLICATION_SSEC_CRC).is_some(),
object_info.checksum.is_some(),
"SSE-C checksums retain their raw passthrough transport"
);
}
}
}
}
#[test]
fn positive_part_sizes_and_legacy_multipart_etags_keep_the_replication_route() {
for (name, metadata) in replication_route_metadata() {
for (etag, actual_sizes) in [
("0123456789abcdef0123456789abcdef", [4, 4, 4]),
("0123456789abcdef0123456789abcdef-3", [4, 0, -1]),
] {
let mut object_info = replication_route_object(Some(etag), actual_sizes, metadata.clone());
object_info.checksum = Some(full_object_multipart_checksum_record());
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("multipart put options");
assert!(is_multipart, "{name}/{etag}: usable sizes and old multipart ETags must retain MPU");
}
}
}
#[test] #[test]
fn multipart_object_with_full_object_checksum_keeps_the_multipart_route() { fn multipart_object_with_full_object_checksum_keeps_the_multipart_route() {
// rustfs#6825: a 768-part upload was replicated with a single // rustfs#6825: a 768-part upload was replicated with a single
@@ -581,6 +718,36 @@ mod tests {
); );
} }
#[test]
fn stored_multipart_parts_keep_the_replication_route_without_a_multipart_etag() {
for etag in [Some("0123456789abcdef0123456789abcdef"), None] {
for checksum in [None, Some(full_object_multipart_checksum_record())] {
let object_info = ObjectInfo {
etag: etag.map(str::to_string),
checksum,
parts: Arc::new(
(1..=2)
.map(|number| ObjectPartInfo {
number,
..Default::default()
})
.collect(),
),
..Default::default()
};
let (options, is_multipart) =
replication_put_object_options("STANDARD", &object_info).expect("build put options");
assert!(
is_multipart,
"stored parts must retain multipart routing: etag={etag:?}, checksum={:?}",
object_info.checksum
);
assert_eq!(options.internal.source_etag, etag.unwrap_or_default());
}
}
}
#[test] #[test]
fn checksum_record_never_changes_the_transport_a_single_part_object_needs() { fn checksum_record_never_changes_the_transport_a_single_part_object_needs() {
// The mirror of the rustfs#6825 guard: an object stored as one PUT // The mirror of the rustfs#6825 guard: an object stored as one PUT
@@ -591,6 +758,10 @@ mod tests {
let object_info = ObjectInfo { let object_info = ObjectInfo {
etag: Some("0123456789abcdef0123456789abcdef".to_string()), etag: Some("0123456789abcdef0123456789abcdef".to_string()),
checksum: Some(checksum.to_bytes(&[])), checksum: Some(checksum.to_bytes(&[])),
parts: Arc::new(vec![ObjectPartInfo {
number: 1,
..Default::default()
}]),
..Default::default() ..Default::default()
}; };
@@ -627,6 +798,19 @@ mod tests {
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options"); let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options");
assert!(is_multipart, "a composite-checksum multipart object must stay on the multipart transport"); assert!(is_multipart, "a composite-checksum multipart object must stay on the multipart transport");
for (name, metadata) in replication_route_metadata() {
let mut legacy = replication_route_object(Some("0123456789abcdef0123456789abcdef"), [4, 0, 4], metadata);
legacy.checksum = Some(checksum.to_bytes(&combined));
let (_, record_is_multipart) = legacy.decrypt_checksums(0, &HeaderMap::new()).expect("decode checksum");
let (_, is_multipart) = replication_put_object_options("STANDARD", &legacy).expect("legacy checksum put options");
if legacy.is_encrypted() {
assert!(!is_multipart, "{name}: encrypted checksum records must not change the old transport");
} else {
assert!(record_is_multipart, "the composite checksum must carry its own multipart signal");
assert!(is_multipart, "{name}: a composite record can still promote the legacy route to MPU");
}
}
} }
#[test] #[test]
@@ -1307,12 +1491,63 @@ mod tests {
..Default::default() ..Default::default()
}; };
let (opts, _is_multipart) = replication_put_object_options("", &object_info).expect("build replication put options"); let (opts, is_multipart) = replication_put_object_options("", &object_info).expect("build replication put options");
assert!(!is_multipart, "{name}: a single-part checksum record must keep the single-PUT route");
let header = ty.key().expect("every forwarded algorithm has an x-amz-checksum header");
assert_eq!( assert_eq!(
opts.user_metadata.get(name), opts.user_metadata.get(header),
Some(&checksum.encoded), Some(&checksum.encoded),
"replication must forward the {name} checksum into user_metadata identically to the classic algorithms" "replication must forward the {name} checksum as the {header} header"
);
assert!(
!opts.user_metadata.contains_key(name),
"{name}: the bare algorithm name would leave as x-amz-meta user metadata"
);
}
}
/// The object-level record of a multipart upload (composite or full-object)
/// must not become a PutObject checksum header: the replica is rebuilt
/// through CreateMultipartUpload/UploadPart, and a checksum announced there
/// that the parts do not carry would be rejected by the target.
#[test]
fn replication_put_object_options_keeps_multipart_checksum_records_off_the_wire() {
let mut composite_type = rustfs_rio::ChecksumType::from_string("crc32");
composite_type
.merge(rustfs_rio::ChecksumType::MULTIPART)
.merge(rustfs_rio::ChecksumType::INCLUDES_MULTIPART);
let mut combined = Vec::new();
for part in [b"part-one".as_slice(), b"part-two".as_slice()] {
let part_checksum =
rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::from_string("crc32"), part).expect("part checksum");
combined.extend_from_slice(part_checksum.raw.as_slice());
}
let composite = rustfs_rio::Checksum::new_from_data(composite_type, &combined)
.expect("composite checksum")
.to_bytes(&combined);
for (label, checksum, etag) in [
("composite", composite, "0123456789abcdef0123456789abcdef-2"),
(
"full-object",
full_object_multipart_checksum_record(),
"0123456789abcdef0123456789abcdef-3",
),
] {
let object_info = ObjectInfo {
etag: Some(etag.to_string()),
checksum: Some(checksum),
..Default::default()
};
let (opts, is_multipart) = replication_put_object_options("", &object_info).expect("build replication put options");
assert!(is_multipart, "{label}: a multipart object must keep the multipart route");
assert!(
opts.user_metadata
.keys()
.all(|key| !key.starts_with("x-amz-checksum-") && key != "CRC32"),
"{label}: no object-level checksum may reach the target's CreateMultipartUpload: {:?}",
opts.user_metadata
); );
} }
} }
@@ -203,7 +203,7 @@ mod tests {
use parking_lot::Mutex; use parking_lot::Mutex;
use std::collections::BTreeMap; use std::collections::BTreeMap;
fn encode_context(context: &HashMap<String, String>) -> String { fn encode_context(context: &BTreeMap<String, String>) -> String {
let ordered = context.iter().collect::<BTreeMap<_, _>>(); let ordered = context.iter().collect::<BTreeMap<_, _>>();
serde_json::to_string(&ordered).expect("context serializes") serde_json::to_string(&ordered).expect("context serializes")
} }

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