Compare commits

...

161 Commits

Author SHA1 Message Date
cxymds 9d90884c30 fix(heal): query automatic tasks on their owning node 2026-10-23 04:41:01 +08:00
cxymds 7ed6c9320d test(scanner): reuse storage boundary for scope regression 2026-10-23 02:00:08 +08:00
cxymds 53a5d11743 fix(scanner): bind object heals to the scanning disk scope 2026-10-23 01:28:51 +08:00
cxymds 757b751482 test(ecstore): cover default heal owners and copy metadata races 2026-10-20 11:32:20 +08:00
cxymds 09fb813a00 test(ecstore): fence multipart continue complete and abort during heal 2026-10-20 10:06:54 +08:00
cxymds 738e5fa14a ci(validation): require G04 native and runtime admission 2026-10-20 09:31:57 +08:00
cxymds 8380466f3a test(ecstore): fence marker and batch deletes during heal 2026-10-20 09:26:19 +08:00
cxymds 170ec8d983 ci(validation): require complete race native runtime preflight 2026-10-20 08:59:50 +08:00
cxymds bd0671ba78 test(ecstore): fence version races and bind cleanup slots 2026-10-20 06:27:54 +08:00
cxymds f954164048 ci(validation): admit source-bound G01 native evidence before fleet execution 2026-10-20 04:35:09 +08:00
cxymds 6afdec9fe1 test(ecstore): exercise heal during committed overwrite cleanup 2026-10-20 04:07:05 +08:00
cxymds 87bd36b313 merge: sync validation fixes with current main 2026-10-20 03:35:22 +08:00
cxymds a4c02d5ac3 fix(filemeta): classify supported inline framing damage as corrupt 2026-10-20 01:59:26 +08:00
cxymds 62ff96774f fix(s3): map typed RPC unavailability to service refusal 2026-10-20 00:34:17 +08:00
Chris 6f582b0548 fix(heal): decide object absence from storage evidence, not source (#8697)
* test(heal): add failing regressions for a source-independent absent verdict

Add the regression tests for backlog#2900 that fail on the current code,
where the same object-level not-found is treated as deleted on the plain
heal path but as recreate_failed on the recreate path, and where the
erasure-set healer, the task predicates and the error helpers disagree on
wrapped not-found and transient inputs.

Also add the per-object disposition cache module with its unit tests, the
manager admission tests for it, the compat register entry for the display
boundary, and the admission-invariant notes. The module and the manager
tests are not declared yet, so nothing is wired into the build.

* fix(heal): decide object absence from storage evidence, not request source

Replace the five heal error classifiers (erasure-set healer outcome
mapping, task-level not-found and lock predicates, error.rs recovery
helpers and the storage existence-check matcher) with one typed
classify_heal_error. An object-level not-found from the locked storage
read is Absent for every source and every recreate_missing value; the
recreate path, the usage-observation and scanner object-dir whitelists
are gone, and display-text matching survives only in the marked
classify_error_display boundary.

Add a bounded, expiring per-object disposition memory consulted before
queue admission so a best-effort producer cannot requeue an absent or
terminally failed object on every tick; durable MRF, admin, forced,
high-priority and dry-run requests bypass it. The read-repair producer no
longer forces recreate_missing.

* fix(heal): keep rendered grace windows out of task retries

A grace window rendered into a task message belongs to an attempt that
already finished; its owner schedules the next one. Only typed grace
errors replay the task, as before the classifier merge. Update the
ecstore read-repair black-box test: the producer no longer forces
recreate_missing.

* fix(heal): classify ambiguous legacy delete residue as permanent explicitly

* fix(heal): scope disposition memory and bound absence parsing
2026-10-20 00:11:49 +08:00
Chris f063a8e862 fix(readiness): hold startup readiness until peers re-admit the node (#8704)
A restarted node published FullReady from its own view while survivors still counted its drives unavailable (stale DNS for the old pod IP, then the Offline->Returning->Online hysteresis). A rolling update could then stop the next pod and cost a set its read quorum (#8669).

Add a read-only PeerDriveAdmission internode RPC that reports how a peer sees another host's drives, and gate the startup FullReady transition until a strict majority of each erasure set's peers count this node's drives online. The gate shares the startup budget, releases after 15s of unreachable peers, skips immediately when older peers cannot answer, and can be disabled with RUSTFS_STARTUP_PEER_READMISSION_ENABLE=false.
2026-10-20 00:28:48 +09:00
Chris 83fb90cb6e ci: move API-only and summary jobs to hosted runners (#8707)
Apply the placement rule in .github/workflows/README.md to the 22 jobs
still on sm-standard-2 that only call APIs, read workflow metadata, print
summaries or scan published images, including cla.yml, the busiest
workflow. Moving the ODM cloud-source lane also stops it restoring the
ci-odm-interop key across images, since its writer already runs on
ubuntu-latest; targets-live gets a -hosted cache key for the same reason.
2026-10-20 00:28:24 +09:00
Chris 473cddd7dc ci: gate pull requests on Clippy and warm its check artifacts (#8708)
Clippy ran only after merge, so lint regressions merged freely and then
failed every main run until fixed (10 of 12 main runs on 2026-10-17/18).
Run the workspace Clippy step on every event, warm its check-mode
artifacts in cache-warm with the identical invocation so pull requests
re-check only what they touch, and pin the unconditional step in the
ci_gate contract and its self-test.
2026-10-20 00:27:40 +09:00
cxymds 85c706cac0 fix(observability): retain typed object error diagnostics 2026-10-19 22:13:24 +08:00
Chris 9a1f3d2c4c fix(quota): keep ledger-admitted writes independent of scanner usage (#8699)
Hard quota admission is owned by the durable reservation ledger (decision
D3, rustfs/backlog#2872), but the request-path quota check still read
scanner usage on every write and mapped `UsageUnavailable` to a retryable
503 even for buckets the ledger admits. For a durable quota the checker now
treats scanner usage as reporting input: an unavailable lookup yields
`current_usage = None` and `allowed = true`, counted and logged at debug
level, and admin quota stats report the usage as null instead of zero.
Buckets without a reservation protocol keep the fail-closed snapshot path.

Quotas saved before the reservation protocols are re-saved under protocol
v2 with the same limit, type and created_at. The write goes through a new
guarded save that keeps the admin save's lifecycle/transaction guards,
incarnation check and fleet capability proof and additionally refuses when
the quota revision moved since the cached read, so a concurrent quota edit
wins. It is triggered lazily from the quota read path (background, one
attempt per bucket with a retry interval, never blocking the read) and in
bulk by `POST /rustfs/admin/v3/quota-migration`, which reports every bucket.

The ADR's Consequences section inventories the remaining scanner-usage
"authority" consumers with their meaning after D3, retirement condition and
compatibility window; the operations guide documents the migration and the
report-only usage semantics.

Refs rustfs/backlog#2904, rustfs/backlog#2872, rustfs/rustfs#8678,
rustfs/rustfs#5716, rustfs/rustfs#7350

Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-19 22:45:08 +09:00
Chris 7c75f319bc fix(scanner): stop treating an ordinary PUT as data movement in cycle termination (#8696)
* fix(scanner): stop treating an ordinary PUT as data movement in cycle termination

A continuously written deployment never reached a `Complete` scanner cycle
(#8494, #8305, #7108). Three places in the termination path vetoed the cycle
for an ordinary PUT; each now lets the write shade its bucket, the path
per-bucket certification (#8542) built, and keeps the data-movement fences.

1. Usage publication is fenced by data movement only.
   `scanner_data_usage_publication_blocked` no longer includes
   `namespace_commits_pending()`, so the post-walk probe, the local defer
   decision, the post-lease recheck and the usage-store route probes stop
   superseding or deferring a cycle for a pending rename fanout. The scanner
   publication leases (acquire, validate, target guard) drop the
   pending-commit rejection and the namespace-generation staleness check and
   keep the movement generation, the operation gate, generation exhaustion
   and the purpose/target restriction. A test-util probe lets fixtures wait
   for tails directly.

2. The plan identity is split into a coverage identity and a reuse identity.
   `scanner_activity_coverage_digest` (peer set, protocol, topology, movement,
   and a remote peer's generations) is what a bucket checkpoint is started
   and resumed under, through `ScannerBucketScanPlan::checkpoint_coverage_digest`,
   `DataUsageCacheReuseOptions::checkpoint_plan_digest` and
   `prepare_bucket_checkpoint_with_coverage`. The reuse identity
   (`scanner_activity_structural_digest`) keeps `instance_id` and
   `maintenance_generation`, so a restart or a maintenance change still
   invalidates the baseline and the set caches; a completed sweep is stored
   under it. `publication_blocked` leaves every digest and stays a guardrail
   veto. A remote worker still resumes under the single digest on the wire.

3. A bucket walk that stops early no longer makes the cycle `Incomplete`
   when the bucket has a certified value. The set scan stands in with the
   bucket's flattened value from its previous complete set cache
   (`carry_partial_bucket_values`), the cycle reports it pending
   (`ScannerBucketScanStatus::PartialCarried`), keeps it in the dirty map,
   and the next scoped cycle walks it because the baseline's pending list
   joins the dirty buckets. A bucket with no certified value keeps the cycle
   `Incomplete`; failed buckets are unchanged.

No persisted file, generation, receipt or environment variable is added.
Refs #8494, #8305, #7108, rustfs/backlog#2903.

* chore(ecstore): gate the namespace commit probe to test builds

The scanner publication barrier no longer consults
InstanceContext::namespace_commits_pending, so it has no production
caller left; keep it for tests and the test-util facade only.

* test(rustfs): observe namespace commit ownership directly in node_service tests

The scanner usage publication barrier is movement-only now, so it no
longer reports a pending rename. The node_service tests that assert a
cancelled handler keeps its physical owner, or that wait for rename
tails to drain, read the namespace commit state through a test-only
ECStore probe instead.
2026-10-19 22:23:59 +09:00
Chris c48ff0bbbd test(connect): hash test provenance before listener peer-cred probe (#8703) 2026-10-19 19:50:44 +09:00
Hauser 05b483b31a fix(storage): preserve scanner checkpoints and bound MRF journal I/O (#8692)
* fix(scanner): preserve checkpoints on identity read failures

Distinguish confirmed missing incarnation sidecars from lookup errors before
cache preparation. Preserve durable checkpoint progress when identity reads
are unavailable and report post-scan uncertainty separately from replacement.

Reuse the transaction-fenced reader for optional and required lookups while
retaining existing missing, malformed, system-bucket and lock-loss behavior.
Verify preservation and recovery through a real coordinator with the identity
sidecar write-locked, including unchanged cache revisions and dirty work.

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

* refactor(ecstore): share required incarnation conversion

Keep the missing-incarnation error contract in one conversion used by both
required lookup entry points. This removes a duplicate formatted error site
and satisfies the existing error-format ratchet without changing its baseline.

Pin the full missing-identity error text in the real SetDisks regression.

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

* fix(heal): bound cross-disk MRF checkpoint I/O

Run independent journal disks with bounded concurrency while retaining each
replica's payload, companion and manifest ordering and the mirror phases.
Share encoded bodies, deduplicate local roots and collect every result
without changing receipt deadlines or persistence failure semantics.

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-10-19 18:16:49 +08:00
Chris f3cef98afc fix(scanner): anchor the bitrot cadence and budget per-disk I/O (#8701)
* test(scanner): pin Normal mode when no bitrot start is recorded

Regression for rustfs/backlog#2905: three consecutive cycles that find no
recorded bitrot start currently run Deep through the selection window, and a
Normal cycle never records the cadence start.

* fix(scanner): anchor the bitrot cadence and budget per-disk I/O

Decide the scan mode from a recorded Deep window start only. A cycle that
finds no recorded start runs Normal and anchors the bitrot cadence in
process memory instead of opening the selection window; the Deep cycle that
follows the elapsed cadence records the window start exactly as before. A
missing record used to be read as a window that started at cycle 0, which
ran Deep for deep_window_cycles cycles whenever the start could not be read
back (rustfs/rustfs#8015). The recorded start is also remembered across a
skipped or failed save, and /v3/scanner/status reports the cadence state
under a new bitrot object.

Add a per-local-disk background I/O budget (operations and bytes per second,
defaults by detected media, auto|off|<rate>) that DynamicSleeper charges
before directory listings and object metadata reads as a hard ceiling
layered on the foreground-priority disk admission gate, with runtime config
keys, per-disk status, cycle metrics, pacing pressure, usage_freshness,
Prometheus series, Helm values, admin config help, and operator docs.

Refs rustfs/backlog#2905, rustfs/rustfs#8015, rustfs/rustfs#8377.

* test(scanner): expect Normal retries on erasure sets without a bitrot start

The pending-PUT retry fixture asserted that a 4-drive set revisits every
bucket because the cycle ran Deep. Without a recorded Deep window the cycle
now runs Normal on every layout and reuses the cold inventory.
2026-10-19 18:15:03 +08:00
Chris 2b57fa8758 fix(heal): block replacement and root records per entry (#8700)
Count only post-format rebuild failures against the replacement retry
budget; handoffs, format fences, outages, grace waits and conflicts keep
their durable reason without spending an attempt. Retired generations
(committed handoff or approved legacy migration) no longer own their set,
so fresh admission for other slots proceeds, and a committed predecessor
becomes collectible once its successor is verified.

Block discovery conflicts per generation instead of per set, re-evaluate
them on every pass, and expose the blocked entries with reasons. Report
root heal inventory conflicts per record and keep replaying the others.
Revalidate blocked checkpoints hourly with the current reader and clear
markers the reader accepts. Return a dedicated error for ambiguous legacy
delete residue instead of the retryable SlowDown.

Implements rustfs/backlog#2902. Fixes rustfs/rustfs#8664. Addresses
rustfs/rustfs#8366, rustfs/rustfs#8565, rustfs/rustfs#8645 and part of
rustfs/rustfs#8608.

Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-19 18:12:43 +08:00
Chris 20d9277ec3 ci: return pull-request build jobs to 2 after an IO-bound timeout at 3 (#8698)
The first pull-request run at CARGO_BUILD_JOBS=3 that met a contended
node (39193827281) hit the 75-minute nextest timeout before any test
started. Its sampler shows cargo spinning for 70 minutes with the pod's
io.pressure "full" at 86-88% and cpu.pressure at zero, with node-level
IO pressure already at 36% when the step began: three concurrent
workspace-scale compile units saturate the pod's shared storage, the
mechanism the original #5394 mitigation named. Keep 3 on main pushes and
dispatches, where three runs succeeded, and 2 on pull requests, in both
the nextest and the serial ILM step; pin the ILM budget guard back to
that expression and record the evidence in the step comment.
2026-10-19 17:54:57 +08:00
Chris 2534b6175b fix(scanner): startup gates back off, rebuild and run degraded (#8695)
* fix(scanner): give startup gates automatic exits instead of a pause

Every fail-closed scanner gate now has a bounded automatic exit without
deleting quarantine evidence or relaxing the leader, CAS, movement or
usage-floor fences (rustfs/backlog#2901).

- Recovery retries back off instead of pausing: after the short budget the
  observation reads `backoff`, stays retryable, names the next attempt and is
  capped at ten minutes; one success resets the count and a 24 h window
  restarts the budget (rustfs/rustfs#8297, #8670). The usage-floor failure no
  longer carries the retry count across attempts.
- A corrupt `.bloomcycle.bin` is rebuilt on the iteration after its quarantine
  marker is durable: the fenced leadership claim CAS-replaces the quarantined
  revision under a higher epoch and the marker stays as evidence.
  `future_schema` and `cleanup-pending` remain blocked.
- An unclaimable pause ledger runs the leader degraded: cycles run untracked
  behind the data-movement fence, `persistence_state` reports `degraded`, and
  the claim is retried at most every five minutes (rustfs/rustfs#8251).
- The usage-state full-rebuild reset starts from the empty baseline when every
  usage slot is corrupt and no cycle state exists (rustfs/rustfs#8670, #6951).
- A cycle-state save reconciles non-conflict failures against durable state
  and retries under the same epoch; an exhausted save reloads durable state in
  place. Only a newer owner or an unreadable primary ends the iteration, so
  peers see a changed fence only when ownership changed (rustfs/rustfs#8141).

* test(scanner): align usage-state reset tests with the empty-baseline rebuild

The full-rebuild reset no longer refuses corrupt or identity-less usage slots
when no cycle state exists (rustfs/rustfs#8670). The two tests that encoded
that refusal now check the new contract: the reset starts from the empty
baseline with a bootstrap-pending marker, never an authoritative zero floor,
and a valid backup still supplies the recovery floor.

---------

Co-authored-by: cxymds <cxymds@gmail.com>
2026-10-19 17:49:00 +08:00
Chris 54e39440d2 fix(ecstore): skip node-local prefix dirs in decommission listing (#8702)
Decommission listing failed closed on `.rustfs.sys/buckets/.heal/`, a
directory that holds only the node-local MRF journals on one drive and
no xl.meta. The partial-listing resolver rejected the single prefix
directory below dir quorum and recorded it as unresolved object
metadata, failing the run before the worker's directory skip ran.

Treat a partial sample made only of empty-metadata directory candidates
with zero disk errors as the prefix it is and hand it to the callback
like a quorum-resolved directory; the recursive walk lists any object
beneath it on its own. Samples with disk errors, objects below the
metadata quorum, and mixed samples stay unresolved.

Fixes #8667
2026-10-19 17:48:25 +08:00
cxymds daa3679160 fix(admin): avoid error logs for idle rebalance status (#8694)
* fix(admin): avoid error logs for idle rebalance status

* refactor(admin): reuse rebalance idle error message

---------

Co-authored-by: Chris <anzhengchao@gmail.com>
2026-10-19 18:03:51 +09:00
Chris 90749fbb06 ci: run feature lanes nightly and warm caches on a schedule (#8683) 2026-10-19 16:18:22 +09:00
Chris e6f76cb64f fix(ci): refresh the e2e-full selection for the new policy tests (#8693)
* fix(ci): refresh the e2e-full selection for the new policy tests

#8615 and #8671 added three e2e tests without refreshing the e2e-full
selection digest, so "Verify e2e full membership" has failed on every
main push since. Regenerate the Linux digest from the listing the failed
run uploaded; the same procedure reproduces the previously committed
value from the last green run's listing, and the name-level diff shows
three additions and no removals. The darwin digest is refreshed
separately from a real macOS listing.

* fix(ci): refresh the darwin e2e-full selection digest

Regenerated from a real `cargo nextest list --profile e2e-full -p e2e_test`
on macOS (rustc 1.99.0, cargo-nextest 0.9.140), so the local check on
macOS agrees with the three policy tests added by #8615 and #8671.
2026-10-19 14:04:23 +08:00
Chris 1ec620ac9c fix(ci): restore the release-profile short-credential guard (#8691)
#8682 replaced the cold `cargo check -p rustfs --release` with a
`cargo rustc ... -C debug-assertions=off` on the rustfs lib alone. That
leaves the dependencies on the dev-profile cfg, so the lib also fails on
items gated on one side of cfg(debug_assertions) only (E0599 on
Filenames::Embedded), and the step's "fails solely at the compile_error"
assertion can never hold; the lane has been red on main and every pull
request since. Restore the whole-graph release check, drop the two-job
limit that only a linking step needed, and record why the single-crate
variant is unsound.
2026-10-19 14:03:57 +08:00
Hauser b3bb9d3596 perf(ecstore): reuse fenced delete settlement inspection
Reuse the fingerprint-validated snapshot while the metadata owner fences an unchanged key. Refresh only after absent-key preparation, retaining destructive preflights, sibling refreshes and final inspection.

Co-Authored-By: heihutu <heihutu@gmail.com>
Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-10-19 11:40:51 +09:00
Hauser d156640451 fix(ecstore): recover deletes beside stale data backups
Treat valid foreign transaction markers as inert data-directory leaves during journal-free inspection. Permit fresh authorized missing-key deletion to reclaim safe legacy residue alongside certified absence receipts while preserving automatic ambiguity and generation fences.

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

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-10-19 11:40:51 +09:00
Chris 441b501189 fix(ci): align the ILM build-budget guard with CARGO_BUILD_JOBS 3 (#8687) 2026-10-19 11:40:28 +09:00
Chris c72ed5e909 fix(helm): hold rolling updates until survivors re-admit a replaced pod (#8686) 2026-10-19 11:40:11 +09:00
Chris 392ccadb78 ci: build development packages nightly instead of on every main push (#8684) 2026-10-19 11:39:38 +09:00
Chris f7b2836d7d fix(helm): skip data PVCs when storageclass.dataStorageSize is empty (#8681)
An empty storageclass.dataStorageSize used to be the way to run on
user-supplied drives (extraVolumes/extraVolumeMounts, e.g. JBOD hostPath),
but the chart now always renders data PVC templates, with an invalid empty
storage request.

When the value is empty, render no data volumeClaimTemplates and no data
volumeMounts, drop the data directory preparation from the init container
(and the init container itself when there is nothing left to prepare), and
omit volumeClaimTemplates when no PVC template remains. Standalone mode skips
the data PVC and data volume unless existingClaim.dataClaim is set. Pools may
override the value independently. Default renders are byte-identical.

Fixes #8665
2026-10-19 09:24:00 +09:00
Chris 4d7cd23f8e ci: reuse the dev cache in Connect boundary lanes and raise build jobs (#8682)
Raise CARGO_BUILD_JOBS to 3 on every event in the workspace test lane and
record the closed backlog#1601 experiment: the test-profile compile took
28m21s at 2 on a pull request and 16-23 min at 3 across three successful
main runs.

Merge the two Connect boundary jobs into connect-feature-boundaries. The
offline enrollment gate no longer exports a fresh CARGO_TARGET_DIR, so it
rebuilds only the rustfs crate on top of the restored ci-dev cache; the E2E
root is isolated by the feature gate and the dedicated binary name, not by
the target directory. The short-credential release guard is now
`cargo rustc -p rustfs --lib --features connect-e2e-short-credentials --
-C debug-assertions=off`, which trips the same cfg(not(debug_assertions))
compile_error against cached dev-profile dependencies instead of a cold
release check of the whole graph. Every assertion of both lanes is kept.

Update ci_gate.py and the ci-gates.md PR matrix for the merged job.
2026-10-19 09:16:12 +09:00
Chris 0de680f4a1 chore(docker): upgrade Alpine base image to 3.24.2 (#8680) 2026-10-19 08:57:47 +09:00
Chris ff70c06cef fix(auth): restrict root-derived accounts and credential logs (#8674) 2026-10-19 08:57:31 +09:00
Chris fca83c72e4 ci: move compile-free and prebuilt-binary lanes to hosted runners (#8679)
* ci: move compile-free and prebuilt-binary lanes to hosted runners

Standard GitHub-hosted runners are free for this public repository, while
every sm-standard minute is paid self-hosted capacity. Move the jobs that
never compile (quick checks, typos, scope selection, the required-checks
aggregate, audit, closed-PR cancellation) and the two s3-tests lanes that
only run the prebuilt debug binary to ubuntu-latest, drop the wget install
the custom image needed for typos, and move the upgrade-compatibility
workflow, whose build shape already runs on hosted runners in
e2e-distributed, with renamed cache keys so the hosted and self-hosted
images never share a rust-cache entry. Gate the package summary job on
resolve having run, so development builds stop starting a pod to report a
skipped job.

* docs(ci): document runner billing and placement rules

Record in .github/workflows/README.md which runner labels GitHub bills
(none for this public repository), which ones RustFS pays for, the rule for
when a job may leave ubuntu-latest, the rust-cache single-writer and
same-image constraints, the patterns that have leaked self-hosted minutes,
and a checklist for new or moved jobs. Point to it from .github/AGENTS.md,
docs/testing/ci-gates.md and the ci.yml placement comment so the rule has a
single home.
2026-10-19 08:55:29 +09:00
Chris 22740876b3 docs(architecture): record scanner usage authority and PUT-tail decisions (#8678)
Supersede the 2026-09-03 decision: hard quota admission is owned by the
durable reservation ledger, and scanner data usage is best-effort reporting
input. Record that a pending ordinary PUT commit is a write-barrier event
rather than a usage-publication veto, and split the scanner plan identity
into a coverage identity and a reuse identity.

Refs rustfs/backlog#2872, rustfs/backlog#2903, rustfs/backlog#2904.
2026-10-19 08:33:28 +09:00
Chris ab7001a714 docs: unify agent instructions across tools (#8675) 2026-10-19 07:52:51 +09:00
Hauser b2770312f2 feat(policy): support object creation operation conditions (#8671)
* feat(policy): support object creation operation conditions

Derive the condition from the resolved S3 operation and keep multipart staging separate from object publication. Reject request and claim spoofing and cover policy evaluation and HTTP upload paths.

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

* fix(protocols): preserve multipart staging authorization

Keep the trusted protocol operation until the shared policy context is built, so FTPS and SFTP honor object creation conditions on multipart staging. Reuse the server-only marker and test real backend decisions for IAM and bucket policies.

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

* test(e2e): cover ObjectCreationOperation in STS session policies

Run AWS's conditional-write example 1 as an inline AssumeRole session
policy: object publication needs a request header, while multipart
staging is exempt through s3:ObjectCreationOperation.

---------

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: oc7o <oc7o@sloow.de>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-10-19 07:17:42 +09:00
Chris e0e18908d5 fix(heal): place checkpoint serialization impls before tests (#8663)
* fix(heal): move impls before test module to satisfy clippy items_after_test_module lint

Clippy in Rust 1.99 flags items defined after #[cfg(test)] mod tests.
Move Serialize and Deserialize impls for CheckpointJson before the
test module in crates/heal/src/heal/resume/checkpoint/integrity.rs.

* fix(heal): restore checkpoint integrity file newline

Add the missing final newline so cargo fmt --all --check passes.
The checkpoint serializer implementations and tests remain unchanged.

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

---------

Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-19 07:08:49 +09:00
Ramakrishna Chilaka 34e883e16a perf(filemeta): avoid clones and intermediate allocations in xl.meta codec (#8661)
* perf(filemeta): avoid clones and intermediate allocations in xl.meta codec

- Slice version headers and metadata payloads directly from the xl.meta buffer in FileMeta::unmarshal_msg instead of allocating temporary header/metadata buffers per version.
- Decode FileInfo directly from borrowed xl.meta slices in get_file_info without constructing an intermediate FileMeta, Vec<FileMetaShallowVersion>, or InlineData copy.
- Validate and search InlineData keys on borrowed slices without per-key Vec/String allocations.
- Borrow FileMetaVersion in FileMetaVersionHeader::from and stream object/delete-marker signatures directly into Xxh64Writer instead of cloning full version structs and marshaling temporary buffers.
- Encode FileMetaVersionHeader into a stack buffer in FileMeta::marshal_msg and pre-size output buffers.
- Decode msgpack struct field names onto a 16-byte stack buffer via read_small_str and unwrap PrependByteReader after reading submap headers.

* test(filemeta): pin xl.meta codec compatibility

Pin pre-optimization signatures for historical RustFS and MinIO fixtures,
and cover direct reads across selectors and decode options. Preserve null
inline keys, free-version selection, empty histories, and allocation
boundaries for large version bodies.

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

---------

Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-19 07:01:11 +09:00
0x70 3a5742c5fd feat(policy): support s3:if-none-match and s3:if-match condition keys (#8615)
* feat(policy): support s3:if-none-match and s3:if-match condition keys

Bucket, IAM, and STS session policies that enforce conditional writes
with s3:if-none-match or s3:if-match were rejected with "invalid key
name". Accept both keys and populate them from the If-None-Match and
If-Match request headers. A blank header is left out of the condition
values because the write ignores it, so it cannot satisfy a Null check
for a write that runs unconditionally.

* fix(policy): classify conditional write keys as request-derived

s3:if-match and s3:if-none-match are filled from request headers, but
is_server_derived() reported them as server-derived because their names
lack the x-amz- prefix. Add an explicit arm so header input is never
refused for these keys if they join COMMON_KEYS.

* fix(s3): reject non-wildcard If-None-Match on writes

S3 accepts If-None-Match on PutObject, CopyObject and
CompleteMultipartUpload only as "*" and answers other values with 501
NotImplemented. RustFS compared such a value as an ETag, so a write with
If-None-Match: "<other etag>" overwrote the key while satisfying a
policy that requires s3:if-none-match.

Reject ETag values in the three handlers before any I/O. The store-level
check now treats any non-blank If-None-Match as create-only, so a caller
that skips the handler check cannot weaken the precondition. Drop the
CompleteMultipartUpload ETag pre-check that the rejection supersedes.

* test: tighten conditional write policy coverage

Pin trimming and the non-UTF-8 case in the condition values, and drop
the auth test that re-ran policy evaluation already covered by the
policy crate. Run the three policy e2e scenarios on one server, assert
that ETag If-None-Match cannot overwrite under a Null: false policy and
that CompleteMultipartUpload with If-None-Match: * returns 412. Share
the AssumeRole response parser through common.rs.

* test(ecstore): isolate manual delete recovery from workers

Stop fixture background services before seeding recovery journals so the
explicit recovery pass owns the decisions its assertions inspect.

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

* fix(s3): enforce request-derived conditional write guards

Validate raw If-None-Match headers as well as DTO fields so POST form
uploads cannot skip rejection of unsupported ETag preconditions. Derive
conditional policy keys after identity claims and clear colliding claims
when the actual request has no usable precondition.

Align multipart and pinned Ceph regressions with wildcard-only writes,
retaining If-Match, object-content, version and delete-marker assertions.
Document that 501 is the implementation's unsupported-value response.

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

* perf(s3): eliminate conditional write request copies

Borrow request headers during policy condition collection and exclude
conditional keys at the identity-claim boundary instead of inserting and
removing them. Preserve existing aliases and multivalue header behavior.

Move the CompleteMultipartUpload input out of its request, compare strong
ETags by reference, and remove the ETag If-None-Match branch made unreachable
by the write guard. Keep both form/HTTP input checks and storage commit
preconditions intact.

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

* test(s3): align ignored conditional PUT and COPY cases

Use If-None-Match: * for the existing-object 412 regressions and assert 501
for matching and nonmatching ETag values. Cover both header-only callers
and parsed DTO-plus-header requests in the serial ILM fixtures.

Check object bytes and live version counts after each rejected operation.
Keep the existing test names and ignored-test lane selection unchanged.

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

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-19 07:00:21 +09:00
InitCool e2583ddcd6 feat(oidc): make the Console session duration configurable (#8568)
* feat(oidc): make the Console session duration configurable

The OIDC callback hard-coded the Console credential lifetime at 3600
seconds, so a Console session always ended after about an hour.
RUSTFS_CONSOLE_AUTH_TIMEOUT, the documented knob next to this behaviour,
is the Console HTTP request timeout and cannot extend a session.

Resolve the lifetime from the process-level
RUSTFS_CONSOLE_OIDC_SESSION_DURATION instead and forward it to
complete_authorization_code(). Unset, unparseable, zero, and negative
values keep DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS (3600), so
existing deployments keep the previous behaviour and a misconfigured
variable can never issue an already expired credential. Values above
CONSOLE_OIDC_SESSION_DURATION_MAX_SECS (7 days) are clamped to that
maximum with a warning, which also caps how far an operator can stretch a
credential by accident.

Keep the Console logout token for that same lifetime. OidcStateStore
pinned the logout cache to a hard-coded hour, so a session configured to
last longer than that dropped its logout token while the credential was
still valid: RP-initiated logout could no longer resolve, and the browser
was sent back to the Console login page with the IdP session still
active. The logout cache now derives its TTL from the same
console_session_duration_secs(), which applies the same clamp, and the
callback reads its lifetime from that one function instead of a local
copy, so the credential lifetime and the logout-token retention cannot
drift apart. Add tests covering the default hour, an extended session,
the maximum, and out-of-range values.

The variable is deliberately not part of ENV_IDENTITY_OPENID_KEYS: that
list is suffix-scanned per provider, and this setting applies to every
provider. It has no identity_openid KVS equivalent and needs a restart,
like the other process-level settings in the same document.

Document the setting in the login flow, the process-level table, and the
troubleshooting table, and clarify that RUSTFS_CONSOLE_AUTH_TIMEOUT is a
request timeout rather than a session lifetime.

* test(ecstore): retain offline marker fixtures across reconnect

Keep simulated drive handles attached and reject I/O until explicit rejoin.
Attempt reconnect in the current-marker case so the former CI race is
deterministic while retaining physical cleanup and historical-data checks.

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

* fix(oidc): align session diagnostics and lifetime regression tests

Use structured IAM diagnostics for clamping and move configuration tests
to their owning crate. Cover duration boundaries, logout retention and
signed credential expiration through the federation binding.

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

---------

Co-authored-by: Chris <anzhengchao@gmail.com>
Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-19 06:52:05 +09:00
Henry Guo 317191ce00 fix(scanner): scale backup deadline to checkpoint save cost (#8659) 2026-10-19 06:21:33 +09:00
Chris f3c4a790dd fix(listing): accept literal prefixes with repeated slashes (#8605)
Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-19 01:27:51 +08:00
Hauser 6681dd12e3 fix(scanner): expose mixed sweep checkpoint transitions (#8657)
* fix(scanner): expose mixed sweep checkpoint transitions

Correlate checkpoint admission, incomplete sweeps, and successful terminal
Partial persistence by bucket/source and cycle/leader epoch. Share the
traversal-position predicate and local/remote save diagnostic without
changing certification, acknowledgement, or persistence contracts.

Extend real-disk cache regressions across cycles and leader changes and
verify that failed terminal saves retain an older periodic frontier until
a successful terminal save permits fresh certification.

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

* fix(scanner): report the aggregate incomplete sweep gate

Expose the existing unresolved_objects decision in the incomplete-sweep
event. Nested cache-entry failures can keep a sweep partial while both
the root counter and the capped diagnostic failure map are empty.

Extend the real-disk regression with diagnostics disabled and distinguish
this case from mixed-only coverage without adding another cache walk.

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-10-18 16:50:42 +08:00
Henry Guo 3b0ee83466 fix(scanner): pace checkpoints by snapshot construction cost (#8656) 2026-10-18 13:36:58 +08:00
Henry Guo 02c4f4c97f fix(ecstore): clean completed PUT staging on pre-commit cancellation (#8654)
fix(ecstore): clean staged PUT data on pre-commit cancellation
2026-10-18 13:36:33 +08:00
Hauser c28e02a722 fix(ecstore): preserve inline heal rollback staging lifetime (#8650)
* fix(ecstore): preserve inline heal rollback staging lifetime

Retain the TMP source prefix in the existing publication lease until the owned receiver finishes rollback. Serialize recursive cleanup and stale GC with the same key, and avoid decoding an unused old version during inline healing.

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

* fix(ecstore): reuse staging lease during parent cleanup

Reuse the existing staging lease when pruning its parent directory, including already-missing prefixes, and retain it in the physical rmdir executor after cancellation. Match the inline-heal fsync test hook to the canonical group path on Linux.

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-10-18 12:58:57 +08:00
Henry Guo 1df96624df feat(table-catalog): support conditional maintenance worker retries (#8562)
* feat(table-catalog): support conditional maintenance worker retries

* fix(table-catalog): refresh conditional worker claim time

* style(table-catalog): format conditional worker expiry test

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-10-18 10:03:58 +08:00
Henry Guo ee534fa19b test(table-catalog): validate current backing contracts (#8559)
* test(table-catalog): qualify current backing contracts

* fix(table-catalog): verify smoke cleanup after object rename

* fix(ci): include the baseline writer test in full E2E selection

* fix(ci): reserve test capacity for disk-backed lock probes

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-10-18 10:03:30 +08:00
Chris 668bc7a4ba test(utils): cover mixed-case metadata alias conflicts (#8651) 2026-10-18 10:02:47 +08:00
Hauser e29a76f391 fix(heal): verify checkpoint integrity before applying defaults (#8649)
* fix(heal): verify checkpoint integrity before applying defaults

Validate the persisted JSON shape so intact signed legacy checkpoints can
reach the existing safe schema rewind. Preserve deployed field ordering and
read historical sorted-key digests without changing the checkpoint schema.
Reuse the signing value when saving and retain strict duplicate-field parsing.

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

* fix(heal): preserve checkpoint writer rollback compatibility

Keep ordered checkpoint parsing local rather than enabling preserve_order
throughout the server. Sort writer keys recursively to retain the production
base reader's digest format while accepting both historical read orders.

Cover real writer canonicalization, nested scalar ordering and depth 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>
2026-10-18 04:03:16 +08:00
Chris 3ddd307304 chore(release): align installation references for 1.0.3 (#8646)
Co-authored-by: Hauser <housemecn@gmail.com>
2026-10-18 02:58:32 +08:00
Ramakrishna Chilaka 9946c67b53 perf(filemeta): avoid clones and redundant map scans in into_fileinfo (#8647)
FileMetaShallowVersion::into_fileinfo and to_fileinfo_with_part_checksums previously decoded an owned FileMetaVersion and then converted it via shared reference (&self), cloning meta_user, meta_sys, part_etags, and part_indices before dropping the temporary FileMetaVersion and scanning the metadata map up to 10 times for optional internal suffixes. Additionally, metadata_compat helpers (get_str, contains_key_str, remove_str, get_consistent_value) allocated two temporary heap Strings via both_keys(suffix) on every absent-key fallback.

- Replace both_keys(suffix) in case-insensitive fallback scans with allocation-free prefix+suffix slice matching.
- Add consuming into_fileinfo_with_part_checksums on FileMetaVersion and MetaObject to move decoded part vectors and metadata maps in place.
- Fold transition-version metadata formatting and optional suffix presence detection into the single meta_sys pass, and skip target_delete_marker_versions in get_internal_replication_state when no delete-marker version keys are present.
2026-10-18 02:57:21 +08:00
Hauser 286e4cf30b perf(ecstore): keep inline heal rollback backups in staging (#8648)
* perf(ecstore): keep inline heal rollback backups in staging

Keep inline repair rollback local to the disk commit and avoid publishing
a live backup that needs a second cleanup operation. Preserve legacy peer
and non-inline backup reclamation, with production-mode rollback coverage.

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

* test(ecstore): route inline heal imports through test facade

Keep ECStore integration-test API access behind storage_api.rs to satisfy
the architecture migration boundary without changing test behavior.

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: Ramakrishna Chilaka <rkchilaka378@gmail.com>
2026-10-18 01:51:52 +08:00
Hauser 453be3dd84 fix(scanner): attribute incomplete census and lease waits (#8643)
* fix(scanner): attribute incomplete census and lease waits

Report bucket sweep blockers and correlate partial remote work and rejected
roots with their set and cycle. Keep distinct accepted bucket names in the
result collector so completion counts and missing-bucket attribution share
one source of truth, including during cancellation drain.

Distinguish worker completion from coverage, retain peer/purpose context in
lease failures without changing retry suffixes, and avoid claiming that an
unverified leader fence changed or a waiting scan was complete.

Document the census stages and preserve publication, dirty acknowledgement,
remote failure classification and checkpoint safety rules.

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

* perf(scanner): move set caches through the result collector

Give the sole result collector exclusive ownership of the aggregate cache
and return it together with the accepted bucket names after channel drain.
Move that cache into complete persistence or incomplete updates instead of
sharing a mutex and cloning the terminal cache.

Return each accepted result's owned name from cache application so coverage
tracking also avoids an extra name allocation. Preserve worker joins,
cancellation drain, publication metadata and all certification fences.

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-10-18 01:05:14 +08:00
唐小鸭 222beaba00 fix(scanner): certify dirty usage per bucket and complete cycles with pending buckets (#8542)
* fix(scanner): treat replication status writebacks as status-class hits

A replication status writeback (persist_replication_state_if_current in
Update mode, including the heals the scanner queues during its own walk)
marked the bucket dirty on the namespace path: it advanced the dirty-usage
generation, woke the scanner, reached the durable dirty journal and
entered the activity snapshot. Any replicated bucket therefore made every
scanner cycle Superseded, the authoritative usage baseline was never
certified and usage freshness stayed stale for as long as replication ran
(#8375).

The writeback is now status class: record_status_class_mutation records
only the process-local producer coverage bit and nothing else. There is
no rescan queue for it, because a deployment with an active replication
rule already walks every bucket on every cycle
(ScannerMaintenanceFeatures::needs_regular_cycle) and that walk refreshes
replication_stats. The routing lives in a named, unit-tested function in
rustfs/src/storage/storage_api.rs; tier-expiration cleanup removes data
and stays on the namespace path.

No persisted format, protocol, admin API or configuration changes.

This is phase 1 of docs/architecture/scanner-per-bucket-convergence.md,
which this change adds together with its index entry.

Refs #8375, #8494, rustfs/backlog#2816.

* fix(scanner): refresh replication statistics during maintenance

Refresh compacted prefixes and completed bucket caches during replication maintenance. Bind set execution reuse to the observed replication statistics so a same-cycle retry cannot restore older counters.

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

* test(scanner): avoid expanding the legacy S3 DTO surface

Decode the existing replication configuration type from a JSON fixture instead of adding direct S3 DTO references to a new file. Preserve the compacted-prefix and same-cycle assertions and the footprint ratchet.

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

* feat(scanner): attribute activity guardrail vetoes per field

The post-walk cluster activity proof compared the whole activity snapshot,
so any namespace or dirty-usage generation movement vetoed publication.
Veto only on the fields that change which heap the walk described
(peer set, instance id, protocol version, topology, maintenance
generation, data movement, movement generation, publication blocked) and
name the field and host in the log and in a new usage-freshness counter.
Generation movement is a shade signal consumed per bucket. A remote peer
whose dirty-usage state moved keeps the whole-cycle outcome until scoped
acknowledgements can name its buckets.

* feat(data-usage): carry pending-bucket certification in DataUsageInfo

Add the additive fields a per-bucket certified scanner snapshot needs:
the sorted list and count of buckets whose dirty-usage generation moved
during the walk, and an explicit observational marker for snapshots whose
authoritative publication was vetoed. All three default when absent and
are skipped when empty, so older readers see the existing snapshot shape.

* feat(scanner): certify dirty usage per bucket and complete cycles with pending buckets

A structurally complete walk that the guardrail did not veto is now
`Complete` even when the dirty-usage generation moved during the walk.
Certification keeps the compare-and-remove semantics of
clear_dirty_usage_buckets: buckets whose generation still equals the start
snapshot are cleared, buckets written during the walk stay in the dirty map
and are published as the pending set, so usage_snapshot_converged becomes
`pending == 0`. The authoritative object is written for such cycles; the
observed route is selected by the explicit observational mark that vetoed
cycles carry instead of by the convergence flag, and the root publication
proof is read for every authoritative save. The post-lease activity recheck
compares the guardrail fields, with the peer dirty-usage fallback, instead
of the full snapshot digest.

* feat(scanner): accept unconverged complete publications as the cache baseline

complete_scanner_cache_baseline_plan_digest required the authoritative
snapshot to be converged, so a site whose cycles always left a bucket
pending could never scope a rescan and every cycle was a full walk. The
authoritative object is now a structural baseline whenever it carries a
convergence marker and no observational mark; bucket-cache reuse is then
governed by certification, because pending buckets stay in the dirty map
and are selected for the rescan while certified buckets reuse their set
cache. Observed candidates keep requiring the unconverged marker, and
segment reuse evidence keeps requiring a converged baseline.

Add the single-disk cycle test: a bucket written during the walk is the
only pending bucket, the others are certified, the publication is
authoritative and unconverged, and the next cycle is scoped to that
bucket; a held PUT tail still supersedes onto the observed route.

* feat(admin): expose scanner guardrail vetoes in usage freshness

Carry the attributed guardrail veto counter, timestamp and <field>@<host>
reason from the scanner metrics report into the madmin scanner status so
an operator can tell a rebalance or a PUT tail from a broken probe. The
fields are additive with serde defaults and merge like the deferral
fields.

* docs(scanner): record per-bucket certification in the design and changelog

Amend the convergence design where the implementation deviates: pending
buckets carry their walked value, maintenance_generation stays a veto,
guardrail localisation and the remaining phase-2 items ship separately,
last_cycle_result keeps its success label, and the admin surface gains the
guardrail veto fields. Update the publication contract for the post-lease
guardrail recheck and the explicit observational routing mark.

* refactor(scanner): drop the unreachable failed-bucket input from the pending set

A failed bucket never reaches a Complete cycle, so the pending set only
needs the start snapshot. Also remove a redundant clone in the data-usage
round-trip test.

* test(scanner): name the activity field mutation table type

* test(scanner): fill the post-walk gate in merged replication fixtures

The replication maintenance fixture merged from main builds a
ScannerCycleRequest literal and predates the test-only post_walk_gate
field; give it the default so the test target compiles on the rebased
branch.

* ci(nextest): run the admin target-repair fixtures on the ecstore base stack

repair_transaction_rejects_a_bucket_recreated_after_target_validation
overflowed the 2 MiB libtest default twice in a row on Linux CI for this
branch. Measured by sweeping RUST_MIN_STACK with the ci profile on macOS
arm64: main (65f30bd5c) passes at 2032 KiB and overflows at 2016 KiB,
this branch passes at 2040 KiB and overflows at 2032 KiB; on both trees
the overflow is in the test's delete_bucket step on the real store, and
the futures on that path are small (the peak is nested poll depth, not
one large future). The module was given this stack in #7272 and removed
in #7280 to check that boxing (#7277) sufficed; with the default now
exhausted on main itself, give the fixtures the same 4 MiB base stack as
the other real-storage rustfs fixtures, in both profiles.

* test(scanner): fill the post-walk gate in the walkers-per-disk fixture

The merge from main brought the walkers_per_disk cycle fixture (#8539),
whose ScannerCycleRequest initializer predates the test-only
post_walk_gate field this branch adds; the scanner test target did not
compile. Fill the default so the merged head builds.

---------

Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-18 00:25:02 +08:00
Hauser aeae6f9980 fix(ecstore): reclaim rollback backups after object heal (#8641)
* fix(ecstore): reclaim rollback backups after object heal

Retire only the backup returned by a successful, fenced heal publication.
Keep child keys, live data directories, and delete recovery evidence safe.

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

* fix(ecstore): retain heal cleanup ownership through cancellation

Forward publication and repair key owners to the existing owned delete
executor so local unlink cannot outlive its exact-key serialization.

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-10-18 00:03:00 +08:00
Hauser f64cb816db fix(scanner): preserve checkpoint progress across object failures (#8642)
Separate completed traversal from persisted object health debt so leader
handoffs reuse healthy subtrees and retry failures without losing progress.
Fence old readers with a new receipt digest domain and preserve exact
accounting under diagnostic eviction, compaction and punctuation ordering.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-17 23:33:47 +08:00
Hauser 816e18f2c8 fix(filemeta): read large metacache records incrementally (#8640)
* fix(filemeta): read large metacache records incrementally

Replace the aggregate 16 MiB guard with fallible chunked growth so
versioned xl.meta records remain listable without trusting length prefixes.
Discard skipped payloads with a bounded buffer and distinguish truncated
records from compatible empty or between-entry EOF.

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

* perf(filemeta): remove redundant metacache reader copies

Track the returned entry with a boolean instead of cloning its metadata,
decode object names directly from the borrowed slice, and derive the read
position from buffer length. Preserve scratch reuse, exact output capacity,
wire framing, and sticky corruption errors.

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-10-17 23:25:21 +08:00
Chris e98992bb87 docs(release): add post-release advisory reconciliation (#8639)
* docs(release): add post-release advisory reconciliation

* docs(release): remove hard wraps from advisory guidance
2026-10-17 22:01:08 +08:00
cxymds ce0c532740 fix(lifecycle): reject expiry from stale policy snapshots (#8637)
* fix(lifecycle): reject expiry from stale policy snapshots

* fix(lifecycle): keep expiry snapshots behind domain boundary

* test(ecstore): fix lifecycle fixtures without test-util
2026-10-17 21:45:30 +08:00
Chris 3defc19d5e fix(ci): refresh E2E selection and isolate disk lock probes (#8638)
* fix(ci): include the baseline writer test in full E2E selection

* fix(ci): reserve capacity for short-deadline disk lock probes
2026-10-17 21:04:04 +08:00
cxymds b1f3ebb184 fix(ecstore): converge partial pool identity writes (#8636)
* fix(ecstore): converge partial pool identity writes

* fix(ecstore): bound pool metadata CAS future state
2026-10-17 20:45:49 +08:00
唐小鸭 b40f1e5fab fix(utils): harden Win32 volume probes and flag Windows network drives (#8631)
* fix(utils): NUL-terminate and extend paths passed to Win32 volume APIs

`get_volume_name` returned the volume root truncated at its NUL and the
result was then passed to `GetVolumeInformationW` as a PCWSTR, an
out-of-bounds read. The disk-space and volume APIs also received the
caller's raw path, so they failed beyond MAX_PATH, and a UNC share root
lacked the trailing backslash these APIs require.

- Wrap every Win32 path in a NUL-terminated `WidePath` that rejects
  interior NULs; volume roots returned by `GetVolumePathNameW` keep
  their terminator.
- Convert inputs to the extended-length `\\?\` / `\\?\UNC\` form after
  `GetFullPathNameW` normalization, and give directory queries a
  trailing backslash.
- Query `GetDiskFreeSpaceW` and `GetVolumeInformationW` on the volume
  root as documented; size buffers to the documented MAX_PATH + 1 or
  the input length.
- Report Win32 error codes instead of raw HRESULTs so `ErrorKind`
  matches `std::fs`.
- Keep volume ids in their conventional `C:\` / `\\server\share\` form.

Add Windows-only `os::` tests for long directories, UNC share roots and
non-empty fstype, and run them in the Windows filesystem workflow.

* fix(utils): report Windows network volumes to the fs guard

Windows names the server's filesystem (usually NTFS) for SMB shares, so
the startup unsupported-filesystem guard, which matches fstype names
such as cifs or smb2, never fired for UNC paths or mapped drives.

- Add `DiskInfo::remote`. On Windows it is set when the directory handle
  answers `FileRemoteProtocolInfo` (only network redirectors do, and the
  handle follows symlinks and junctions to their target) or the volume
  root's `GetDriveTypeW` is `DRIVE_REMOTE`. Unix keeps it false because
  the client fstype already names network mounts.
- Treat remote volumes as unsupported in the startup guard and label
  them "<fstype> on network share".
- Cover UNC shares, paths beyond MAX_PATH below a share, a directory
  symlink to a share and a `net use` mapped drive with Windows-only
  `os::` tests that skip when `\\localhost\<drive>$` is unreachable;
  the Windows filesystem workflow maps the drive before running them.

* chore(deps): replace yanked inout 0.2.3 with 0.2.4

cargo-deny's advisories check rejects the yanked inout 0.2.3 that main's lockfile pins, failing every open PR. Lockfile-only patch bump.

* fix(utils): detect network targets from the final NT path

On the Windows runner, a directory symlink to \\localhost\C$ was reported as local: FileRemoteProtocolInfo did not identify it, and the volume root of the link is a fixed drive.

Also treat a handle whose final NT path (GetFinalPathNameByHandleW with VOLUME_NAME_NT) lies under \Device\Mup\ as remote; the Multiple UNC Provider fronts every UNC and mapped-drive target. The tests now assert the handle probe on its own for local and share paths and report both signals when it disagrees.

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-10-17 19:10:09 +08:00
唐小鸭 276cb24e26 fix(ecstore): share cache and lock identity across Windows key aliases (#8635)
* fix(ecstore): share metadata cache identity across Windows key aliases

Windows disks treat `\` as a path separator and `path_join_buf` drops the
empty segments that leaves, so `a\b`, `a\\b`, `\a\b` and `a\b\` all
address the xl.meta of `a/b`. The GetObject metadata cache keyed and
hashed the raw spelling, so DELETE `a\b` retired nothing cached for
`a/b`: GET/HEAD `a/b` kept returning the deleted object for up to the
2s cache TTL.

Fold the object name into the same segment form the disk resolves to
before computing the cache hash, key and lookup key. Off Windows the
name stays verbatim and borrowed, so `\` remains an ordinary key byte.

* fix(ecstore): serialize Windows backslash key aliases on one namespace lock

Windows disks treat `\` as a path separator and drop the empty segments
it leaves, so `a\b`, `a\\b`, `\a\b` and `a\b\` all address the xl.meta
of `a/b`. Namespace locks keyed the raw spelling, so writes, deletes and
heals through different spellings ran concurrently on one xl.meta and a
read-modify-write in rename_data could drop a version.

Fold the object name into its disk identity (rustfs_utils::path::
object_name_disk_identity, a no-op off Windows) wherever a namespace
lock key is formed:
- SetDisks::new_ns_lock, which every object lock path goes through;
- the batch delete lock request and its dedup/lookups, which build
  ObjectKeys directly.

Two consumers compared lock identities by spelling and follow along:
- WriteCommitGuard records the folded key from the lock resource, so
  matching a borrowed guard now folds the target name too;
- CopyObject holds the source read guard until EOF on streaming reads.
  `a\b\` -> `a/b` is not a same-object copy by path_join_buf, but shares
  one lock, so it now takes the self-copy lock instead of waiting on
  itself until the lock timeout.

Distributed lock keys are folded by the requesting node only; lock
servers compare them verbatim.

* refactor(ecstore): key the metadata cache by the shared disk identity

The metadata cache fix carried its own copy of the Windows backslash
folding rule. Use rustfs_utils::path::object_name_disk_identity, which
namespace locks already use, so cache and lock identities cannot drift.

* ci(windows): run filesystem tests when key identity rules change

The Windows backslash folding for object names lives in utils path.rs
and is consumed by the namespace lock and write commit guard; run the
windows_ tests when any of them changes.
2026-10-17 18:06:29 +08:00
cxymds 508caab281 fix(lifecycle): skip empty delete replication tasks (#8628)
* fix(lifecycle): skip empty delete replication tasks

* test(lifecycle): route replication hooks through the sink

* test(e2e): make upgrade write readiness checks deterministic

* fix(deps): replace yanked inout 0.2.3 with 0.2.4
2026-10-17 17:15:37 +08:00
唐小鸭 776cdc517b fix(ecstore): keep Windows disk roots out of verbatim form (#8633)
* fix(ecstore): keep Windows disk roots out of verbatim form

The disk root comes from GetFinalPathNameByHandleW, which returns a
verbatim path. `simplified` only strips `\\?\C:` below MAX_PATH, so a
data directory past 260 characters (or one reached through a UNC share)
kept the prefix. Disk IO extends the root by joining `/`-separated
relative paths as strings (`read_raw` builds `<object>/xl.meta` with
`path_join`), and inside a verbatim path `/` is not a separator: the
first metadata read, pool.bin.identity, failed with ERROR_INVALID_NAME
until startup gave up.

Add `rustfs_utils::non_verbatim`, which strips the disk and UNC verbatim
prefixes without a length limit while still keeping paths whose
components Win32 would rewrite, and use it for the publication root.
std re-adds the extended-length prefix for long paths.

Cover a root past MAX_PATH with a disk-level read/write test and run all
`dunce::` tests in the Windows filesystem workflow.

* fix(ecstore): query Win32 volume APIs with extended long disk roots

A disk root past the legacy limit used to reach `rustfs_utils::os` in
verbatim form, which the Win32 volume APIs accept. Now that the root
stays non-verbatim, GetDiskFreeSpaceExW rejected it with
ERROR_PATH_NOT_FOUND and LocalDisk::new failed in the Windows filesystem
workflow. Pass long roots to `get_info` and `get_physical_device_ids` in
extended-length form; shorter roots stay unchanged so reported volume
identities keep their `C:\` form.
2026-10-17 17:04:21 +08:00
cxymds 8dd399cd58 fix(lifecycle): compact inline versions once per deletion batch (#8634) 2026-10-17 16:16:30 +08:00
唐小鸭 353e67bbf3 fix(ecstore): resolve Windows drive paths in URL endpoints (#8629)
On Windows, URL endpoints such as http://host:9000/C:/data{1...4} (the only
form multi-node and pool-expansion layouts accept) produced the disk path
`/C:/data1`: `try_from` stripped the synthetic slash, but `Url::set_path`
put it back, and `get_file_path` only removed it for `file` endpoints.
Windows resolves `/C:/data1` as an invalid `C:` component under the current
drive, so disk validation and `resolve_local_disk_root` failed.

- `get_file_path` now restores `/X:/...` to `X:/...` for every scheme on
  Windows; URL paths without a drive (`/export1`, `/C:foo`) are unchanged.
- UNC shares (`\\nas\share\d1`, `//nas/share/d1`, `\\?\UNC\...`) are rejected
  with an explicit error instead of becoming a file URL whose host is the
  share server and whose path drops the share name.
- A drive root (`http://host:9000/D:/`) is accepted in URL form, matching
  the path-style `D:\` endpoint that was already accepted.
- The "distinct physical disks" error explains on Windows that directories
  on one volume share a device identity, alongside the existing
  RUSTFS_UNSAFE_BYPASS_DISK_CHECK hint.
- The Windows filesystem lane now runs on layout changes and runs the
  layout::endpoint:: tests; new Windows tests carry the windows_ prefix.
- Document Windows endpoint forms, the same-volume check, and the SNSD
  expansion limit in cluster-lifecycle-operations.md.
2026-10-17 16:06:37 +08:00
唐小鸭 78ea655d87 fix(startup): drive startup on an explicitly sized thread (#8630)
`run_process` called `block_on(async_main())` on the native main thread,
so the whole startup chain ran on whatever stack the platform hands the
main thread: 8 MiB from `ulimit -s` on Linux and macOS, but the 1 MiB PE
default on Windows. A Windows debug build overflowed before it listened.
`RUST_MIN_STACK` does not reach the native main thread.

Run the runtime on a `rustfs-main` thread with an explicit 8 MiB stack,
keeping the budget Linux and macOS already had and making it independent
of the linker, toolchain (msvc/gnu) and `ulimit`. Panics are re-raised
on the main thread so the exit status is unchanged.

Measured on macOS arm64 by lowering the main-thread stack with
`ulimit -s` before this change: debug startup overflows at 1792 KiB and
reaches /health/ready at 2048 KiB; release overflows at 512 KiB and is
ready at 768 KiB. After it, debug starts with a 256 KiB main thread.

Add a Windows `Startup Smoke` job that builds the debug server, runs
`--version`, and waits for /health/ready on a single volume.
2026-10-17 16:06:19 +08:00
唐小鸭 5efae5baa6 fix(ecstore): unify object key admission for Windows hosts (#8632)
* fix(ecstore): admit CopyObject destinations like PutObject

Object-key platform checks were split across call sites with gaps:
CopyObject only ran check_bucket_and_object_names on its destination and
then reached the pools' put_object directly, so it admitted keys PutObject
rejects (over 1024 bytes, leading slash, and on Windows reserved characters
or unaddressable segments, the "writes but cannot be read" case of #3449).

- rustfs-utils: expose check_windows_file_name as the single per-segment
  Win32 rule set; dunce's simplified() reuses it. Reserved device names now
  include COM/LPT with superscript digits and CONIN$/CONOUT$.
- ecstore: replace the duplicated reserved-name table with that function;
  Windows also rejects control characters 0x01-0x1F (NUL keeps its
  platform-independent ObjectNameInvalid answer).
- ecstore: add check_new_object_args as the shared admission entry for
  PutObject, cross-key CopyObject destinations and CreateMultipartUpload.
  In-place copies (metadata replace, restore) keep the copy-only check so
  existing keys stay rewritable. CreateMultipartUpload now also rejects
  over-NAME_MAX segments up front instead of failing at completion.
- CI: run bucket::utils and dunce tests in the Windows filesystem job.

* fix(ecstore): reject new backslash keys on Windows hosts

A Windows disk treats '\' as a path separator, so a new key `a\b` lands on
the same xl.meta as `a/b` and the two keys overwrite each other.

Reject '\' only where a key enters the namespace (check_new_object_args:
PutObject, cross-key CopyObject destinations, CreateMultipartUpload) and
only on Windows. Reads, deletes, in-flight multipart operations, in-place
copies, data movement and internal meta buckets keep accepting it, so keys
already stored with '\' stay readable, deletable and movable.

Pending owner ruling: whether new '\' keys should be rejected at all, and
whether reads/deletes should keep accepting them.

* ci(windows): quote filtered test runs and drop duplicate path
2026-10-17 15:53:16 +08:00
Henry Guo 8110ab2893 fix(admin): preserve dirty usage over observed snapshots (#8627)
fix(data-usage): retain dirty counts over observations

Preserve unreconciled local mutations when an observed snapshot completes later. Add focused selection-to-overlay regressions for writes, deletes and authority boundaries.

Validation checkpoint: the pre-fix regression failed as expected. Post-fix tests, formatting and runtime verification were not run before the user stopped the task. This local commit is not ready for publication.
2026-10-17 15:10:11 +08:00
Ramakrishna Chilaka 59ea8bf8ce fix(s3): return 412 for conditional multipart uploads on existing keys (#8625) 2026-10-17 14:09:04 +08:00
Henry Guo 749a1686aa feat(table-catalog): plan maintenance on durable backing (#8567)
* feat(table-catalog): plan maintenance on durable backing

* test(table-catalog): bootstrap strong planning handler fixture
2026-10-17 12:06:02 +08:00
Chris 6db3158740 fix: resolve clippy warnings from Rust 1.99 lints (#8619)
- Combine identical if-block arms in runtime_sources.rs (clippy::if_same_then_else)
- Allow redundant_locals for cfg-gated closure captures in metadata_read.rs and read.rs
- Remove needless borrow in upgrade_compatibility_test.rs (clippy::needless_borrows_for_generic_args)
2026-10-17 11:46:33 +08:00
Henry Guo c07a5d292c fix(connect): retain bounded object cleanup diagnostics (#8624) 2026-10-17 10:57:39 +08:00
Chris 00d1f6ead2 chore(release): align installation references for 1.0.2 (#8617) 2026-10-16 23:19:34 +08:00
Henry Guo adfe60ec7d fix(connect): prepare profile fixtures before timed IPC (#8616) 2026-10-16 23:16:19 +08:00
Henry Guo a5c6f6195e fix(scanner): retain partial progress without a usage update (#8612) 2026-10-16 21:05:33 +08:00
Ramakrishna Chilaka ec0208480e fix(ecstore): publish delete rollback files via verbatim-safe rename (#8607) 2026-10-16 20:04:44 +08:00
唐小鸭 62b6b6149c fix(ecstore): publish delete rollback backups past Windows MAX_PATH (#8606)
tempfile's Windows `persist` passes the raw paths to MoveFileExW and
SetFileAttributesW, bypassing std's extended-length handling. Once
`<object>/<rollback>/xl.meta.bkp` reached 260 UTF-16 units every delete
of that key failed with 500, while PUT and GET kept working.

Add `rustfs_utils::extended_length_path`, which normalizes a path with
GetFullPathNameW and prefixes it with `\\?\` (or `\\?\UNC\`), and use it
for the staging directory and destination handed to tempfile. Cover the
single and batch delete paths with a deep-key regression test and run the
helper's Windows assertions in the Windows filesystem workflow.

Fixes #8545
2026-10-16 16:16:32 +08:00
cxymds 7951bb6298 fix(heal): retain exhausted admin batch repair receipts 2026-10-16 15:33:15 +08:00
唐小鸭 741de6f81c fix(replication): locate encrypted replicas without comparing ETags (#8602)
Since 1.0.2 an encrypted object's ETag is a keyed fingerprint, not the
plaintext MD5. A target that computes its own ETag (AWS S3, Wasabi) never
reports it, so every "find the existing replica by exact ETag" step missed
encrypted objects on targets that mint their own version ids:

- a heal / MRF re-drive after a lost PUT response wrote a second version;
- an existing-object resync re-PUT a replica its ledger already named
  (the ledger-located HEAD was confirmed by ETag only), and on the AWS
  400 shape the ledger was not consulted at all;
- resync verification counted such replicas as failed;
- a ledger-less version purge reported the replica as already gone.

Encrypted sources now recognise their replica by shape: the byte count the
replication PUT sent (plaintext for managed SSE, ciphertext for SSE-C) and
a target write time no earlier than the source version. An ETag match
still wins (RustFS/MinIO targets). The ledger is consulted first on both
the 404 and the AWS 400 shape. Without a ledger, only a unique shape match
among the unclaimed ListObjectVersions candidates is adopted and recorded;
several shape matches mean re-PUT for convergence and a refused purge. The
AWS current-version fallback stays ETag-only, and a listing the
target refuses on the 400/403 shape keeps that fallback (re-PUT) rather
than stalling. An unversioned target's `null` version is never matched by
shape: it holds whichever generation was written last. Plaintext sources keep exact
ETag identity.

Builds on the ETag-computing fake target mode and the ComputesOwnEtags
matrix row from rustfs#8583. Adds an SSE-S3 variant of the mint-own-ids
re-drive test (red before this change: the heal left two target versions),
and both variants now also assert the purge.
2026-10-16 13:44:33 +08:00
Chris f5ff8783f4 fix(ecstore): box large enum variant in CurrentDeleteGeneration (#8576) 2026-10-16 13:11:49 +08:00
cxymds 12ae143bee fix(quota): keep writes available with a degraded fleet (#8604) 2026-10-16 13:05:20 +08:00
cxymds 628663f468 fix(heal): recover replacements after pool removal (#8600)
* fix(heal): recover replacements after pool removal

* test(e2e): gate upgrade probes on writable baseline nodes

* test(e2e): register baseline readiness regression in smoke digest
2026-10-16 12:08:29 +08:00
唐小鸭 d457378c02 feat(scanner): decouple bucket walkers from disk count (#8539)
* fix(scanner): treat replication status writebacks as status-class hits

A replication status writeback (persist_replication_state_if_current in
Update mode, including the heals the scanner queues during its own walk)
marked the bucket dirty on the namespace path: it advanced the dirty-usage
generation, woke the scanner, reached the durable dirty journal and
entered the activity snapshot. Any replicated bucket therefore made every
scanner cycle Superseded, the authoritative usage baseline was never
certified and usage freshness stayed stale for as long as replication ran
(#8375).

The writeback is now status class: record_status_class_mutation records
only the process-local producer coverage bit and nothing else. There is
no rescan queue for it, because a deployment with an active replication
rule already walks every bucket on every cycle
(ScannerMaintenanceFeatures::needs_regular_cycle) and that walk refreshes
replication_stats. The routing lives in a named, unit-tested function in
rustfs/src/storage/storage_api.rs; tier-expiration cleanup removes data
and stays on the namespace path.

No persisted format, protocol, admin API or configuration changes.

This is phase 1 of docs/architecture/scanner-per-bucket-convergence.md,
which this change adds together with its index entry.

Refs #8375, #8494, rustfs/backlog#2816.

* fix(scanner): refresh replication statistics during maintenance

Refresh compacted prefixes and completed bucket caches during replication maintenance. Bind set execution reuse to the observed replication statistics so a same-cycle retry cannot restore older counters.

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

* test(scanner): avoid expanding the legacy S3 DTO surface

Decode the existing replication configuration type from a JSON fixture instead of adding direct S3 DTO references to a new file. Preserve the compacted-prefix and same-cycle assertions and the footprint ratchet.

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

* perf(scanner): keep raw enumeration bookkeeping linear per entry

The page writer inserted into its building page with a push-and-sort on
every entry and committed each page through a clone. Insert in sorted
position and move the committed page into the index instead.

Add a regression test that records 20,000 entries in one prefix and pins
the cost to one hash per entry and well under a second, so the quadratic
re-normalization removed by #8114 (rustfs#8113) cannot return once pacing
builds on this walker.

* feat(scanner): decouple bucket walkers from disk count

Add the scanner key walkers_per_disk (RUSTFS_SCANNER_WALKERS_PER_DISK,
1 through 16, default 1). Each local coordinator disk of a set spawns that
many bucket walkers; remote disks keep one. The walker count is what
max_concurrent_disk_scans caps and the disk count stays the floor.

The walkers of one disk share its scanner admission through
LocalDiskAdmissionShare: the first walker to start a bucket takes the
per-disk permit and the last to finish releases it, so a peer's remote
request still sees one scan session per physical disk. Without the share
the second walker was rejected as busy and retired.

The disk_concurrency_budget log line reports walkers_per_disk, walkers and
the reason for the effective limit. The default stays 1 because no local
disk reports rotational media today; the design keeps a higher flash
default as the intent.

* docs(scanner): document walkers_per_disk in the Helm chart and configuration manuals

RUSTFS_SCANNER_WALKERS_PER_DISK was only described in the scanner runtime
controls table. Add it to the Helm chart (values, configmap rendering,
README table, template test), the configuration crate's environment
reference with operator guidance, and a short section in the operations
manual on when to raise it and how to confirm it took effect.

---------

Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-16 11:15:45 +08:00
cxymds e76cb67e93 fix(lifecycle): read exact object versions after writes (#8601)
* fix(lifecycle): read exact object versions after writes

* test(lifecycle): respect ECStore dependency boundaries
2026-10-16 11:09:55 +08:00
Hauser 8c8c74cbc3 fix(ecstore): unify bounded delete recovery across upgrades (#8594)
* fix(ecstore): complete delete recovery across upgrades and restarts

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

* test(ecstore): expect typed quorum error after delete rollback

Assert the exact InsufficientWriteQuorum bucket and object instead of the removed Io wrapper, retaining the rollback and cleanup ownership checks.

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-10-16 11:09:50 +08:00
Hauser 9d85d21ee6 chore(deps): refresh workspace dependencies (#8597)
* chore(deps): refresh workspace dependencies

Run cargo update and cargo upgrade twice from origin/main.

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

* fix(deps): pin Pulsar 6.9.0 until recovery is bounded

Keep Pulsar on 6.9.0 after reproducing background recovery and lookup activity after delivery timeout and target destruction in 6.9.1. Use an exact requirement to prevent a later cargo update from selecting the affected release.

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-10-16 09:54:58 +08:00
Hauser 4e9b3818eb test(s3-select): fix schema reference field ordering (#8599)
Enable preserve_order for the Arrow JSON schema reference in tests and verify first-seen field ordering across nested records.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-16 09:12:37 +08:00
overtrue f0b584d61a fix(ci): stop PRs for sync conflicts 2026-10-16 08:06:27 +08:00
Chris d0fec44808 test(e2e): send the quota batch delete in verbose mode (#8589)
Since #8535 DeleteObjects honours Quiet=true the way S3 does and omits the
successful entries from the response, so the quota batch-delete test has
been asserting two Deleted entries against an empty list on every main run.
The test only needs the batch to succeed and free quota; make it verbose so
the existing assertion holds and check that no entry failed.
2026-10-16 06:07:29 +08:00
唐小鸭 b4f58b81cf test(e2e): gate the rc.5 baseline restarts on drive re-admission (#8595)
* test(e2e): gate the rc.5 baseline restarts on drive re-admission

The mixed-version rolling upgrade failed three times on unrelated branches
with `MultipartAcknowledgementDeadline { node: 2, key: "one-current-node/..." }`.
The baseline kills the next rc.5 node about 10 ms after the previous one
reports ListBuckets, while that node is still replaying its startup traffic.
Two failed operations drop the peer's drive to Offline on the pinned release,
and only the 2 s recovery probe brings it back three successes later. In both
failing runs node 2 held node 3's drive as `faulty` when the multipart load
started three seconds later; it then committed every upload at three of four
drives with a heal per object, and on a two-vCPU runner one upload exceeded
the 30 s acknowledgement budget. The green run on the same branch only
reached `suspect`. An all-rc.5 cluster reproduces the same degradation after
the same restart sequence and an all-current cluster shows none of it.

Write a slash-free probe object before the first restart and, after each
baseline restart, block until every node reports all four drives of that
object as `ok` through a dry-run object heal. `storageinfo` and `info` only
carry each node's local drives; the object heal runs on the receiving node and
classifies a short-circuited remote drive as `faulty`. The gate stays in the
all-rc.5 baseline: admin heal first probes every peer's heal-control
capability, and a mixed-version cluster fails that probe on both binaries.

Record every node's lock table in `mixed-multipart-failure.json`; the stalled
writer is a previous-release node whose table the current node cannot show.
The 30 s budget, layout, load and assertions are unchanged.

* test(e2e): probe drive re-admission with a parity-0 write per node

The dry-run heal gate observed only node 0: the pinned rc.5 admin heal
forwards start and status requests to the heal-control coordinator whatever
node receives them, so the loop checked node 0's drive handles four times
and never the restarted peers' handles.

Run the cluster with `RUSTFS_STORAGE_CLASS_RRS=EC:0` and probe each node
with a reduced-redundancy PUT instead. A parity-0 write on the four-drive
set needs all four drives (`write_quorum = data_drives`), so the PUT fails
with 503 `ServiceUnavailable` until the serving node writes through every
one of its own drive handles. The probe is plain S3 and executes on the
receiving node on both binaries, so the gate now also runs before each
mixed-version load and after the last upgrade. Standard-class objects keep
the default layout.

`parity_zero_write_needs_every_drive` pins the premise on the workspace
build: with one node down a standard write still commits, the
reduced-redundancy write fails with 503, and the gate converges once the
node returns. The diagnostic snapshot test now checks the per-node lock
probe array.
2026-10-16 05:03:31 +08:00
cxymds 7682a12b58 fix(rebalance): wait for readiness and admit peer workers (#8570)
* fix(rebalance): avoid contention when peers join the same run

* fix(rebalance): wait for fleet proof during initialization

* test(rebalance): check contextualized competing run errors

* fix(rebalance): retain typed worker admission lock errors

* test(rebalance): retry the init readiness 503 and pin the retry error shape (#8586)

The e2e harness only retried the pre-fix 500 carrying the fleet-proof
marker; the init stage now answers 503 + Retry-After after its own
readiness budget, so start_rebalance treats that as the same wait. The
missing-proof regression also asserts the typed error shape that the admin
handler's retry predicate depends on, not only the rendered message.

---------

Co-authored-by: Chris <anzhengchao@gmail.com>
2026-10-15 17:12:26 +00:00
cxymds 65f30bd5c1 fix(multipart): retain commit guards across request cancellation (#8571)
* fix(multipart): retain commit guards across request cancellation

* fix(multipart): preserve commit task error context

* fix(multipart): reclaim verified orphan upload staging (#8572)

* fix(multipart): reclaim verified orphan upload staging

* fix(multipart): share orphan guards across matching lock domains

* fix(multipart): preserve orphan cleanup task error context

* fix(ecstore): box guarded delete transition identity

* fix(multipart): keep NoSuchUpload when orphan recovery cannot prove absence (#8587)

Recovery is a side path of an idempotent Abort. An offline disk, lock
contention, partially overlapping lock domains or unreadable bucket
metadata all stop the staging-absence proof before any deletion; those
used to propagate as 503/500, where the base answered NoSuchUpload. They
now keep that answer and delete nothing; only failures inside the
deletion task remain request failures, because a retry can finish them.

---------

Co-authored-by: Chris <anzhengchao@gmail.com>
2026-10-15 17:05:48 +00:00
Hauser 7462c25319 feat(diagnostics): port read attribution to main (#8579)
* feat(diagnostics): attribute storage reads to request origins

Port the diagnostics instrumentation from its experiment base onto main
without carrying the base branch's unrelated changes.

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

* refactor(diagnostics): consolidate read context scopes

Keep caller and entry provenance in one task-local context, combine the object-read scope, and classify source paths without allocating.

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-10-16 01:02:03 +08:00
Hauser 1c663b8207 fix(rpc): clarify stale epoch rejection diagnostics (#8585)
Read the connection peer address using the stored extension type and distinguish retryable stale epochs from terminal signature errors. Cover diagnostics and authenticated ReadAll recovery without changing replay protection.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-16 01:01:45 +08:00
cxymds cb2dfec17e fix(ecstore): merge migrated copies across inline layouts (#8588) 2026-10-16 01:01:10 +08:00
Hauser c1f906d128 fix(ecstore): quiet routine admin info timing logs (#8584)
Replace unconditional stage warnings with one trace-gated, monotonic
completion summary. Preserve topology and RPC failure diagnostics.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-16 00:26:59 +08:00
唐小鸭 b08331335a test(replication): cover SSE-S3 replicas on ETag-computing targets (#8583)
The outbound target matrix only modeled targets that adopt the forwarded
source ETag, so the post-PUT replica ETag audit was never exercised against
a target that computes its own ETag (AWS S3 and most compatible stores),
and no matrix shape was encrypted. That gap let the SSE-S3 "replica etag
mismatch" regression fixed in #8573 ship unnoticed.

Add an ignore_source_etags mode to the fake S3 target, a ComputesOwnEtags
matrix row that uses it, and an SseS3 object shape (the source runs with the
local KMS). The shape asserts its source ETag is not the plaintext MD5 so
the cell cannot pass vacuously. Against a build without #8573 the new row
fails exactly computes-own-etags/SseS3 with "replica etag mismatch"; with it
the whole matrix passes.

Also correct the #8573 docs and comment that called the replication PUT
checksum the integrity check for encrypted sources: managed-SSE sources
forward no checksum, so their replicas have no post-PUT content audit.
2026-10-16 00:07:54 +08:00
Hauser aae5cc4919 fix(scanner): persist partial progress after budget cancellation (#8578)
* fix(scanner): persist partial progress after budget cancellation

Separate progress-only cycles from observational and authoritative usage
updates. Keep peer movement leases alive under the parent cycle context
for a bounded drain after the scan budget stops the walk, while preserving
the existing strict publication fences for emitted usage updates.

Skip root rename fences and the usage persister when no update was emitted.
Cover real lease admission, cancellation, bounded retries, and durable
partial-cycle advancement without usage publication or dirty acknowledgement.

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

* refactor(scanner): consolidate publication policy and ownership

Classify emitted updates directly in the publication plan constructor and
exercise that single policy from the namespace-admission tests.

Share the parent cancellation state for read-only progress proof checks
and move the one-use root fence into the persister, avoiding a child token
allocation and a JSON string copy without changing lease or commit rules.

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-10-15 23:27:26 +08:00
Chris 69e987b4de fix(ecstore): box the transition payload of CurrentDeleteGeneration (#8581)
`cargo clippy --all-targets -- -D warnings` fails on main since #8535:
`CurrentDeleteGeneration::Present` carries a `CurrentDeleteTransition`
(three Strings, an Option<String>, a 32-byte identity and two small
fields, ~211 bytes) while `Absent` carries nothing, which trips
clippy::large_enum_variant. The workflow only runs clippy on pushes to
main (`if: github.event_name != 'pull_request'`), so the PR CI stayed
green and main's CI has been red since 606d8a3a.

Store the transition behind a Box so the enum is pointer-sized plus the
small fields. The value is transient ("never persisted in object
metadata") and its serde form is unchanged, because serde treats Box<T>
transparently; PartialEq/Clone/Debug derive through it. The two tests
that matched the struct through the Option now bind the Box and check
the same fields in a guard.


Claude-Session: https://claude.ai/code/session_01CezCdjE9AdShFUi9tNk797

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 22:48:01 +08:00
Chris 7a32ca2adb fix(replication): skip the replica ETag audit for encrypted sources (#8573)
New encrypted writes store a keyed HMAC fingerprint as the object ETag
(docs/operations/encrypted-etags.md). Replication forwards that ETag on
x-rustfs-source-etag, but a target that computes its own ETag (AWS S3 and
most compatible stores) answers the plaintext PUT with the MD5 of what it
received. Both values are 32 hex characters, so verify_single_part_replica
treated the fingerprint as a comparable MD5 and failed every single-part
SSE-S3 / SSE-KMS replica with "replica etag mismatch"; MRF and resync then
retried the object forever. RustFS and MinIO targets adopt the source ETag
and were unaffected.

Exempt sources whose stored ETag cannot describe the wire bytes: any
managed-SSE or SSE-C source, decided by the existing replication
source-encryption classifier. The checksum header on the replication PUT
remains the integrity check for those objects; plaintext sources keep the
audit unchanged.


Claude-Session: https://claude.ai/code/session_01CezCdjE9AdShFUi9tNk797

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 22:01:08 +08:00
Chris d7156290d5 fix(s3): make an unsupported delete participant retryable and explain it (#8574)
A user-bucket delete fans out through the delete-transaction RPC. When a
participant answers Unimplemented (a peer still running a release without
that RPC, or a protocol-version mismatch) the coordinator reports
StorageError::UnsupportedDisk, which had no S3 mapping: clients received a
500 InternalError whose message was the variant's Display text,
"drive does not support O_DIRECT". That text describes the startup drive
probe and sends an operator mid-upgrade after the wrong cause.

Map UnsupportedDisk to 503 ServiceUnavailable with a constant message that
names the protocol-version cause and the remedy (finish upgrading every
node, then retry). The typed source is retained for diagnostics and no
Retry-After is added, since retrying the same node does not help until the
cluster upgrade completes. No request path other than delete recovery
produces this variant; the startup drive probe still fails startup as
before.


Claude-Session: https://claude.ai/code/session_01CezCdjE9AdShFUi9tNk797

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 21:48:46 +08:00
cxymds a3230599a8 fix(ci): allow recovery of completed cancelled validation runs 2026-10-15 19:19:31 +08:00
Henry Guo 6ec1942ba3 fix(data-usage): retain complete totals when a set is missing (#8558) 2026-10-15 19:09:06 +08:00
Ramakrishna Chilaka 606d8a3a8a fix(scanner): clear transient cycle recovery on follower lock contention (#8566) 2026-10-15 18:36:48 +08:00
Hauser 2c2c1133d4 fix(scanner): use observational leases for incomplete snapshots (#8561)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-15 15:49:38 +08:00
Hauser 8a96fedfdd fix(ecstore): retain pool metadata repair ownership (#8552)
* fix(ecstore): retain system metadata repair ownership

Return system metadata convergence failures to the fenced writer after
committed cleanup instead of submitting an unexecutable MRF handoff.
Drain system rename tails before acknowledging and preserve ordinary
incarnation-bound object repair.

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

* refactor(ecstore): separate PUT commit and completion outcomes

Keep committed metadata writes on their retained cleanup owner when the
writer must handle repair. Propagate the completion error at the API
boundary without a duplicate temporary-data deletion or worker wait.

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

* fix(ecstore): scope repair completion to pool metadata owners

Keep ordinary system metadata quorum-compatible and explicitly select
owner-observed repair completion for fenced pool metadata CAS writes.
Seed format-heal metadata before injecting the missing-format fault.
Preserve degraded bucket recreation and test all completion modes.

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-10-14 21:02:41 -07:00
cxymds ce00d8f7e5 feat(s3): support conditional bulk deletes (#8535)
* feat(s3): support conditional bulk deletes

* fix(s3): report null marker identity in bulk delete responses

* fix(s3): isolate conditional bulk delete actions and tier guards

* fix(tier): preserve backend identity validation diagnostics

---------

Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-10-14 20:20:14 -07:00
Chris cbd53974f9 test(e2e): wait for heal control before deep heal after restart (#8554) 2026-10-15 07:07:12 +08:00
alpbeysir 65ea7db6a9 feat(iam): support wildcard in IAM policy resources (#8555)
Allow wildcard in IAM policy
2026-10-14 15:58:27 -07:00
Hauser bdc00c8d0d fix(s3): align raw disk-full failures with capacity refusals (#8553)
Map DiskFull through the existing StorageFull S3 response contract instead
of falling back to InternalError. Preserve the typed cause and keep lock
retry advice separate from capacity failures.

Reuse the PUT storage-failure fixture to cover capacity refusals and the
existing read-quorum stages without adding a runtime normalization layer.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-14 10:16:39 -07:00
Hauser 84f7ab1f5c chore(deps): refresh Rustls and TAR parser test crates (#8547)
* chore(deps): update Rustls patch release

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

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

* chore(deps): update TAR parser test crates

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

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

* fix(heal): avoid collecting requests in test

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

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

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: hector <42570491+majinghe@users.noreply.github.com>
2026-10-14 23:23:57 +08:00
Hauser 8b75145a29 fix(s3): preserve catalog lock failures at the error boundary (#8550)
Retain typed retryable catalog lock failures and route S3 publication
errors through the canonical storage conversion for bounded metrics,
public-message redaction, and existing retry advice.

Borrow CAS results for metrics instead of cloning opaque error sources.
Cover authorization under a real publication writer, HTTP serialization,
source preservation, recovery, and established catalog error categories.

Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-10-14 23:16:40 +08:00
唐小鸭 c9f88cff66 fix(scanner): treat replication status writebacks as status-class hints (#8509)
* fix(scanner): treat replication status writebacks as status-class hits

A replication status writeback (persist_replication_state_if_current in
Update mode, including the heals the scanner queues during its own walk)
marked the bucket dirty on the namespace path: it advanced the dirty-usage
generation, woke the scanner, reached the durable dirty journal and
entered the activity snapshot. Any replicated bucket therefore made every
scanner cycle Superseded, the authoritative usage baseline was never
certified and usage freshness stayed stale for as long as replication ran
(#8375).

The writeback is now status class: record_status_class_mutation records
only the process-local producer coverage bit and nothing else. There is
no rescan queue for it, because a deployment with an active replication
rule already walks every bucket on every cycle
(ScannerMaintenanceFeatures::needs_regular_cycle) and that walk refreshes
replication_stats. The routing lives in a named, unit-tested function in
rustfs/src/storage/storage_api.rs; tier-expiration cleanup removes data
and stays on the namespace path.

No persisted format, protocol, admin API or configuration changes.

This is phase 1 of docs/architecture/scanner-per-bucket-convergence.md,
which this change adds together with its index entry.

Refs #8375, #8494, rustfs/backlog#2816.

* fix(scanner): refresh replication statistics during maintenance

Refresh compacted prefixes and completed bucket caches during replication maintenance. Bind set execution reuse to the observed replication statistics so a same-cycle retry cannot restore older counters.

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

* test(scanner): avoid expanding the legacy S3 DTO surface

Decode the existing replication configuration type from a JSON fixture instead of adding direct S3 DTO references to a new file. Preserve the compacted-prefix and same-cycle assertions and the footprint ratchet.

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

* test(ecstore): seed the ordinary-timeout peer fixture under the default drive deadline

The one-second ENV_DRIVE_MAX_TIMEOUT_DURATION in
target_ordinary_timeout_keeps_its_physical_namespace_owner wrapped the
whole test, so the fixture's write_metadata and the final read_version
were bounded by it too. On a loaded CI runner the seed took longer than a
second and the test failed with "seed real metadata: Timeout" before the
paused rename under test ran. Only the rename now runs under the
one-second deadline; the fixture and the tail read use the default.

---------

Co-authored-by: Hauser <housemecn@gmail.com>
Co-authored-by: heihutu <heihutu@gmail.com>
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
Co-authored-by: overtrue <anzhengchao@gmail.com>
2026-10-14 23:10:57 +08:00
cxymds 1c7fc818dd fix(ecstore): retain below-quorum data during no-remove heal 2026-10-13 02:02:13 +08:00
cxymds 5475bd06bc chore(ecstore): shrink typed-error ratchet baseline 2026-10-13 01:07:12 +08:00
cxymds a3ba311ab0 fix(ecstore): retain typed quorum errors for missing parts 2026-10-13 00:59:50 +08:00
cxymds e517d35fa2 test(ci): archive scoped F07 directory fsync evidence 2026-10-12 23:47:15 +08:00
cxymds 1b7a197267 fix(ci): isolate validation package publication credentials 2026-10-12 23:30:45 +08:00
Chris 3b416b76cd Merge branch 'main' into cxymds/validation-runner 2026-10-12 06:18:15 -07:00
cxymds bebee3d5d0 ci(validation): verify C11 native storage evidence 2026-10-12 19:07:12 +08:00
cxymds 1aa9be87d1 test(ecstore): verify incarnation fence at repair commit 2026-10-12 18:38:26 +08:00
cxymds e224eb48e5 fix(heal): deeply verify read repair payload failures 2026-10-12 17:40:36 +08:00
cxymds 16cf19817e ci(validation): dispatch isolated packages through the driver 2026-10-12 12:29:11 +08:00
cxymds 32fd1e704a test(validation): verify isolated package publication failures 2026-10-12 12:22:53 +08:00
cxymds ac197ea655 test(validation): enforce private candidate manifest schema 2026-10-12 12:18:15 +08:00
cxymds e18ed73942 ci(validation): publish isolated immutable candidate packages 2026-10-12 12:06:59 +08:00
cxymds 3c131f2b2e fix(heal): preserve typed errors and SSE-C response headers 2026-10-12 11:36:29 +08:00
cxymds c5f4b5a4f7 fix(heal): preserve typed bucket errors for bounded retries 2026-10-12 10:45:27 +08:00
cxymds 03fb3d78a0 ci(validation): consume pinned manager native prerequisites 2026-10-11 11:55:36 +08:00
cxymds b8126e50f7 test(ci): expose manager regressions through tools-only dispatch 2026-10-11 11:21:35 +08:00
cxymds 59fb72aa82 test(ci): add isolated manager native validation builds 2026-10-11 11:20:46 +08:00
cxymds 9de01c9170 docs(ci): describe supported validation recovery 2026-10-11 11:07:20 +08:00
cxymds 2ec26fa4b9 feat(ci): expose read-only validation fleet inventory 2026-10-11 10:42:48 +08:00
cxymds f4507178a3 test(ci): bind C10 execution to the verified shared codec 2026-10-11 10:09:26 +08:00
cxymds 9b4cc40abd test(validation): build optional C10 fixture codec on hosted Linux 2026-10-11 09:44:09 +08:00
cxymds b14ff4eeb1 test(validation): bind C07 to pinned native authority evidence 2026-10-11 09:27:59 +08:00
cxymds ae9b5cbabf Run validation tool resolution on hosted Linux without acquiring the fleet lock 2026-10-11 07:49:26 +08:00
cxymds 6c78b0d996 test(validation): route native tools through the existing driver 2026-10-11 06:06:17 +08:00
cxymds 0066e34764 test(validation): add optional native Heal regression tooling 2026-10-11 06:03:43 +08:00
cxymds fbddcc2965 ci: audit retained validation environments before finalization 2026-10-10 23:28:39 +08:00
cxymds b1793b55a0 test(validation): verify Heal tools against released shard captures 2026-10-10 21:11:43 +08:00
cxymds 835bd3f52d test(validation): verify pinned Heal tools before fleet execution 2026-10-10 20:46:40 +08:00
cxymds cb32d55878 test(validation): expose tool artifacts through the existing manual lane 2026-10-10 20:35:14 +08:00
cxymds 864e58592c test(validation): build the candidate-bound Heal inspector artifact 2026-10-10 20:32:34 +08:00
cxymds 3016209330 test(validation): provide the Python client for Heal cases 2026-10-10 20:30:10 +08:00
cxymds 64e0eeba91 Install validation client for replication cases 2026-10-10 18:52:42 +08:00
cxymds 35fa7488a5 Prepare Python client for pool validation cases 2026-10-10 18:41:48 +08:00
cxymds 1b77846f8e Install pinned ILM client for canonical validation lane 2026-10-10 18:26:49 +08:00
cxymds b0c861841e test(validation): add pinned auto-testing manual lane 2026-10-10 17:34:37 +08:00
394 changed files with 59288 additions and 6456 deletions
@@ -25,7 +25,7 @@ Read only the current phase's procedure; retain completed evidence across turns.
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
| Confirmation | Complete the gate below and end the turn. |
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
| 7 | [Maintain milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
| 7 | [Reconcile Security Advisories, milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
## Human Confirmation
@@ -39,4 +39,4 @@ Only a new affirmative user reply to that exact report authorizes Phase 6. The o
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting candidate branches or preview tags requires an explicit decision.
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; advisory reconciliation results and pending disclosure decisions; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
@@ -1,7 +1,17 @@
# Post-release Milestone, Installation, and Website Updates
# Post-release Security Advisory, Milestone, Installation, and Website Updates
Read only after the final non-preview tag has passed Phase 6. Tag creation or a green binary build alone is insufficient: the release assets, source archive, container images, Helm package, and applicable latest channels must be available. Preview tags never enter this phase.
## Security Advisories
Reconcile advisories after every completed final release, including Console-only releases. Read-only preparation may start earlier; do not claim an unpublished artifact is an available fix.
1. Fetch every page of the repository's live Security Advisories, including triage, draft, published, and closed states. A permission error or partial response blocks reconciliation; it does not mean there are no advisories. Keep unpublished advisory details in private task artifacts, never in a public PR, release note, or issue.
2. For each potentially relevant advisory, map its fix to the exact published tag and verify the regression evidence. Main-branch reachability, a merged private fix, or a green build alone does not prove the shipped version is fixed. Check cherry-picked equivalents and reverted fixes explicitly. For embedded Console or dependency fixes, verify the shipped component version.
3. Determine the first available fixed release and affected version ranges from evidence. Preserve an earlier correct patched version; do not relabel all fixes as the current release. An unchanged server baseline such as a Console-only release requires checking the earlier server release as well. Leave unresolved reports and fixes absent from the tag pending; do not close them merely because a release shipped.
4. Prepare any necessary corrections to package identity, affected ranges, patched versions, fix/release links, and advisory text. Preserve unrelated fields, credit, CVE identifiers, and disclosure coordination. Apply updates only within existing user authorization; read back the result to verify it. Publishing a draft, requesting a CVE, or closing a report is a separate action: require explicit authorization for the concrete advisories and respect any embargo. Release-tag approval alone does not authorize those actions. Prepare the exact proposed changes before asking for missing authorization.
5. Record each relevant advisory's URL, current state, fix evidence, first fixed version, action taken or proposed, and remaining blocker in private task artifacts. Report advisory follow-up separately from artifact publication. A successful release with unresolved advisory work is not complete follow-up; retain that work for the release monitor or a concrete user decision.
## Version milestones
1. Query all pages of both open and closed milestones in the RustFS release repository, matching titles exactly to version strings without a `v` prefix. A failed or incomplete query is not evidence that a milestone is absent; resolve the lookup before making changes.
@@ -1,4 +0,0 @@
interface:
display_name: "RustFS Release Bump"
short_description: "Prepare RustFS release branches like PR #2957."
default_prompt: "Use $rustfs-release-version-bump to prepare and verify an exact RustFS release-version bump."
@@ -1,4 +0,0 @@
interface:
display_name: "Security Advisory Lessons"
short_description: "Apply advisory lessons in reviews."
default_prompt: "Use $security-advisory-lessons for a dedicated RustFS security review grounded in past advisories."
-5
View File
@@ -1,5 +0,0 @@
# Bound individual tool outputs retained in context; retrieve relevant ranges
# from task-owned log files when more evidence is needed.
# https://learn.chatgpt.com/docs/config-file/config-reference
tool_output_token_limit = 4000
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=1520cd3027c3a6924afc6a204eddd5475bc30f0109d4c2847456a66177263244
sha256-linux=26155b7f1c5bf6bf596ff24a4136e6767ea4ee6461c32db7fbe1400791da2c28
sha256-darwin=3a7f52dd884ed269746029e983006ce69b62f260db2ad1acdcffdb2196c65d11
sha256-linux=4af4bb607919b96e63e2a3fb7a78bd18fcb08e265f4ca4ce6afb09a8be798305
+1 -1
View File
@@ -1 +1 @@
sha256=ba8f392b617cf9f2338142233a417e43927b28096635e628233ce8a60cf49d10
sha256=ce16185f87c6cd30aa0891ebd22be3352aedc10040d48cc5b00652d72f7be3b5
+20 -2
View File
@@ -76,6 +76,13 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
threads-required = "num-test-threads"
# These real-disk lock probes must finish durable IO within production's 5s
# acquisition budget. Reserve capacity from sibling test processes while
# preserving the probes' internal concurrency, deadlines, and assertions.
[[profile.default.overrides]]
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
threads-required = "num-test-threads"
[[profile.default.overrides]]
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
threads-required = "num-test-threads"
@@ -89,8 +96,11 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
setup = 'ecstore-large-stack'
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
# The admin target-repair fixtures compose the full bucket delete and recreate
# path on a real store; measured with RUST_MIN_STACK on 2026-10-15 they need
# 2016-2040 KiB of the 2 MiB libtest default and overflowed on Linux CI.
[[profile.default.scripts]]
filter = '(package(rustfs) & test(/^(admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
setup = 'ecstore-base-stack'
[[profile.default.scripts]]
@@ -332,6 +342,13 @@ threads-required = "num-test-threads"
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
threads-required = "num-test-threads"
# These real-disk lock probes must finish durable IO within production's 5s
# acquisition budget. Reserve capacity from sibling test processes while
# preserving the probes' internal concurrency, deadlines, and assertions.
[[profile.ci.overrides]]
filter = '(package(rustfs-ecstore) & test(=core::pools_test::decommission_lock_order_tests::active_decommission_migration_does_not_invert_capacity_and_delete_object_tag_locks)) | (package(rustfs-heal) & test(=heal::mrf_queue::partial_write::tests::durable_retry_submission_error_keeps_checkpoint_and_uses_backoff))'
threads-required = "num-test-threads"
[[profile.ci.overrides]]
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
threads-required = "num-test-threads"
@@ -346,7 +363,7 @@ setup = 'ecstore-large-stack'
# These cross-crate real-storage fixtures need the same bounded stack as ECStore tests.
[[profile.ci.scripts]]
filter = '(package(rustfs) & test(/^(admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
filter = '(package(rustfs) & test(/^(admin::handlers::replication::target_repair_tests::[a-z_]+|admin::handlers::site_replication::tests::durable_iam_state_apply_iam_item_rejects_a_stale_recreate_after_a_replicated_delete|admin::service::federated_identity::tests::(binding_keeps_explicit_iam_mapping_strict|markerless_legacy_oidc_session_crosses_auth_and_iam_deletion_boundaries)|storage::scanner_dirty_journal::tests::(clear_callback_keeps_unknown_coverage_until_all_pending_work_is_confirmed|owned_journals_keep_peer_records_after_interleaved_writes_and_clear))$/)) | (package(rustfs-heal) & ((binary(heal_b5_versioned_regression_test) & test(/^serial_tests::(test_heal_unversioned_bucket_e2e|test_historical_null_recursive_heal_restores_physical_versions|test_recursive_deep_heal_reports_stale_delete_marker_repair)$/)) | (binary(usage_observation_read_repair_test) & test(=usage_observation_read_repair_survives_snapshot_cleanup)))) | (package(rustfs-test-utils) & test(=data_usage_snapshot_tests::data_usage_snapshot_recovery_wires_primary_backup_and_negative_cache))'
setup = 'ecstore-base-stack'
[[profile.ci.scripts]]
@@ -605,6 +622,7 @@ default-filter = """
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/)
| test(/^conditional_delete_objects_test::/)
| test(/^reliant::tiering::/)
| test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
+5
View File
@@ -23,6 +23,11 @@ duplicate its rules here.
pass by fixing the cause, not by weakening the gate).
- If `shellcheck` is installed, actionlint also lints `run:` scripts; treat
those findings as part of the gate.
- Runner placement and billing rules live in `.github/workflows/README.md`.
A new job defaults to `ubuntu-latest` (free for this public repository);
a self-hosted `runs-on` needs one of the reasons listed there, stated in a
comment next to it, and a `rust-cache` key is never shared between the
hosted and self-hosted images.
## CI Alignment
+1 -1
View File
@@ -1,6 +1,6 @@
[
{ "workflow": ".github/workflows/audit.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/build.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
+74
View File
@@ -0,0 +1,74 @@
# Workflow runners: what is billed and where a job belongs
**Use this when:** adding a job, changing a `runs-on`, moving a lane between runner pools, or deciding whether a new check is worth its runner cost.
**Source of truth:** `runs-on` in each workflow file for current placement; `.github/actionlint.yaml` for the declared self-hosted labels; the organisation's Settings → Billing → Usage page for what is actually charged; `docs/testing/ci-gates.md` for which checks block a merge (that does not change when a runner changes).
## Billing facts
This repository is public. For a public repository, GitHub's standard hosted runners (`ubuntu-latest`, `windows-latest`, `macos-latest`) cost nothing and have no minute cap, whatever plan the organisation is on. GitHub bills only two things: larger hosted runners, which have custom labels and are not used here, and standard runners in private repositories.
Every other label in this repository is capacity RustFS pays for by the hour whether or not a job is running well:
| Label | Paid by | What it is | Use it for |
|---|---|---|---|
| `ubuntu-latest` | nobody | GitHub-hosted, Ubuntu 24.04, 4 vCPU, 16 GB RAM, about 14 GB of free disk. Ships a C toolchain, `docker`, `gh`, `curl`, `wget`, `jq`, Python. Can `mount` tmpfs and loop devices; io_uring works | the default for every job |
| `windows-latest`, `macos-latest` | nobody | GitHub-hosted standard runners | Windows and macOS targets |
| `sm-standard-2` | RustFS | ARC pod, Ubuntu 24.04, minimal image: no `gh`, no C toolchain; Docker only on the `dind-` label | only a job that a hosted runner cannot run (see the placement rule) |
| `sm-standard-4` | RustFS | ARC pod, Ubuntu 24.04, 14 CPUs and 28 GB (the label is not the core count), large disk | workspace-scale Cargo builds |
| `sm-standard-4-arm` | RustFS | ARC pod, aarch64; reached through the `build.yml` matrix, so `actionlint.yaml` does not list it | native aarch64 release builds |
| `dind-sm-standard-2` | RustFS | ARC pod with Docker-in-Docker | image builds that need `buildx` and a registry push |
| `smoke-testing`, `pf-testing` | RustFS | long-lived lab machines | the packaged functional chain and performance suites |
The self-hosted pods and the hosted image are both Ubuntu 24.04, so a binary built on one runs on the other; `ci.yml` builds the debug binary on `sm-standard-4` and runs it on both pools.
## Placement rule
A job runs on `ubuntu-latest` unless it has one of these reasons, and the reason goes in a comment next to its `runs-on`:
1. **Disk.** It restores or produces the workspace `target/`: `cargo build` or `cargo test` with `--all-targets`, `cargo nextest run --all`, or any reader of the `ci-dev` / `ci-feat-*` caches. The workspace test build leaves about 64 GB in `target/`; a hosted runner has about 14 GB.
2. **Critical-path CPU.** It is a build that several downstream lanes wait on and would blow its timeout at 4 vCPU (`build-rustfs-debug-binary` gates the e2e and s3-tests lanes).
3. **Hardware.** Native aarch64, Docker-in-Docker, or the lab machines.
4. **Cache ownership.** It reads a `rust-cache` key that a self-hosted job writes (see the cache rule below).
Jobs that do only the following have no reason to be self-hosted: shell or Python scripts, `cargo fmt`, `cargo deny`, typos, actionlint, API calls (`gh`, dependency review, issue and stale bots, CLA checks), downloading an artifact and running a prebuilt binary, or a `cargo build -p rustfs` with its own cache key. `e2e-distributed.yml` and `e2e-upgrade.yml` build the server and the `e2e_test` harness on `ubuntu-latest` inside their budgets, so "it compiles something" alone is not a reason.
A hosted runner is also the only choice for a few things the pods cannot do: `mount -o loop` and `mount -t tmpfs` fail inside the ARC pods, and a container runtime cannot guarantee that `io_uring_setup` is allowed. `uring-integration` and `e2e-distributed` are on `ubuntu-latest` for that reason, not for cost.
## Cache rule
`Swatinem/rust-cache` builds its key from the OS, architecture, toolchain and lockfile. It does not include the runner label or image, so a cache written on an `sm-standard-4` pod and one written on `ubuntu-latest` would overwrite each other under the same `shared-key`.
- Every `shared-key` has exactly one writer. `cache-warm.yml` owns `ci-dev`, `ci-feat-rio`, `ci-feat-proto` and `ci-uring`; every other `./.github/actions/setup` call states `cache-save-if` explicitly, and `scripts/security/check_cache_save_if.sh` fails the build when one does not.
- All readers of a key run on the same image as its writer. Keys that live on hosted runners say so in their name (`ci-uring` is warmed on `ubuntu-latest`; `ci-e2e-distributed-hosted`, `e2e-upgrade-server-hosted`, `e2e-upgrade-tests-hosted`).
- Moving a job between pools therefore means renaming its key. The new key is empty until a run from `main` (a scheduled run, or a manual dispatch of that workflow from `main`) saves it; until then that lane does a cold build.
- `CARGO_BUILD_JOBS` and every other `CARGO*`, `CC*`, `CFLAGS*`, `CXX*`, `CMAKE*` and `RUST*` variable is hashed into the key. `ci.yml` sets `CARGO_BUILD_JOBS` at step level for that reason; promoting it to job level rotates the key for every lane that shares it.
## Where self-hosted minutes leak
These are the patterns that have cost real money here without validating anything more:
- An `if: always()` summary or alert job on a self-hosted label inside a `workflow_run`-triggered workflow. The upstream workflow completes many times a day, the real jobs skip, and the summary still starts a pod each time. Gate it on the upstream job having run, and keep it on `ubuntu-latest`.
- Per-push workflows on `main`. A merge triggers the full `ci.yml` matrix, `cache-warm.yml` and the `build.yml` development build; with tens of merges a day that is as much self-hosted time as all pull requests together. Anything that does not verify the merged tree itself belongs on a schedule or behind a `paths` filter.
- `apt-get install` of tools the hosted image already ships. It is only needed on the minimal pod images.
- A throttled `CARGO_BUILD_JOBS` on a 14-CPU pod. The limit exists for link-storm reasons documented in `ci.yml`; measure before raising it, and measure before lowering it further.
- Tiny jobs on ARC pods. Each one pays roughly a minute of pod start-up before its first step; a compile-free job that takes ten seconds costs more to schedule than to run.
## Measuring a run
To see which pool each job of a run used and for how long:
```bash
gh api "repos/rustfs/rustfs/actions/runs/<run_id>/jobs?per_page=100" \
--jq '.jobs[] | "\(.conclusion)\t\(.labels | join(","))\t\(.started_at)\t\(.completed_at)\t\(.name)"'
```
Only rows whose label starts with `sm-`, `dind-`, `smoke-` or `pf-` cost anything. At the October 2026 review, a full pull-request run of `ci.yml` spent about 150 self-hosted minutes, a third of it in jobs that never compiled; treat that as the scale of what a placement mistake costs, not as a current figure.
## Checklist for a new or moved job
- `runs-on` is a literal label. A new self-hosted label is declared in `.github/actionlint.yaml` in the same change (`.github/AGENTS.md`).
- The job declares `timeout-minutes` (`scripts/security/check_job_timeouts.sh`).
- `actionlint` reports nothing.
- A self-hosted `runs-on` carries a comment naming its reason from the placement rule.
- Every `./.github/actions/setup` call states `cache-save-if`, and a key read from one pool is not written from the other.
- Moving a job does not change its check name, so the `main` ruleset and `docs/testing/ci-gates.md` stay as they are; renaming it does, and then that document's change checklist applies.
+4 -4
View File
@@ -77,7 +77,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -89,7 +89,7 @@ jobs:
cargo-deny:
name: Cargo Deny
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout repository
@@ -132,7 +132,7 @@ jobs:
workflow-pin-report:
name: Workflow Pin Report
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
@@ -169,7 +169,7 @@ jobs:
dependency-review:
name: Dependency Review
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
if: github.event_name == 'pull_request' && github.event.action != 'closed'
permissions:
+12 -42
View File
@@ -22,9 +22,12 @@
# 3. Trigger docker.yml to build and push images using the uploaded binaries
#
# Platform scope:
# - Pushes to main (development builds) build the Linux targets only — they
# feed the dev download channel and Docker dev images. Tags, the weekly
# schedule and manual dispatch build the full platform matrix.
# - Development builds come from the nightly schedule and manual dispatch and
# cover the full platform matrix; they feed the dev download channel
# (artifacts/rustfs/dev on R2, the *-main-latest packages). Tags build the
# release matrix. Main pushes do not build: at the usual merge rate most
# per-merge builds were cancelled by the next merge before publishing
# anything, and the dev channel does not need per-merge granularity.
#
# Manual Parameters:
# - publish: Publish binaries/releases for manual runs (default: false)
@@ -36,24 +39,8 @@ name: Build and Release
on:
push:
tags: [ "*.*.*" ]
branches: [ main ]
paths-ignore:
- "**.md"
- "**.txt"
- ".github/**"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "LICENSE*"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".gitignore"
- ".dockerignore"
- "flake.lock"
schedule:
- cron: "13 1 * * 0" # Weekly on Sunday 01:13 UTC
- cron: "13 1 * * *" # Nightly 01:13 UTC
workflow_dispatch:
inputs:
publish:
@@ -93,7 +80,7 @@ jobs:
# Build strategy check - determine build type based on trigger
build-check:
name: Build Strategy Check
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -144,12 +131,6 @@ jobs:
build_type="release"
echo "📦 Release build detected: $tag_name"
fi
elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
# Main branch push - development build
should_build=true
build_type="development"
version="dev-${short_sha}"
echo "🛠️ Development build detected"
elif [[ "${{ github.event_name }}" == "schedule" ]] || \
[[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
# Scheduled or manual build
@@ -178,7 +159,7 @@ jobs:
prepare-platform-matrix:
name: Prepare Platform Matrix
needs: build-check
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
matrix: ${{ steps.select.outputs.matrix }}
@@ -200,17 +181,6 @@ jobs:
selected="all"
fi
# Per-merge development builds only feed the dev download channel
# and the Docker dev images, which consume the Linux binaries; at
# the usual merge cadence most per-merge builds are cancelled by the
# next merge anyway. macOS and Windows stay covered by tag builds,
# the weekly scheduled build and manual dispatch.
if [[ "${selected}" == "all" \
&& "${{ github.event_name }}" == "push" \
&& "${{ needs.build-check.outputs.build_type }}" == "development" ]]; then
selected="linux-x86_64-musl,linux-aarch64-musl,linux-x86_64-gnu,linux-aarch64-gnu"
echo "Development (main push) build: restricting to Linux targets"
fi
all='{"include":[
{"target_id":"linux-x86_64-musl","os":"sm-standard-4","target":"x86_64-unknown-linux-musl","cross":false,"platform":"linux","rustflags":"","features":"","glibc":""},
@@ -645,7 +615,7 @@ jobs:
LATEST_FILES="$LATEST_FILE"
fi
elif [[ "$BUILD_TYPE" == "development" ]]; then
# Development builds (only main branch triggers development builds)
# Development builds (nightly schedule and manual dispatch)
# Create main-latest version filename
# Convert from rustfs-linux-x86_64-dev-abc123 to rustfs-linux-x86_64-main-latest
MAIN_LATEST_FILE="${PACKAGE_BASENAME}-main-latest.zip"
@@ -860,7 +830,7 @@ jobs:
name: Build Summary
needs: [ build-check, build-rustfs ]
if: always() && needs.build-check.outputs.should_build == 'true'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Build completion summary
@@ -1049,7 +1019,7 @@ jobs:
name: Update Latest Version
needs: [ build-check, publish-release ]
if: needs.build-check.outputs.should_publish == 'true' && startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Update latest.json
+31 -22
View File
@@ -42,26 +42,23 @@
name: Cache Warm
on:
schedule:
# Every six hours; the 22:41 UTC run lands before ci.yml's nightly 00:11
# UTC matrix. A reader restores the newest entry under its key and
# rebuilds whatever its own diff changed, so a cache up to six hours old
# costs a PR the handful of crates merged since. Warming after every merge
# (fourteen completed runs a day, about two sm-standard-4 hours each)
# spent far more than that to avoid it.
- cron: "41 4,10,16,22 * * *"
push:
branches: [ main ]
# Mirrors ci.yml's push paths-ignore: if a commit cannot change what ci.yml
# compiles, it cannot change what ci.yml needs restored either.
paths-ignore:
- "**.md"
- "docs/**"
- "deploy/**"
- "scripts/dev_*.sh"
- "scripts/probe.sh"
- "LICENSE*"
- ".gitignore"
- ".dockerignore"
- "README*"
- "**/*.png"
- "**/*.jpg"
- "**/*.svg"
- ".github/workflows/build.yml"
- ".github/workflows/docker.yml"
- ".github/workflows/audit.yml"
# A toolchain or lockfile change invalidates most of every key; those
# re-warm at once instead of waiting for the next slot.
paths:
- "Cargo.lock"
- "rust-toolchain.toml"
- ".github/actions/setup/**"
- ".github/workflows/cache-warm.yml"
workflow_dispatch:
inputs:
emit_timings:
@@ -75,7 +72,7 @@ on:
permissions:
contents: read
# Scoped by event. A push run and a dispatch run do not compete: GitHub keeps
# Scoped by event. Scheduled, push and dispatch runs do not compete: GitHub keeps
# one running plus one pending per group, so with a single shared group a
# manually dispatched run was displaced as pending by the next merge and
# cancelled — observed three times in a row, which made the --timings gate in
@@ -162,6 +159,17 @@ jobs:
cargo build --workspace --all-targets
cargo build -p rustfs --bins --features e2e-test-hooks
# ci.yml runs Clippy on every pull request. Clippy's check-mode artifacts
# are separate from the build above, so warm them with the exact
# invocation ci.yml uses; a pull request then re-checks only what its diff
# touches. A lint failure on main must not stop the warm-up: the cache is
# still saved (cache-on-failure), and the failure belongs to ci.yml.
- name: Warm Clippy check artifacts
run: |
if ! cargo clippy --all-targets -- -D warnings; then
echo "::warning::cargo clippy reports findings on this commit; check artifacts are still cached"
fi
# Runs before rust-cache's post step, so these are the sizes it is about
# to archive. Reported so the cache-all-crates decision stays evidence-led:
# registry/src is what that flag prunes, registry/cache is what the pruned
@@ -185,9 +193,10 @@ jobs:
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Readers: test-and-lint-rio-v2 (per-PR), build-rustfs-debug-binary-rio-v2
# (weekly schedule / manual dispatch only — dormant rio-v2 variant, see
# rustfs/backlog#1835 and docs/architecture/minio-file-format-compat.md).
# Readers: test-and-lint-rio-v2 (nightly schedule, merge group, dispatch),
# build-rustfs-debug-binary-rio-v2 (nightly schedule / manual dispatch only —
# dormant rio-v2 variant, see rustfs/backlog#1835 and
# docs/architecture/minio-file-format-compat.md).
# The second build below stays despite the reduced cadence: it warms the
# rio-v2,e2e-test-hooks feature resolution the scheduled build restores,
# which keeps that lane inside its 30-minute timeout.
+94 -107
View File
@@ -42,7 +42,7 @@ on:
merge_group:
types: [ checks_requested ]
schedule:
- cron: "11 0 * * 0" # Weekly on Sunday 00:11 UTC
- cron: "11 0 * * *" # Nightly 00:11 UTC, after the 22:41 UTC cache-warm run
workflow_dispatch:
permissions:
@@ -62,10 +62,15 @@ env:
jobs:
# Compile-free lanes, and lanes that only run a prebuilt binary, use
# GitHub-hosted runners: they are free for this public repository, and the
# self-hosted pool is reserved for the lanes that need its CPU count and
# disk (the workspace test build leaves a ~64GB target/, hosted runners
# have 14GB). Placement and billing rules: README.md in this directory.
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -74,7 +79,7 @@ jobs:
classify-changes:
name: Select CI scope
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
mode: ${{ steps.scope.outputs.mode }}
@@ -100,16 +105,12 @@ jobs:
typos:
name: Typos
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Install typos download dependency
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends wget
- name: Typos check with custom config file
uses: crate-ci/typos@512fc24f32f44ab01972217aaaf3dc86ec234d53 # v1.50.2
@@ -117,7 +118,7 @@ jobs:
quick-checks:
name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
@@ -168,10 +169,13 @@ jobs:
echo "started_at=$(date --utc --iso-8601=seconds)"
} > artifacts/test-and-lint/run-metadata.txt
# Keep full Clippy validation on main, merge groups, and scheduled/manual
# runs. PRs retain the complete default test suite and compile-free checks.
# Clippy gates every event, pull requests included. When it ran only after
# merge, a lint regression merged freely and then failed every main run
# until someone fixed it (10 of 12 main runs on 2026-10-17/18). cache-warm
# writes the same invocation's check artifacts into ci-dev, so a pull
# request re-checks only the crates its diff touches. Main keeps the step
# because pull requests can merge without being up to date with main.
- name: Run clippy lints
if: github.event_name != 'pull_request'
run: |
./scripts/ci/resource_sampler.sh start clippy
trap './scripts/ci/resource_sampler.sh stop' EXIT
@@ -179,31 +183,27 @@ jobs:
- name: Run nextest tests
env:
# #5394 mitigation, now under a measured experiment (backlog#1601).
# #5394 mitigation, settled by the backlog#1601 experiment.
#
# 2 was chosen when three concurrent workspace test links were believed
# to saturate the runner's overlay I/O and wedge Cargo until the 75m
# timeout. cgroup v2 readings from the sampler show the pod actually
# has 14 CPUs and 28GB (peak use 2.1GB), so 2 throttles compilation to
# a seventh of what is available and memory was never the constraint —
# the label name "sm-standard-4" had led everyone, including the
# original mitigation, to assume 4 cores.
# has 14 CPUs and 28GB (peak use 2.1GB): the label name "sm-standard-4"
# had led everyone, including the original mitigation, to assume 4
# cores, and memory was never the constraint.
#
# Raised to 3 on main pushes and manual dispatches; PRs keep 2 so the
# merge path is untouched while the experiment runs.
#
# During baseline collection, main pushes cancelled superseded runs:
# only 4 of 20 Test and Lint jobs reached a terminal state. Running
# main validations now finish; dispatch remains an independent group
# for collecting additional samples on demand.
#
# Baseline over 17 samples at 2:
# median nextest/clippy step ratio 1.95, spread 1.85-2.06. The gate-2
# criterion is that ratio dropping at least 10% (below ~1.76) with no
# 75m timeout and no run showing three consecutive samples of
# rustc/collect2/rust-lld in D state. If it does not, the conclusion is
# "this limit is not the bottleneck" — fix it back at 2 and record the
# experiment, which is a result, not a failure.
# Measured on the same ci-dev cache, the test-profile compile took
# 28m21s at 2 on a pull request and 16-23 min at 3 across three
# successful main runs. The first pull-request run at 3 that met a
# busy node (run 39193827281) then hit the 75m timeout before any
# test started: the sampler shows cargo spinning for 70 minutes with
# the pod's io.pressure "full" at 86-88% and CPU pressure at zero,
# while node-level IO pressure was already 36% from co-tenants when
# the step began. The constraint is the pod's shared storage, not
# CPU or memory, and the limit that storage sustains on a contended
# node is 2. Main pushes and dispatches keep 3 (rarer, and three
# successes); pull requests stay at 2 until the pods get local disk
# for target/, which is the lever that would unlock the 14 cores.
#
# Must stay step-level: rust-cache hashes CARGO/CC/CFLAGS/CXX/CMAKE/RUST
# prefixed variables from process.env into the cache key, so promoting
@@ -323,10 +323,12 @@ jobs:
path: artifacts/ilm-integration
retention-days: 3
# This gate builds into a fresh target directory to isolate the E2E root.
# Give its cold build a separate budget from workspace tests and migration proofs.
offline-enrollment-e2e:
name: Offline Enrollment E2E Root Boundary
# Both Connect boundaries rebuild only the rustfs crate on top of the restored
# ci-dev cache: the two e2e features are empty, so every dependency keeps its
# cached dev-profile artifact and each feature set recompiles rustfs once.
# The budget covers a ci-dev miss, where the first step builds the whole graph.
connect-feature-boundaries:
name: Connect Feature Boundaries
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
@@ -351,14 +353,58 @@ jobs:
- name: Protect Connect test home
run: chmod go-w "$(realpath "$HOME")"
- name: Run short credential behavior tests
env:
CARGO_BUILD_JOBS: "2"
run: |
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
registration_enforces_build_profile_credential_lifetime -- --exact
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
# The guard in rustfs/src/connect/registration.rs is cfg(not(debug_assertions)),
# and it must be tripped by a profile that turns debug assertions off for
# the whole graph. Handing `-C debug-assertions=off` to the rustfs lib alone
# through `cargo rustc` leaves its dependencies on the dev-profile cfg, so
# the lib also fails on items gated on one side only (E0599 on
# Filenames::Embedded) and "fails solely at the compile_error" can never
# hold. `cargo check --release` is metadata-only and needs no
# link-concurrency limit; it is cold because ci-dev holds no release
# artifacts.
- name: Reject short credentials in release builds
run: |
log="$(mktemp)"
set +e
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
--features connect-e2e-short-credentials >"$log" 2>&1
status=$?
set -e
cat "$log"
expected='error: connect-e2e-short-credentials is restricted to debug builds'
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
expected_count="$(grep -Fxc "$expected" "$log" || true)"
summary_count="$(grep -Fc "$summary" "$log" || true)"
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
echo "feature gate did not fail solely at the expected compile_error" >&2
rm -f "$log"
exit 1
fi
rm -f "$log"
- name: Check offline enrollment E2E root boundary
run: ./scripts/check_offline_enrollment_e2e.sh
test-and-lint-rio-v2:
name: Test and Lint (rio-v2)
# Alternate feature configurations run on main, merge groups, and scheduled
# or manual validation; the default workspace remains the PR gate.
if: needs.classify-changes.outputs.mode == 'full' && github.event_name != 'pull_request'
# Alternate feature configurations run on the nightly schedule, merge
# groups and manual dispatch. The default workspace remains the PR gate and
# the per-push main baseline: repeating four feature builds on every merge
# bought nothing the nightly run does not report within a day.
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -414,67 +460,10 @@ jobs:
retention-days: 3
if-no-files-found: error
connect-short-credential-boundary:
name: Connect Short Credential Boundary
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-dev
cache-save-if: 'false'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Run short credential behavior tests
env:
CARGO_BUILD_JOBS: "2"
run: |
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
registration_enforces_build_profile_credential_lifetime -- --exact
cargo test -p rustfs --test connect_registration \
--features connect-e2e-short-credentials \
rotation_waits_for_threshold_and_stops_on_revocation -- --exact
- name: Reject short credentials in release builds
env:
CARGO_BUILD_JOBS: "2"
run: |
log="$(mktemp)"
set +e
CARGO_TERM_COLOR=never cargo check -p rustfs --release \
--features connect-e2e-short-credentials >"$log" 2>&1
status=$?
set -e
cat "$log"
expected='error: connect-e2e-short-credentials is restricted to debug builds'
summary="error: could not compile \`rustfs\` (lib) due to 1 previous error"
expected_count="$(grep -Fxc "$expected" "$log" || true)"
summary_count="$(grep -Fc "$summary" "$log" || true)"
error_count="$(grep -Ec '^error(:|\[)' "$log" || true)"
if [ "$status" -ne 101 ] || [ "$expected_count" -ne 1 ] \
|| [ "$summary_count" -ne 1 ] || [ "$error_count" -ne 2 ]; then
echo "release feature gate did not fail solely at the expected compile_error" >&2
rm -f "$log"
exit 1
fi
rm -f "$log"
test-and-lint-protocols:
name: "Test and Lint (${{ matrix.features.name }})"
if: needs.classify-changes.outputs.mode == 'full' && github.event_name != 'pull_request'
# Same cadence as test-and-lint-rio-v2 above.
if: needs.classify-changes.outputs.mode == 'full' && contains(fromJSON('["merge_group", "schedule", "workflow_dispatch"]'), github.event_name)
needs: [ quick-checks, classify-changes ]
runs-on: sm-standard-4
timeout-minutes: 90
@@ -630,7 +619,7 @@ jobs:
build-rustfs-debug-binary-rio-v2:
name: Build RustFS Debug Binary (rio-v2)
# Dormant rio-v2 variant (rustfs/backlog#1835): the feature ships in no
# default build, so this full-suite lane runs only on the weekly schedule
# default build, so this full-suite lane runs only on the nightly schedule
# and manual dispatch. Per-PR cfg-seam coverage stays with
# test-and-lint-rio-v2. Lifecycle and the promote-or-delete condition:
# docs/architecture/minio-file-format-compat.md ("rio-v2 variant lifecycle").
@@ -1100,7 +1089,7 @@ jobs:
s3-implemented-tests:
name: S3 Implemented Tests
needs: [ build-rustfs-debug-binary, e2e-tests ]
runs-on: sm-standard-4
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout repository
@@ -1169,12 +1158,12 @@ jobs:
name: S3 Lifecycle Behavior Tests
# Also gated on e2e-tests, matching s3-implemented-tests: when the e2e smoke
# suite is already red this lane cannot tell us anything new, and it holds a
# sm-standard-4 for up to 30 minutes doing so. Both lanes only download the
# runner for up to 30 minutes doing so. Both lanes only download the
# prebuilt debug binary (no cargo build), and s3-implemented-tests — which
# already waits on e2e-tests — finishes later anyway, so a green PR's total
# wall clock is unchanged.
needs: [ build-rustfs-debug-binary, e2e-tests ]
runs-on: sm-standard-4
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
@@ -1231,9 +1220,8 @@ jobs:
- typos
- quick-checks
- test-and-lint
- offline-enrollment-e2e
- connect-feature-boundaries
- test-and-lint-rio-v2
- connect-short-credential-boundary
- test-and-lint-protocols
- build-rustfs-debug-binary
- uring-integration
@@ -1243,7 +1231,7 @@ jobs:
- build-rustfs-debug-binary-rio-v2
- e2e-tests-rio-v2
- e2e-full
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -1259,12 +1247,11 @@ jobs:
name: Alert on scheduled failure
needs:
- classify-changes
- connect-short-credential-boundary
- connect-feature-boundaries
- required-checks
- typos
- quick-checks
- test-and-lint
- offline-enrollment-e2e
- test-and-lint-rio-v2
- test-and-lint-protocols
- build-rustfs-debug-binary
+2 -2
View File
@@ -46,7 +46,7 @@ jobs:
# Echoes one line; the run exists only so the concurrency group cancels the
# in-flight run of a closed PR.
permissions: {}
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -61,7 +61,7 @@ jobs:
checks: write
issues: write
pull-requests: write
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Report CLA result for merge queue
+3 -3
View File
@@ -83,7 +83,7 @@ jobs:
(github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_branch != 'main')
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
should_build: ${{ steps.check.outputs.should_build }}
@@ -501,7 +501,7 @@ jobs:
name: Scan Docker Images
needs: [ build-check, build-docker ]
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
@@ -556,7 +556,7 @@ jobs:
name: Docker Build Summary
needs: [ build-check, build-docker ]
if: always() && needs.build-check.outputs.should_build == 'true'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Docker build completion summary
+1 -1
View File
@@ -371,7 +371,7 @@ jobs:
name: Upstream HEAD classification canary
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
continue-on-error: true
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
+31 -24
View File
@@ -46,14 +46,11 @@ concurrency:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
jobs:
build:
name: Build upgrade candidate
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 60
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -66,19 +63,11 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-server
cache-shared-key: e2e-upgrade-server-hosted
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Download pinned previous release
run: |
mkdir -p target/debug
archive="target/debug/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
- name: Build current RustFS binary
run: python3 scripts/e2e_binary.py build
@@ -89,19 +78,31 @@ jobs:
path: |
target/debug/rustfs
target/debug/rustfs.e2e.json
target/debug/${{ env.UPGRADE_SOURCE_ASSET }}
if-no-files-found: error
retention-days: 3
compression-level: 0
overwrite: true
upgrade:
name: Upgrade compatibility
name: Upgrade compatibility (${{ matrix.source.version }})
needs: build
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
source:
- version: 1.0.0-rc.5
asset: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
sha256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
- version: 1.0.1
asset: rustfs-linux-x86_64-gnu-v1.0.1.zip
sha256: 9742e183cb0dfb0d48fa909797af572fe47b7f827569036baa4156e0d3958ee5
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
UPGRADE_SOURCE_VERSION: ${{ matrix.source.version }}
UPGRADE_SOURCE_ASSET: ${{ matrix.source.asset }}
UPGRADE_SOURCE_SHA256: ${{ matrix.source.sha256 }}
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
@@ -111,7 +112,7 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-upgrade-tests
cache-shared-key: e2e-upgrade-tests-hosted
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
install-test-tools: "false"
@@ -132,6 +133,8 @@ jobs:
set -euo pipefail
mkdir -p "$SOURCE_DIR"
archive="target/debug/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/rustfs/rustfs/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
unzip -q "$archive" -d "$SOURCE_DIR"
chmod +x "$SOURCE_DIR/rustfs"
@@ -148,14 +151,18 @@ jobs:
run: |
export no_proxy="$NO_PROXY"
cases=(
direct-upgrade:direct_upgrade_from_rc2_preserves_object_contracts
mixed-version-upgrade:rolling_upgrade_from_rc2_preserves_mixed_version_contracts
direct-upgrade:direct_upgrade_from_previous_release_preserves_object_contracts
mixed-version-upgrade:rolling_upgrade_from_previous_release_preserves_mixed_version_contracts
bucket-config-upgrade:direct_upgrade_from_previous_release_preserves_bucket_configuration
bucket-config-rollback:rollback_to_previous_release_reads_current_bucket_metadata
odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration
multipart-layout-upgrade:direct_upgrade_from_rc5_preserves_multipart_layouts
multipart-layout-baseline:rc5_baseline_replicates_multipart_layouts
multipart-layout-upgrade:direct_upgrade_from_previous_release_preserves_multipart_layouts
multipart-layout-baseline:previous_release_baseline_replicates_multipart_layouts
)
if [[ "$UPGRADE_SOURCE_VERSION" == "1.0.0-rc.5" ]]; then
cases+=(odm-config-rollback:rc5_rollback_requires_restoring_odm_configuration)
else
cases+=(legacy-delete-reservation:stable_upgrade_deletes_foreign_reservations_without_losing_live_objects)
fi
status=0
for entry in "${cases[@]}"; do
artifact="${entry%%:*}"
@@ -180,7 +187,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-g09-evidence-${{ github.run_number }}
name: upgrade-g09-evidence-${{ matrix.source.version }}-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence
if-no-files-found: ignore
retention-days: 14
@@ -189,7 +196,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: upgrade-server-logs-${{ github.run_number }}
name: upgrade-server-logs-${{ matrix.source.version }}-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-logs
if-no-files-found: warn
retention-days: 14
+1 -1
View File
@@ -60,7 +60,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
+31 -65
View File
@@ -6,29 +6,20 @@ name: Gateway Integration Sync
# Merges `main` into the long-lived `gateway/integration` branch every weekday
# so the s3s -> gateway migration (rustfs/backlog#2734, task T0.1 in #2736)
# never accumulates merge debt. A clean merge lands as a `--no-ff` merge commit
# pushed straight to the branch. A conflicting merge is aborted and surfaced as
# a pull request from `sync/<source>-<date>`, whose head is the un-merged
# source commit, so the conflict is resolved the same day, in review, with no
# other change riding along. The run itself stays green in that case: the pull
# request is the signal. Any other failure (push rejected, pull request not
# created) fails the run and, on the schedule, files the shared failure issue.
# pushed straight to the branch. A conflicting merge is aborted and fails the
# run after its summary records the source and target SHAs, run URL, and exact
# conflicting paths. It creates no sync branch or pull request. Resolve the
# merge in an isolated worktree based on the latest target, preserve the
# two-parent merge commit, and push only after the required checks pass and the
# target still matches the merge's first parent. If the target moves, restart
# from its new head.
#
# Resolving a conflict pull request: merge `origin/gateway/integration` into
# the sync branch, resolve, push, get the review. Land it with a fast-forward
# push of the resolved merge commit to `gateway/integration`, not with the
# squash button: a squash drops the merge ancestry and the same hunks conflict
# again on the next run.
#
# Write scope: only `gateway/integration` and `sync/*` are ever pushed, and
# both push targets below are literal refs. The pushes use a one-hour
# installation token of the organisation's own GitHub App (contents: write)
# because that app is the only non-admin bypass actor of the
# Write scope: only `gateway/integration` is ever pushed. The push uses a
# one-hour installation token of the organisation's GitHub App (contents:
# write) because that app is the only non-admin bypass actor of the
# `gateway/integration` ruleset; GITHUB_TOKEN cannot be a ruleset bypass actor,
# so it cannot push through the required reviews and checks. The conflict pull
# request is opened with FLAKE_UPDATE_TOKEN, the PAT that already opens the
# weekly flake.lock pull requests, because this repository keeps "Allow GitHub
# Actions to create and approve pull requests" switched off. GITHUB_TOKEN is
# therefore read-only here.
# so it cannot push through the required reviews and checks. GITHUB_TOKEN is
# read-only here.
#
# `schedule` fires only from the default branch, so the cron is live once this
# file exists on `main`. `workflow_dispatch` runs from any ref that carries the
@@ -94,6 +85,7 @@ jobs:
id: merge
env:
SOURCE_REF: ${{ inputs.source_ref || 'main' }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
if ! [[ "$SOURCE_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
@@ -119,13 +111,25 @@ jobs:
fi
exit 0
fi
git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicts.txt"
git merge --abort
if [[ ! -s "$RUNNER_TEMP/conflicts.txt" ]]; then
conflicts=$(git diff --name-only --diff-filter=U)
if [[ -z "$conflicts" ]]; then
git merge --abort || true
echo "::error::git merge failed without reporting conflicting paths; this is not a conflict"
exit 1
fi
echo "result=conflict" >> "$GITHUB_OUTPUT"
{
echo "## Gateway integration sync stopped on conflicts"
echo
echo "- Run: ${RUN_URL}"
echo "- Source: \`${SOURCE_REF}\` at ${source_sha}"
echo "- Target: \`${TARGET_BRANCH}\` at ${target_sha}"
echo
echo "Conflicting files:"
printf '%s\n' "$conflicts" | sed 's/^/- /'
} >> "$GITHUB_STEP_SUMMARY"
git merge --abort
echo "::error::${SOURCE_REF} cannot be merged cleanly into ${TARGET_BRANCH}; no branch or pull request was created"
exit 1
- name: Push the merge commit
if: steps.merge.outputs.result == 'merged'
@@ -137,50 +141,12 @@ jobs:
git push origin HEAD:refs/heads/gateway/integration
echo "Merged \`${SOURCE_REF}\` (${SOURCE_SHA}) into \`${TARGET_BRANCH}\` as $(git rev-parse HEAD)." >> "$GITHUB_STEP_SUMMARY"
- name: Open the conflict pull request
if: steps.merge.outputs.result == 'conflict'
env:
GH_TOKEN: ${{ secrets.FLAKE_UPDATE_TOKEN }}
SOURCE_REF: ${{ steps.merge.outputs.source_ref }}
SOURCE_SHA: ${{ steps.merge.outputs.source_sha }}
TARGET_SHA: ${{ steps.merge.outputs.target_sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
date_utc=$(date -u +%Y-%m-%d)
sync_branch="sync/${SOURCE_REF//\//-}-${date_utc}"
git push origin "refs/remotes/origin/${SOURCE_REF}:refs/heads/${sync_branch}"
{
echo "The scheduled merge of \`${SOURCE_REF}\` into \`${TARGET_BRANCH}\` stopped on conflicts and was aborted; \`${TARGET_BRANCH}\` is unchanged."
echo
echo "This branch is the un-merged \`${SOURCE_REF}\` head. Merge \`origin/${TARGET_BRANCH}\` into it, resolve the files below, push, and get the review. Land the resolved merge commit with a fast-forward push to \`${TARGET_BRANCH}\`; a squash merge drops the merge ancestry and the same hunks conflict again on the next run. Do not include any change that is not conflict resolution."
echo
echo "- Run: ${RUN_URL}"
echo "- Source: \`${SOURCE_REF}\` at ${SOURCE_SHA}"
echo "- Target: \`${TARGET_BRANCH}\` at ${TARGET_SHA}"
echo
echo "## Conflicting files"
echo
echo "Output of \`git diff --name-only --diff-filter=U\` captured before \`git merge --abort\`:"
echo
sed 's/^/- /' "$RUNNER_TEMP/conflicts.txt"
} > "$RUNNER_TEMP/pr-body.md"
title="sync: merge ${SOURCE_REF} ${date_utc} (conflicts)"
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --state open --json number --jq '.[0].number // empty')
if [[ -n "$existing" ]]; then
gh pr edit "$existing" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md"
pr_url=$(gh pr view "$existing" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
else
pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md")
fi
echo "Conflicts in \`${SOURCE_REF}\` -> \`${TARGET_BRANCH}\`; pull request: ${pr_url}" >> "$GITHUB_STEP_SUMMARY"
alert-on-failure:
name: Alert on scheduled failure
needs: [sync]
# Same ci-8 mechanism audit.yml and coverage.yml use: a scheduled run that
# could not push or could not open the conflict pull request must not stay
# visible only in the Actions tab.
# could not push or encountered merge conflicts must not stay visible only
# in the Actions tab.
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
runs-on: ubuntu-latest
timeout-minutes: 10
+2 -2
View File
@@ -31,7 +31,7 @@ permissions:
jobs:
build-helm-package:
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
if: |
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
@@ -166,7 +166,7 @@ jobs:
retention-days: 1
publish-helm-package:
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [ build-helm-package ]
if: needs.build-helm-package.result == 'success'
+1 -1
View File
@@ -32,7 +32,7 @@ permissions:
jobs:
build:
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: usthe/issues-translate-action@b41f55ddc81d7d54bd542a4f289fe28ec081898e # v2.7
+1 -1
View File
@@ -45,7 +45,7 @@ jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve nightly source
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
update-flake:
if: github.repository == 'rustfs/rustfs'
name: Update flake.lock
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
cancel-closed-pr-runs:
name: Cancel Closed PR Runs
if: github.event_name == 'pull_request' && github.event.action == 'closed'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Explain cancellation run
@@ -186,7 +186,7 @@ jobs:
cloud-source:
name: ${{ matrix.provider }} source (three-case minimum)
if: github.repository == 'rustfs/rustfs'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
+4 -2
View File
@@ -618,8 +618,10 @@ jobs:
summary:
name: Summary
needs: [ resolve, package ]
if: always()
runs-on: sm-standard-2
# Every completed Build and Release run triggers this workflow; when
# resolve declined it (not a release tag), there is nothing to summarise.
if: always() && needs.resolve.result != 'skipped'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Print summary
+436 -5
View File
@@ -20,13 +20,65 @@ on:
workflow_dispatch:
inputs:
build_run_id:
description: Successful nightly build run on main
description: Successful nightly or validation package run; empty for package-only build
type: string
required: true
required: false
default: ''
build_run_attempt:
description: Exact successful build attempt
type: string
required: true
required: false
default: ''
validation_package_sha:
description: Exact source SHA for an isolated package-only build
type: string
required: false
default: ''
validation_case:
description: Canonical case ID, environment.inspect for read-only resources, or empty for functional chain
type: string
required: false
default: ''
auto_testing_sha:
description: Exact auto-testing commit for a validation case
type: string
required: false
default: ''
validation_tools_only:
description: Build candidate-bound validation tools without running a fleet case
type: boolean
required: false
default: false
validation_native_tools:
description: Include exact-source native regressions in a tools-only build
type: boolean
required: false
default: false
validation_shared_tools:
description: Include the controlled C10 shared-reference encoder in a tools-only build
type: boolean
required: false
default: false
validation_manager_tools:
description: Include exact checkpoint/MRF/incarnation regressions in a tools-only build
type: boolean
required: false
default: false
validation_cleanup_run_id:
description: Failed validation run whose supported retained environment needs evidence-based finalization
type: string
required: false
default: ''
validation_tools_run_id:
description: Successful tool build run; required by Heal physical metadata checks
type: string
required: false
default: ''
validation_tools_run_attempt:
description: Exact tool build attempt
type: string
required: false
default: '1'
workflow_run:
workflows: ["Nightly GNU Build"]
types: [completed]
@@ -36,12 +88,47 @@ permissions:
actions: read
concurrency:
group: rustfs-functional-chain-runs
group: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' && format('rustfs-functional-validation-package-{0}', inputs.validation_package_sha) || github.event_name == 'workflow_dispatch' && inputs.validation_tools_only && 'rustfs-functional-validation-tools' || 'rustfs-functional-chain-runs' }}
cancel-in-progress: false
jobs:
validation-package-request:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate exclusive package-only request
env:
PACKAGE_INPUTS: ${{ toJSON(inputs) }}
run: |
python3 - <<'PY'
import json
import os
import re
value = json.loads(os.environ["PACKAGE_INPUTS"])
if re.fullmatch(r"[0-9a-f]{40}", value["validation_package_sha"]) is None:
raise ValueError("package build requires an exact source SHA")
exclusive = ("build_run_id", "build_run_attempt", "validation_case", "auto_testing_sha",
"validation_tools_only", "validation_native_tools", "validation_shared_tools",
"validation_manager_tools", "validation_cleanup_run_id", "validation_tools_run_id")
if any(value.get(name) for name in exclusive):
raise ValueError("package-only build cannot select existing candidate, tools, fleet or recovery execution")
PY
validation-package:
needs: validation-package-request
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha != '' }}
uses: ./.github/workflows/rustfs-validation-package.yml
with:
source_sha: ${{ inputs.validation_package_sha }}
secrets:
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
prepare:
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.event == 'schedule' }}
if: ${{ (github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_case == '' && !inputs.validation_tools_only && !inputs.validation_native_tools && !inputs.validation_shared_tools && !inputs.validation_manager_tools && inputs.validation_cleanup_run_id == '') || github.event.workflow_run.event == 'schedule' }}
runs-on: smoke-testing
timeout-minutes: 10
outputs:
@@ -89,6 +176,350 @@ jobs:
echo "performance_ready=offline" >> "$GITHUB_OUTPUT"
fi
validation:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && (inputs.validation_case != '' || inputs.validation_tools_only || inputs.validation_native_tools || inputs.validation_shared_tools || inputs.validation_manager_tools || inputs.validation_cleanup_run_id != '') }}
runs-on: ${{ inputs.validation_tools_only && 'ubuntu-latest' || 'smoke-testing' }}
timeout-minutes: 90
outputs:
source_sha: ${{ steps.candidate.outputs.source_sha }}
concurrency:
group: ${{ inputs.validation_tools_only && 'rustfs-validation-tools-resolver' || 'rustfs-shared-functional-tests-v2' }}
cancel-in-progress: false
env:
RUSTFS_ACCESS_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_ACCESS_KEY || '' }}
RUSTFS_SECRET_KEY: ${{ !inputs.validation_tools_only && secrets.RUSTFS_SECRET_KEY || '' }}
RUSTFS_API_ENDPOINT: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT) || '' }}
RUSTFS_NODES: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_NODES || vars.RUSTFS_NODES) || '' }}
RUSTFS_SSH_USER: ${{ !inputs.validation_tools_only && (secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER) || '' }}
steps:
- name: Checkout candidate resolver
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Initialize validation evidence
run: |
set -euo pipefail
umask 077
VALIDATION_ROOT="${RUNNER_TEMP}/rustfs-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${VALIDATION_ROOT}"
printf 'VALIDATION_ROOT=%s\n' "${VALIDATION_ROOT}" >> "${GITHUB_ENV}"
- name: Resolve immutable candidate
id: candidate
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
BUILD_RUN_ID: ${{ inputs.build_run_id }}
BUILD_RUN_ATTEMPT: ${{ inputs.build_run_attempt }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
CLEANUP_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
SELECTED_CASE: ${{ inputs.validation_case }}
TOOLS_ONLY: ${{ inputs.validation_tools_only }}
NATIVE_TOOLS: ${{ inputs.validation_native_tools }}
SHARED_TOOLS: ${{ inputs.validation_shared_tools }}
MANAGER_TOOLS: ${{ inputs.validation_manager_tools }}
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
from pathlib import Path
import re
from scripts.resolve_functional_candidate import resolve
revision = os.environ["AUTO_TESTING_SHA"]
cleanup = os.environ["CLEANUP_RUN_ID"]
if os.environ["TOOLS_ONLY"] == "true" and (cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("tools-only execution cannot select a fleet case or cleanup")
if os.environ["NATIVE_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("native regressions require tools-only execution, without a fleet case or cleanup")
if os.environ["SHARED_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("shared fixture builds require tools-only execution, without a fleet case or cleanup")
if os.environ["MANAGER_TOOLS"] == "true" and (os.environ["TOOLS_ONLY"] != "true" or cleanup or os.environ["SELECTED_CASE"]):
raise ValueError("manager regressions require tools-only execution, without a fleet case or cleanup")
if cleanup and (not cleanup.isdigit() or int(cleanup) <= 0 or os.environ["SELECTED_CASE"] or os.environ["TOOLS_ONLY"] == "true"):
raise ValueError("cleanup requires one exact failed run and is exclusive with case/tool execution")
if re.fullmatch(r"[0-9a-f]{40}", revision) is None:
raise ValueError("validation requires an exact auto-testing commit SHA")
candidate = resolve(int(os.environ["BUILD_RUN_ID"]), int(os.environ["BUILD_RUN_ATTEMPT"]), allow_validation=True)
manifest = candidate["manifest"]
root = Path(os.environ["VALIDATION_ROOT"])
(root / "candidate-source.json").write_text(json.dumps(candidate, sort_keys=True) + "\n")
(root / "candidate.json").write_text(json.dumps({
"package_url": manifest["package_url"],
"package_sha256": manifest["package_sha256"],
"source_sha": manifest["source_sha"],
}, sort_keys=True) + "\n")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
output.write("source_sha=" + manifest["source_sha"] + "\n")
PY
- name: Checkout exact auto-testing revision
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install validation Python client
if: ${{ !inputs.validation_tools_only && (startsWith(inputs.validation_case, 'ilm.') || startsWith(inputs.validation_case, 'pool.') || startsWith(inputs.validation_case, 'bucket-replication.') || startsWith(inputs.validation_case, 'site-replication.') || startsWith(inputs.validation_case, 'heal.')) }}
run: |
set -euo pipefail
python3 -m venv "${RUNNER_TEMP}/rustfs-validation-python"
"${RUNNER_TEMP}/rustfs-validation-python/bin/python" -m pip install \
-r auto-testing/scripts/validation_ilm/requirements.txt
printf '%s\n' "${RUNNER_TEMP}/rustfs-validation-python/bin" >> "${GITHUB_PATH}"
- name: Validate Heal tool build identity
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
env:
GH_TOKEN: ${{ github.token }}
TOOL_RUN_ID: ${{ inputs.validation_tools_run_id }}
TOOL_RUN_ATTEMPT: ${{ inputs.validation_tools_run_attempt }}
run: |
set -euo pipefail
[[ "${TOOL_RUN_ID}" =~ ^[1-9][0-9]*$ ]]
[[ "${TOOL_RUN_ATTEMPT}" =~ ^[1-9][0-9]*$ ]]
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${TOOL_RUN_ID}/attempts/${TOOL_RUN_ATTEMPT}" \
> "${VALIDATION_ROOT}/tool-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
value = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "tool-run.json").read_text())
if (value.get("conclusion") != "success" or value.get("status") != "completed"
or value.get("run_attempt") != int(os.environ["TOOL_RUN_ATTEMPT"])
or value.get("path") not in {".github/workflows/rustfs-functional-chain.yml", ".github/workflows/rustfs-validation-tools.yml"}):
raise ValueError("Heal tool run is not an exact successful validation build attempt")
PY
- name: Download pinned Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-tools-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify Heal metadata inspector
if: ${{ !inputs.validation_tools_only && startsWith(inputs.validation_case, 'heal.') }}
run: |
set -euo pipefail
TOOL_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-tools-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
python3 -B auto-testing/scripts/validation_heal/inspector.py \
--directory "${TOOL_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/tool-manifest.json"
printf 'VALIDATION_HEAL_TOOL_DIRECTORY=%s\n' "${TOOL_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Select exact-source race native evidence
id: race_native
if: ${{ inputs.validation_case == 'heal.race-unversioned-overwrite' || inputs.validation_case == 'heal.race-versioned-put' || inputs.validation_case == 'heal.race-version-delete' || inputs.validation_case == 'heal.race-markers-and-batch-delete' }}
env:
CANDIDATE_SHA: ${{ steps.candidate.outputs.source_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
required=false
if [ -f auto-testing/scripts/validation_heal/race_native.py ]; then
status=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" --case-id "${VALIDATION_CASE_ID}" || status=$?
if [ "${status}" -eq 0 ]; then
required=true
elif [ "${status}" -ne 2 ]; then
exit "${status}"
fi
fi
reviewed=false
case "${CANDIDATE_SHA}:${VALIDATION_CASE_ID}" in
6afdec9fe1b0b88007f2e5b445c19bf73d51d3ca:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-unversioned-overwrite | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-versioned-put | \
bd0671ba781d8ebf509f4f03533228dfb44496f1:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-unversioned-overwrite | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-versioned-put | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-version-delete | \
8380466f3a27c3c3ef4115084178a0ab99a78f32:heal.race-markers-and-batch-delete) reviewed=true ;;
esac
if [ "${reviewed}" = true ] && [ "${required}" != true ]; then
printf 'Race candidate requires its reviewed native collector before fleet execution\n' >&2
exit 1
fi
printf 'required=%s\n' "${required}" >> "${GITHUB_OUTPUT}"
- name: Download pinned native storage evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || steps.race_native.outputs.required == 'true' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C07 native source and exact test evidence
if: ${{ inputs.validation_case == 'heal.conflicting-subquorum-authority' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/authority.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/native-authority-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify C11 physical storage and exact final-rename barrier
if: ${{ inputs.validation_case == 'heal.bucket-incarnation-recreation' }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/incarnation_native.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${{ inputs.auto_testing_sha }}" \
> "${VALIDATION_ROOT}/native-incarnation-manifest.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Verify exact-source race native and runtime evidence
if: ${{ steps.race_native.outputs.required == 'true' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
VALIDATION_CASE_ID: ${{ inputs.validation_case }}
run: |
set -euo pipefail
NATIVE_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${NATIVE_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/race_native_evidence.py \
--directory "${NATIVE_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-manifest.json"
VALIDATION_HEAL_NATIVE_DIRECTORY="${NATIVE_DIRECTORY}" \
python3 -B auto-testing/scripts/validation_heal/race.py preflight \
--candidate "${VALIDATION_ROOT}/candidate.json" --testing-commit "${AUTO_TESTING_SHA}" \
--case-id "${VALIDATION_CASE_ID}" \
> "${VALIDATION_ROOT}/native-race-runtime-preflight.json"
printf 'VALIDATION_HEAL_NATIVE_DIRECTORY=%s\n' "${NATIVE_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned manager persistence and incarnation evidence
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-manager-native-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify exact manager native prerequisites before fleet mutation
if: ${{ inputs.validation_case == 'heal.checkpoint-and-mrf-corruption' || inputs.validation_case == 'heal.bucket-incarnation-recreation' || inputs.validation_case == 'heal.outcome-counters-and-error-truth' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
run: |
set -euo pipefail
MANAGER_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-manager-native-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${MANAGER_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/manager_fixture.py \
--directory "${MANAGER_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
--case-id "${VALIDATION_CASE}" > "${VALIDATION_ROOT}/manager-prerequisite-manifest.json"
printf 'VALIDATION_HEAL_MANAGER_DIRECTORY=%s\n' "${MANAGER_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Download pinned C10 shared-reference encoder
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ github.token }}
run-id: ${{ inputs.validation_tools_run_id }}
name: rustfs-validation-shared-${{ inputs.validation_tools_run_id }}-${{ inputs.validation_tools_run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
- name: Verify C10 exact-source codec and original tests
if: ${{ inputs.validation_case == 'heal.dangling-and-orphan-safety' }}
run: |
set -euo pipefail
SHARED_DIRECTORY="${RUNNER_TEMP}/rustfs-validation-shared-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cp "${VALIDATION_ROOT}/tool-run.json" "${SHARED_DIRECTORY}/tool-run.json"
python3 -B auto-testing/scripts/validation_heal/shared_fixture.py \
--directory "${SHARED_DIRECTORY}" --candidate "${VALIDATION_ROOT}/candidate.json" \
> "${VALIDATION_ROOT}/shared-fixture-manifest.json"
printf 'VALIDATION_HEAL_SHARED_DIRECTORY=%s\n' "${SHARED_DIRECTORY}" >> "${GITHUB_ENV}"
- name: Run selected canonical validation case
if: ${{ !inputs.validation_tools_only && inputs.validation_cleanup_run_id == '' && inputs.validation_case != 'environment.inspect' }}
env:
VALIDATION_CASE: ${{ inputs.validation_case }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
./auto-testing/scripts/run_validation_case.sh \
"${VALIDATION_CASE}" "${VALIDATION_ROOT}/candidate.json" \
"${AUTO_TESTING_SHA}" "${VALIDATION_ROOT}/run"
- name: Inspect current Heal fleet resources without mutation
if: ${{ !inputs.validation_tools_only && inputs.validation_case == 'environment.inspect' }}
env:
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/environment.py \
--candidate "${VALIDATION_ROOT}/candidate.json" \
--testing-commit "${AUTO_TESTING_SHA}" --evidence "${VALIDATION_ROOT}/environment"
- name: Audit and finalize retained validation environment
if: ${{ inputs.validation_cleanup_run_id != '' }}
env:
GH_TOKEN: ${{ github.token }}
FAILED_RUN_ID: ${{ inputs.validation_cleanup_run_id }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
gh run view "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--json status,conclusion,headBranch,workflowName > "${VALIDATION_ROOT}/failed-run.json"
python3 - <<'PY'
import json
import os
from pathlib import Path
run = json.loads((Path(os.environ["VALIDATION_ROOT"]) / "failed-run.json").read_text())
if (run["status"] != "completed" or run["conclusion"] not in {"failure", "cancelled"}
or run["workflowName"] != "RustFS Functional Chain" or run["headBranch"] != os.environ["GITHUB_REF_NAME"]):
raise ValueError("cleanup source must be a completed failed or cancelled Functional Chain run")
PY
gh run download "${FAILED_RUN_ID}" --repo "${GITHUB_REPOSITORY}" \
--pattern "rustfs-validation-${FAILED_RUN_ID}-*" --dir "${VALIDATION_ROOT}/failed-artifact"
python3 -B auto-testing/scripts/validation_heal/recover_environment.py \
--failed-run-id "${FAILED_RUN_ID}" --artifact "${VALIDATION_ROOT}/failed-artifact" \
--candidate "${VALIDATION_ROOT}/candidate.json" --evidence "${VALIDATION_ROOT}/recovery"
- name: Show validation report
if: ${{ always() }}
run: |
if [ -f "${VALIDATION_ROOT}/run/report.md" ]; then
cat "${VALIDATION_ROOT}/run/report.md" >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Upload validation evidence
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/rustfs-validation-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
validation-tools:
needs: [validation]
if: ${{ github.event_name == 'workflow_dispatch' && inputs.validation_package_sha == '' && inputs.validation_tools_only }}
uses: ./.github/workflows/rustfs-validation-tools.yml
with:
candidate_sha: ${{ needs.validation.outputs.source_sha }}
auto_testing_sha: ${{ inputs.auto_testing_sha }}
build_native_heal: ${{ inputs.validation_native_tools }}
build_shared_heal: ${{ inputs.validation_shared_tools }}
build_manager_native: ${{ inputs.validation_manager_tools }}
secrets: inherit
upgrade:
needs: [prepare]
if: ${{ always() && needs.prepare.result == 'success' }}
@@ -0,0 +1,244 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
name: RustFS Validation Package
on:
workflow_call:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
secrets:
R2_ACCESS_KEY_ID:
required: true
R2_SECRET_ACCESS_KEY:
required: true
R2_ENDPOINT:
required: true
R2_BUCKET:
required: true
workflow_dispatch:
inputs:
source_sha:
description: Exact RustFS commit SHA to build
required: true
type: string
permissions:
contents: read
concurrency:
group: rustfs-validation-package-${{ inputs.source_sha }}
cancel-in-progress: false
jobs:
resolve-source:
if: github.repository == 'rustfs/rustfs'
name: Resolve exact validation source
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.source.outputs.sha }}
source_ref: ${{ steps.source.outputs.ref }}
steps:
- name: Validate requested source
id: source
env:
REQUESTED_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
[[ "${REQUESTED_SHA}" =~ ^[0-9a-f]{40}$ ]]
source_directory="$(mktemp -d "${RUNNER_TEMP}/validation-source.XXXXXX")"
trap 'rm -rf "${source_directory}"' EXIT
git init -q "${source_directory}"
git -C "${source_directory}" fetch --no-tags --depth=1 https://github.com/rustfs/rustfs.git "${REQUESTED_SHA}"
test "$(git -C "${source_directory}" rev-parse FETCH_HEAD)" = "${REQUESTED_SHA}"
echo "sha=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
echo "ref=${REQUESTED_SHA}" >> "${GITHUB_OUTPUT}"
build:
needs: resolve-source
name: Build immutable validation DEB
runs-on: ubuntu-latest
timeout-minutes: 150
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
steps:
- name: Checkout exact source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
ref: ${{ needs.resolve-source.outputs.source_sha }}
- name: Confirm checked out source
env:
EXPECTED_SHA: ${{ needs.resolve-source.outputs.source_sha }}
run: test "$(git rev-parse HEAD)" = "${EXPECTED_SHA}"
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: validation-package-hosted-x86_64-unknown-linux-gnu
cache-save-if: "false"
install-build-packaging-tools: "false"
install-test-tools: "false"
- name: Build RustFS
run: cargo build --release --locked --target x86_64-unknown-linux-gnu -p rustfs --bins
- name: Build validation DEB
shell: bash
run: |
set -euo pipefail
command -v fakeroot >/dev/null 2>&1 || sudo apt-get install -y -qq fakeroot
package_dir="rustfs-validation-${VALIDATION_SOURCE_SHA}"
package_file="${RUNNER_TEMP}/validation-package/rustfs.deb"
mkdir -p "${RUNNER_TEMP}/validation-package"
binary="target/x86_64-unknown-linux-gnu/release/rustfs"
test -x "${binary}"
mkdir -p "${package_dir}/DEBIAN" "${package_dir}/usr/bin" "${package_dir}/etc/default" \
"${package_dir}/lib/systemd/system" "${package_dir}/usr/share/doc/rustfs"
cp "${binary}" "${package_dir}/usr/bin/rustfs"
chmod 755 "${package_dir}/usr/bin/rustfs"
cp deploy/build/rustfs.service "${package_dir}/lib/systemd/system/"
cat > "${package_dir}/etc/default/rustfs" <<'ENVEOF'
# RustFS Environment Configuration
# RUSTFS_ACCESS_KEY=<changeme>
# RUSTFS_SECRET_KEY=<changeme>
# RUSTFS_VOLUMES=""
# RUSTFS_ADDRESS=":9000"
ENVEOF
cat > "${package_dir}/DEBIAN/control" <<'CONTROLEOF'
Package: rustfs
Version: 0.0.0~validation
Section: utils
Priority: optional
Architecture: amd64
Depends: libc6 (>= 2.31)
Maintainer: RustFS Team <support@rustfs.com>
Description: RustFS immutable validation package
Candidate package for RustFS validation only.
CONTROLEOF
printf '%s\n' /etc/default/rustfs > "${package_dir}/DEBIAN/conffiles"
./scripts/release/package_service_scripts.sh after-install > "${package_dir}/DEBIAN/postinst"
./scripts/release/package_service_scripts.sh before-remove > "${package_dir}/DEBIAN/prerm"
./scripts/release/package_service_scripts.sh after-remove > "${package_dir}/DEBIAN/postrm"
chmod 755 "${package_dir}/DEBIAN/postinst" "${package_dir}/DEBIAN/prerm" "${package_dir}/DEBIAN/postrm"
cp LICENSE README.md "${package_dir}/usr/share/doc/rustfs/"
fakeroot dpkg-deb --build "${package_dir}" "${package_file}"
test -s "${package_file}"
- name: Upload opaque validation package
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package/rustfs.deb
if-no-files-found: error
publish:
needs: [resolve-source, build]
name: Publish immutable validation DEB
# Candidate-controlled build scripts never run on this fresh hosted runner.
runs-on: ubuntu-latest
timeout-minutes: 20
env:
VALIDATION_SOURCE_SHA: ${{ needs.resolve-source.outputs.source_sha }}
VALIDATION_SOURCE_REF: ${{ needs.resolve-source.outputs.source_ref }}
steps:
- name: Download opaque validation package
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: validation-package-bytes-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/validation-package-input
- name: Validate and stage opaque package bytes
shell: bash
run: |
set -euo pipefail
python3 -I - <<'PYTHON'
import os
from pathlib import Path
import shutil
import stat
root = Path(os.environ["RUNNER_TEMP"])
incoming = root / "validation-package-input"
if incoming.is_symlink() or not incoming.is_dir() or sorted(p.name for p in incoming.iterdir()) != ["rustfs.deb"]:
raise SystemExit("Validation artifact must contain exactly one rustfs.deb file")
fd = os.open(incoming / "rustfs.deb", os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as source:
info = os.fstat(source.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size == 0:
raise SystemExit("Validation package must be a nonempty regular file")
staged = root / "validation-publication"
staged.mkdir(mode=0o700)
with (staged / "rustfs.deb").open("xb") as target:
shutil.copyfileobj(source, target)
PYTHON
- name: Prepare trusted publication tools
id: tools
env:
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
AWS_CLI=aws
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
sudo apt-get update
sudo apt-get install -y -qq python3-venv
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/validation-awscli.XXXXXX")"
python3 -m venv "${AWS_CLI_DIR}"
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
fi
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
echo "aws_cli=${AWS_CLI}" >> "${GITHUB_OUTPUT}"
- name: Upload validation package to immutable R2 path
id: publish
env:
PACKAGE_FILE: ${{ runner.temp }}/validation-publication/rustfs.deb
AWS_CLI: ${{ steps.tools.outputs.aws_cli }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
AWS_EC2_METADATA_DISABLED: true
shell: bash
run: |
set -euo pipefail
if [[ -z "${R2_ACCESS_KEY_ID}" || -z "${R2_SECRET_ACCESS_KEY}" || -z "${R2_ENDPOINT}" || -z "${R2_BUCKET}" ]]; then
echo "Validation package publication requires complete R2 configuration" >&2
exit 1
fi
[[ "${VALIDATION_SOURCE_SHA}" =~ ^[0-9a-f]{40}$ ]]
test "${VALIDATION_SOURCE_REF}" = "${VALIDATION_SOURCE_SHA}"
test -s "${PACKAGE_FILE}" && test ! -L "${PACKAGE_FILE}"
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}" AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}" AWS_DEFAULT_REGION=auto
package_sha256="$(sha256sum "${PACKAGE_FILE}" | cut -d ' ' -f 1)"
key="artifacts/rustfs/packages/validation/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${package_sha256}/rustfs.deb"
url="https://dl.rustfs.com/${key}"
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${key}" --body "${PACKAGE_FILE}" \
--if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
downloaded_sha256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${url}" | sha256sum | cut -d ' ' -f 1)"
test "${downloaded_sha256}" = "${package_sha256}"
manifest="${RUNNER_TEMP}/validation-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --arg workflow_sha "${GITHUB_SHA}" --arg source_ref "${VALIDATION_SOURCE_REF}" \
--arg source_sha "${VALIDATION_SOURCE_SHA}" --arg channel validation \
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
--arg package_url "${url}" --arg package_sha256 "${package_sha256}" \
'{schema: 3, workflow_sha: $workflow_sha, source_ref: $source_ref, source_sha: $source_sha, channel: $channel, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' > "${manifest}"
echo "candidate_file=${manifest}" >> "${GITHUB_OUTPUT}"
- name: Upload validation candidate manifest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: validation-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error
@@ -0,0 +1,343 @@
# Build source-bound helpers separately so live validation needs only artifacts.
name: RustFS Validation Tools
on:
workflow_call:
inputs:
candidate_sha:
type: string
required: true
auto_testing_sha:
type: string
required: true
build_native_heal:
type: boolean
required: false
default: false
build_shared_heal:
type: boolean
required: false
default: false
build_manager_native:
type: boolean
required: false
default: false
secrets:
PF_TESTING_GH_TOKEN:
required: true
workflow_dispatch:
inputs:
candidate_sha:
description: Exact RustFS source SHA from the validation candidate manifest
type: string
required: true
auto_testing_sha:
description: Exact auto-testing SHA containing the Heal metadata inspector
type: string
required: true
build_native_heal:
description: Also compile and run separate exact-source native Heal regressions
type: boolean
required: false
default: false
build_shared_heal:
description: Build the controlled C10 shared-reference fixture encoder
type: boolean
required: false
default: false
build_manager_native:
description: Build and run exact candidate checkpoint/MRF/incarnation regressions
type: boolean
required: false
default: false
permissions:
contents: read
jobs:
manager-native-regressions:
if: ${{ inputs.build_manager_native }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable manager native inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact manager candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact manager collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install manager native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build exact unmodified candidate manager test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/manager_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out manager-tools
- name: Run exact manager regressions independently
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/manager_native.py run \
--directory manager-tools --out manager-evidence
- name: Upload manager executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-manager-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
manager-tools/heal-manager-native-tests
manager-tools/*.json
manager-tools/*.jsonl
manager-tools/*.stderr
manager-tools/*.log
manager-evidence/
if-no-files-found: warn
native-heal-regressions:
if: ${{ inputs.build_native_heal }}
runs-on: ubuntu-22.04
timeout-minutes: 180
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable native source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact native candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact native collector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Install native build dependencies
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y clang lld cmake pkg-config libssl-dev protobuf-compiler
df -h .
- name: Build and enumerate the unmodified candidate test executable
run: |
set -euo pipefail
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
use_race_native=false
if [[ -f auto-testing/scripts/validation_heal/race_native.py ]]; then
support_rc=0
python3 -B auto-testing/scripts/validation_heal/race_native.py supports \
--commit "${CANDIDATE_SHA}" || support_rc=$?
case "${support_rc}" in
0) use_race_native=true ;;
2) ;; # The original exact-source collector handles legacy candidates.
*) exit "${support_rc}" ;;
esac
fi
if [[ "${use_race_native}" == true ]]; then
python3 -B auto-testing/scripts/validation_heal/race_native.py build \
--source candidate --commit "${CANDIDATE_SHA}" --testing-commit "${AUTO_TESTING_SHA}" --out native-tools
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py build \
--source candidate --commit "${CANDIDATE_SHA}" --out native-tools
fi
- name: Run exact native regressions separately from fleet validation
run: |
set -euo pipefail
if [[ -f native-tools/race-native.json ]]; then
test ! -e native-tools/native-regressions.json
python3 -B auto-testing/scripts/validation_heal/race_native.py run \
--directory native-tools --out native-evidence
else
python3 -B auto-testing/scripts/validation_heal/native_regressions.py run \
--directory native-tools --out native-evidence
fi
- name: Run existing F07 directory fsync failure regressions
if: ${{ inputs.candidate_sha == '1aa9be87d18cf941e5020f19e8622c26ba4b10ac' }}
run: |
set -euo pipefail
# Older immutable harness pins retain their original native suite;
# absence of this additive collector must never fabricate its proof.
if [[ ! -f auto-testing/scripts/validation_heal/persistence_native.py ]]; then
mkdir native-persistence-evidence
echo '{"complete":false,"reason":"pinned harness predates F07 directory-fsync collector","requiredNativeCoverageComplete":false,"canonicalCasePassClaimed":false}' \
> native-persistence-evidence/not-available.json
exit 0
fi
python3 -B auto-testing/scripts/validation_heal/persistence_native.py run \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
python3 -B auto-testing/scripts/validation_heal/persistence_native.py verify \
--directory native-tools --native-evidence native-evidence --out native-persistence-evidence \
--testing-commit "${AUTO_TESTING_SHA}"
- name: Upload native executable identities and original diagnostics
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-native-${{ github.run_id }}-${{ github.run_attempt }}
path: |
native-tools/ecstore-native-tests
native-tools/ecstore-race-native-tests
native-tools/*.json
native-tools/*.jsonl
native-tools/*.stderr
native-tools/*.log
native-evidence/
native-persistence-evidence/
if-no-files-found: warn
heal-metadata-inspector:
runs-on: ubuntu-22.04
timeout-minutes: 30
env:
CANDIDATE_SHA: ${{ inputs.candidate_sha }}
AUTO_TESTING_SHA: ${{ inputs.auto_testing_sha }}
steps:
- name: Validate immutable source inputs
run: |
set -euo pipefail
[[ "${CANDIDATE_SHA}" =~ ^[0-9a-f]{40}$ ]]
[[ "${AUTO_TESTING_SHA}" =~ ^[0-9a-f]{40}$ ]]
- name: Checkout exact candidate source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
ref: ${{ inputs.candidate_sha }}
path: candidate
persist-credentials: false
- name: Checkout exact inspector source
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
repository: rustfs/auto-testing
ref: ${{ inputs.auto_testing_sha }}
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
path: auto-testing
persist-credentials: false
- name: Build the read-only candidate metadata decoder
run: |
set -euo pipefail
test "$(git -C candidate rev-parse HEAD)" = "${CANDIDATE_SHA}"
test "$(git -C auto-testing rev-parse HEAD)" = "${AUTO_TESTING_SHA}"
df -h .
python3 auto-testing/scripts/validation_heal/prepare_heal_meta_inspector.py \
--source candidate --commit "${CANDIDATE_SHA}" --out inspector-build
# Seed the helper's separate graph from the candidate's frozen graph.
# Cargo adds the helper package; both lock identities are retained.
cp candidate/Cargo.lock inspector-build/Cargo.lock
cargo build --release --manifest-path inspector-build/Cargo.toml
python3 auto-testing/scripts/validation_heal/check_bitrot_tool.py \
--binary inspector-build/target/release/heal-meta-inspect --source candidate
mkdir validation-tools
cp inspector-build/target/release/heal-meta-inspect validation-tools/
cp inspector-build/Cargo.lock validation-tools/inspector-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("inspector-build/source.json").read_text())
binary = Path("validation-tools/heal-meta-inspect")
source.update(built=True, testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
inspectorLockSha256=hashlib.sha256(Path("validation-tools/inspector-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True),
target="x86_64-unknown-linux-gnu", runId=os.environ["GITHUB_RUN_ID"],
runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("validation-tools/heal-meta-inspect.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Build and test the controlled C10 metadata encoder
if: ${{ inputs.build_shared_heal }}
run: |
set -euo pipefail
python3 -B auto-testing/scripts/validation_heal/prepare_heal_shared_fixture.py \
--source candidate --commit "${CANDIDATE_SHA}" --out shared-build
cp candidate/Cargo.lock shared-build/Cargo.lock
export CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/inspector-build/target"
cargo build --release --manifest-path shared-build/Cargo.toml
mkdir shared-tools
if ! cargo test --release --manifest-path shared-build/Cargo.toml -- --test-threads=1 \
> shared-tools/shared-fixture-tests.log 2>&1; then
cat shared-tools/shared-fixture-tests.log
exit 1
fi
cp "${CARGO_TARGET_DIR}/release/heal-shared-fixture" shared-tools/
cp shared-build/Cargo.lock shared-tools/shared-Cargo.lock
python3 - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
source = json.loads(Path("shared-build/source.json").read_text())
source.update(schemaVersion=1, built=True, sourceUnmodified=True,
candidateSourceTree=subprocess.check_output(["git", "-C", "candidate", "rev-parse", "HEAD^{tree}"], text=True).strip(),
testingCommit=os.environ["AUTO_TESTING_SHA"],
binarySha256=hashlib.sha256(Path("shared-tools/heal-shared-fixture").read_bytes()).hexdigest(),
sharedLockSha256=hashlib.sha256(Path("shared-tools/shared-Cargo.lock").read_bytes()).hexdigest(),
toolchain=subprocess.check_output(["rustc", "-vV"], text=True), target="x86_64-unknown-linux-gnu",
runId=os.environ["GITHUB_RUN_ID"], runAttempt=os.environ["GITHUB_RUN_ATTEMPT"])
Path("shared-tools/heal-shared-fixture.json").write_text(json.dumps(source, indent=2) + "\n")
PY
git -C candidate diff --exit-code
- name: Upload controlled C10 fixture encoder evidence
if: ${{ always() && inputs.build_shared_heal }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-shared-${{ github.run_id }}-${{ github.run_attempt }}
path: shared-tools/
if-no-files-found: warn
- name: Upload immutable validation tools
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: rustfs-validation-tools-${{ github.run_id }}-${{ github.run_attempt }}
path: validation-tools/
if-no-files-found: error
@@ -44,7 +44,7 @@ permissions:
jobs:
forced-failure:
name: Forced failure
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Fail on purpose
+1 -1
View File
@@ -27,7 +27,7 @@ on:
jobs:
stale:
if: github.repository == 'rustfs/rustfs'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
+1 -1
View File
@@ -15,7 +15,7 @@ concurrency:
jobs:
update:
if: github.repository == 'rustfs/rustfs'
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
targets-live:
if: github.repository == 'rustfs/rustfs'
name: PostgreSQL, MySQL, AMQP, and NATS
runs-on: sm-standard-2
runs-on: ubuntu-latest
timeout-minutes: 90
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -60,7 +60,7 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: targets-live-lane
cache-shared-key: targets-live-lane-hosted
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
install-build-packaging-tools: 'false'
install-test-tools: 'false'
+130
View File
@@ -19,8 +19,21 @@ on:
branches: [ main ]
paths:
- "crates/ecstore/src/disk/**"
- "crates/ecstore/src/layout/**"
- "crates/ecstore/src/store/init_format.rs"
- "crates/ecstore/src/set_disk/ops/locking.rs"
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
- "crates/utils/src/path.rs"
- "rustfs/build.rs"
- "rustfs/Cargo.toml"
- "rustfs/src/main.rs"
- "rustfs/src/server/runtime.rs"
- "rustfs/src/startup_*.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/actions/setup/**"
@@ -29,8 +42,21 @@ on:
branches: [ main ]
paths:
- "crates/ecstore/src/disk/**"
- "crates/ecstore/src/layout/**"
- "crates/ecstore/src/store/init_format.rs"
- "crates/ecstore/src/set_disk/ops/locking.rs"
- "crates/ecstore/src/object_api/write_commit_context.rs"
- "crates/ecstore/src/bucket/utils.rs"
- "crates/ecstore/Cargo.toml"
- "crates/utils/src/dunce.rs"
- "crates/utils/src/os/**"
- "crates/utils/Cargo.toml"
- "crates/utils/src/path.rs"
- "rustfs/build.rs"
- "rustfs/Cargo.toml"
- "rustfs/src/main.rs"
- "rustfs/src/server/runtime.rs"
- "rustfs/src/startup_*.rs"
- "Cargo.toml"
- "Cargo.lock"
- ".github/actions/setup/**"
@@ -77,14 +103,118 @@ jobs:
shell: pwsh
run: cargo test -p rustfs-ecstore --lib rename_all_ -- --nocapture
- name: Test Windows path conversion
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
- name: Test Windows volume and disk-space probes
shell: pwsh
run: |
# Map a drive letter to the temp drive's administrative share so the
# remote-volume probe also covers mapped network drives.
$drive = (Split-Path -Qualifier ([System.IO.Path]::GetTempPath())).TrimEnd(':')
net use R: "\\localhost\$drive`$" /persistent:no
if ($LASTEXITCODE -eq 0) {
$env:RUSTFS_TEST_WINDOWS_MAPPED_DRIVE = 'R:'
} else {
Write-Output "::warning::could not map R: to \\localhost\$drive`$; mapped-drive test will skip"
}
cargo test -p rustfs-utils --features os --lib os:: -- --nocapture
$status = $LASTEXITCODE
if ($env:RUSTFS_TEST_WINDOWS_MAPPED_DRIVE) { net use R: /delete /y | Out-Null }
exit $status
- name: Test Windows handle guards
shell: pwsh
run: cargo test -p rustfs-ecstore --lib windows_ -- --nocapture
- name: Test endpoint path parsing
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib layout::endpoint:: -- --nocapture"
- name: Test startup temporary-directory cleanup
shell: pwsh
run: cargo test -p rustfs-ecstore --lib cleanup_tmp_on_startup_ -- --nocapture
- name: Test object key Windows admission
shell: pwsh
run: "cargo test -p rustfs-ecstore --lib bucket::utils:: -- --nocapture"
- name: Test Windows file-name rules
shell: pwsh
run: "cargo test -p rustfs-utils --no-default-features --features path --lib dunce:: -- --nocapture"
- name: Test fresh format publication
shell: pwsh
run: cargo test -p rustfs-ecstore --lib fresh_format_load_initializes_all_disks -- --nocapture
# The native Windows main thread only gets the 1 MiB PE stack reserve, while
# Linux and macOS hand out 8 MiB. Startup runs the whole root future on the
# thread that calls `block_on`, so a debug server once overflowed before it
# listened on Windows only. Start a real server to keep that covered.
startup-smoke:
name: Startup Smoke
runs-on: windows-latest
timeout-minutes: 90
steps:
- name: Checkout repository
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: build-x86_64-pc-windows-msvc
cache-save-if: 'false'
install-build-packaging-tools: 'false'
install-test-tools: 'false'
- name: Build debug server binary
shell: pwsh
run: cargo build --locked -p rustfs --bin rustfs
- name: Print version
shell: pwsh
run: |
& .\target\debug\rustfs.exe --version
if ($LASTEXITCODE -ne 0) { throw "rustfs --version exited with $LASTEXITCODE" }
- name: Start server and wait for readiness
shell: pwsh
env:
RUSTFS_ACCESS_KEY: rustfsadmin
RUSTFS_SECRET_KEY: rustfsadmin
run: |
$volume = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke'
$stdout = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke.out.log'
$stderr = Join-Path $env:RUNNER_TEMP 'rustfs-startup-smoke.err.log'
New-Item -ItemType Directory -Force -Path $volume | Out-Null
$server = Start-Process -FilePath .\target\debug\rustfs.exe `
-ArgumentList @('--address', '127.0.0.1:9000', "`"$volume`"") `
-RedirectStandardOutput $stdout -RedirectStandardError $stderr `
-NoNewWindow -PassThru
# Cache the process handle now; otherwise ExitCode reads back empty.
$null = $server.Handle
$ready = $false
try {
for ($i = 0; $i -lt 180; $i++) {
if ($server.HasExited) { break }
try {
$response = Invoke-WebRequest -Uri 'http://127.0.0.1:9000/health/ready' -UseBasicParsing -NoProxy -TimeoutSec 2
if ($response.StatusCode -eq 200) { $ready = $true; break }
} catch {}
Start-Sleep -Seconds 1
}
} finally {
if (-not $server.HasExited) { Stop-Process -Id $server.Id -Force }
$server.WaitForExit()
Write-Host '--- server stdout (tail) ---'
Get-Content $stdout -Tail 100 -ErrorAction SilentlyContinue
Write-Host '--- server stderr (tail) ---'
Get-Content $stderr -Tail 100 -ErrorAction SilentlyContinue
}
if (-not $ready) {
throw ("rustfs did not report ready on /health/ready (exit code: {0})" -f $server.ExitCode)
}
+37
View File
@@ -3,6 +3,9 @@
This file contains repository-wide rules. Use the nearest subdirectory
`AGENTS.md` for path-specific invariants.
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
multi-pool, supports ILM tiering/lifecycle.
## Precedence
1. System/developer instructions.
@@ -38,6 +41,27 @@ rules. A skill cannot expand the user's requested scope or grant authorization.
pending. Before requesting missing approval, prepare the concrete result that
is already authorized; retain explicit merge and release gates.
- Keep individual tool outputs concise. Save large outputs to task-owned logs
and retrieve only the relevant ranges.
## Common Commands
```bash
cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
```
> **Docker build note**: `buildx build` without `--load` keeps the image in
> the buildx cache only — `docker run` will use a stale local image. The
> Makefile already includes `--load`; if you suspect a stale binary, add
> `--no-cache` to the `buildx build` invocation inside
> `.config/make/build-docker.mak`.
## Task-Specific Guidance
Read only the reference needed for the current task, once per unchanged context:
@@ -65,6 +89,19 @@ or agent scratch notes. Durable architecture belongs under `docs/architecture/`,
operations under `docs/operations/`, and testing references under
`docs/testing/`. `scripts/check_no_planning_docs.sh` enforces this boundary.
## Further References
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
policy: [docs/testing/README.md](docs/testing/README.md)
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
## Verification
Select checks from the final task-owned diff. Scoped `AGENTS.md` files may add a
+3
View File
@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
### Fixed
- **Replication status writebacks no longer invalidate scanner cycles**: every replication status writeback (`persist_replication_state_if_current` in `Update` mode, including the heals the scanner queues during its own walk) marked the bucket dirty on the namespace path, advanced the dirty-usage generation and woke the scanner, so a site-replication resync or any steadily replicated bucket ended every cycle `superseded`, the authoritative usage baseline was never certified, and the console reported usage freshness as stale for as long as replication ran (#8375). Those writebacks are now status class: they do not touch the dirty-usage generation, the activity snapshot, the dirty-usage wake or the durable dirty journal, and only the process-local producer coverage is recorded. They need no rescan queue, because a deployment with an active replication rule already walks every bucket on every cycle and that walk refreshes `replication_stats`. Tier-expiration cleanup, which removes data, stays on the namespace path. No persisted format, protocol, admin API or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
- **A bucket written during a scanner walk no longer supersedes the whole cycle**: the post-walk cluster activity proof required the entire activity snapshot and the dirty-usage generation to be unchanged, so a single-node deployment with one bucket written every few seconds never reached a `Complete` cycle, `usage_snapshot_converged` never became true and the authoritative usage baseline was never certified (#8494). Dirty usage is now certified per bucket: a structurally complete walk that the guardrail did not veto is `Complete`, buckets whose dirty generation still matches the start snapshot are cleared, and buckets written during the walk stay dirty and are published as the pending set, so `usage_snapshot_converged` means "no bucket pending" and the next cycle is scoped to the pending buckets. Such a cycle writes the authoritative usage object; the observed object is now selected by an explicit observational mark that vetoed cycles carry. The guardrail itself vetoes only on the fields that change which namespace the walk described (peer set, process instance, protocol version, topology, maintenance generation, data movement, movement generation, publication blocked) and reports the field and host in the scanner log and in the new `usage_freshness` fields `guardrail_veto_total`, `last_guardrail_veto_unix_secs` and `last_guardrail_veto_reason`. In a distributed deployment a remote peer whose dirty usage moved during the walk still supersedes the cycle until scoped acknowledgements can name its buckets. `.usage.v2.json` gains the additive, default-when-absent fields `usage_snapshot_pending_buckets`, `usage_snapshot_pending_bucket_count` and `usage_snapshot_observational`; older readers ignore them and treat the snapshot as an authoritative but unconverged publication. Quota is unchanged: an uncertified bucket keeps its last usage value as the floor and live writes do not advance it. No protocol or configuration changes. Design: `docs/architecture/scanner-per-bucket-convergence.md`.
- **SSE-C TLS requirement now covers copy-source keys**: with `RUSTFS_SSE_C_REQUIRE_TLS=true`, a `CopyObject` or `UploadPartCopy` that carried only the `x-amz-copy-source-server-side-encryption-customer-*` headers (an SSE-C source copied to a destination that is not SSE-C) was accepted on a plaintext transport and was not counted in `rustfs_ssec_plaintext_requests_total`, although those headers carry the source object's customer key. They now count as SSE-C headers: such requests are counted, and refused with `400 InvalidRequest` when the switch is on. Requests over TLS are unaffected.
- **SSE-KMS encryption context could replace the location entry**: data keys are wrapped under an encryption-context entry, keyed by the bucket name, that ties them to the object's location. A client `x-amz-server-side-encryption-context` entry under the same key replaced it, and because the client context is stored with the object, the replacement also applied on every read. SSE-KMS writes now refuse such a context with `400 InvalidArgument`; other keys, including the names of other buckets, are unaffected. Objects stored with such an entry by an earlier release remain readable. See `docs/operations/kms-backend-security.md`.
- **Two admin handlers reported a missing policy or user as `500 InternalError`**: `GET /rustfs/admin/v3/info-canned-policy` and `DELETE /rustfs/admin/v3/remove-user` wrapped every IAM error as an internal fault instead of classifying it, so a lookup for something that does not exist answered `500 InternalError` where the rest of the admin API answers `404 NoSuchResource` — `GET /rustfs/admin/v3/user-info` already did, and `iam_error_to_s3_error` already mapped `NoSuchPolicy`, `NoSuchUser`, `NoSuchServiceAccount` and `NoSuchTempAccount` to `NoSuchResource`. A client could not tell "does not exist" from a real server failure, and a controller that checks existence before creating (a Kubernetes operator reconciling policies or users) could never create the object: the missing-policy lookup failed with a `500` and it retried forever. Both handlers now map their IAM errors through the shared `iam_error_to_s3_error` helper, so a missing policy or user is `404 NoSuchResource` and the `error` is preserved as the S3 error's source; the three IAM calls in the removal path (temporary-user lookup, service-account lookup, delete) are all reclassified, and every other IAM error stays `500 InternalError`. Fixes #8117.
@@ -85,6 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
### Changed
- **Scanner bucket walkers are no longer tied to the disk count**: a new scanner key `walkers_per_disk` (`RUSTFS_SCANNER_WALKERS_PER_DISK`, `1` through `16`, default `1`) spawns that many bucket walkers per local disk of an erasure set, so a single-disk deployment can walk several buckets at once instead of one bucket, one object at a time. The walkers of one disk share its scanner admission, so a peer still sees one scan session per physical disk, and remote disks keep one walker. The walker count is what `max_concurrent_disk_scans` caps and the disk count stays the floor; the `disk_concurrency_budget` log line now reports `walkers_per_disk`, `walkers`, and the `reason` for the effective limit. The default keeps today's I/O profile because a local disk cannot yet report whether it is rotational; the design intends a higher default on flash once it can (`docs/architecture/scanner-per-bucket-convergence.md`). As groundwork for pacing, the raw-enumeration page writer inserts into its building page in place and commits pages without copying them, and a regression test pins the cost of recording 20,000 entries in one prefix so the quadratic bookkeeping removed by [#8114](https://github.com/rustfs/rustfs/pull/8114) (rustfs#8113) cannot return. No persisted format, protocol or admin API changes beyond the additional config key, which older releases preserve and ignore.
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
-46
View File
@@ -1,46 +0,0 @@
# RustFS — CLAUDE.md
S3-compatible object store in Rust, derived from MinIO. Erasure-coded,
multi-pool, supports ILM tiering/lifecycle.
Agent rules (change style, verification, PR conventions, scoped per-crate
guidance) live in [AGENTS.md](AGENTS.md) — follow it. This file only adds
what Claude Code needs on top: commands and pointers.
## Commands
```bash
cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
```
> **Docker build note**: `buildx build` without `--load` keeps the image in
> the buildx cache only — `docker run` will use a stale local image. The
> Makefile already includes `--load`; if you suspect a stale binary, add
> `--no-cache` to the `buildx build` invocation inside
> `.config/make/build-docker.mak`.
## Where to look (do not duplicate here)
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
- Crate membership: `Cargo.toml` `[workspace].members`
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
- CI workflow steps: `.github/workflows/`; event, timeout, and required-status
matrix: [docs/testing/ci-gates.md](docs/testing/ci-gates.md)
- Test-layer taxonomy, per-layer entry commands, serial/nextest rules, flake
policy: [docs/testing/README.md](docs/testing/README.md)
- Tier/ILM transition debugging (xl.meta inspection, versionId tracing):
[docs/operations/tier-ilm-debugging.md](docs/operations/tier-ilm-debugging.md)
## Domain conventions worth knowing up front
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
"Cross-Cutting Storage Invariants" — read them before touching metadata or
tiering code.
Generated
+61 -61
View File
@@ -30,7 +30,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
dependencies = [
"crypto-common 0.2.2",
"inout 0.2.2",
"inout 0.2.4",
]
[[package]]
@@ -328,9 +328,9 @@ dependencies = [
[[package]]
name = "archive-trait"
version = "0.0.14"
version = "0.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6080ea14ccf9019d7ce572c319e581c20a805c9bdc6dbfb9b988da003cbd1a3"
checksum = "999bf88cc483664cab77700ca2fdfb0415a73412abe284ef5bc2319d00938e00"
dependencies = [
"cap-std",
"thiserror 2.0.21",
@@ -371,9 +371,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "arrow"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c14b3d39f306bc28fd639d59f06e17a0f377d0021e1b7e9054e4d6fedc98774"
checksum = "4c25317ebddb8f2e6f006759336c95e5ded847a446f3524c27853bfe7d77a69b"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -392,9 +392,9 @@ dependencies = [
[[package]]
name = "arrow-arith"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2961626677665b2195eb59242af4c7befe7b8737ca2050295389362380104e"
checksum = "2ab555c0ffe9cb817041c0cee80baa9663a22225d0a035693579771f2693cadc"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -406,9 +406,9 @@ dependencies = [
[[package]]
name = "arrow-array"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e5f6adeffdf587d7a31db5d2266189624b526730cd3627f9ff9fedae97ad584"
checksum = "2dae1d91737421e92c6dd7c7f9d0f2d25198fd6fc539738b628f4dad10adf533"
dependencies = [
"ahash",
"arrow-buffer",
@@ -425,9 +425,9 @@ dependencies = [
[[package]]
name = "arrow-buffer"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "097d193003ce7995d5d087089069ec2a6e0187faf5a6f8c9f38af2645d987182"
checksum = "1fe9cd0064f2d7cf8ecfaf77cd70e67a980bc71aea92b63102bd37a7d3379a09"
dependencies = [
"bytes",
"half",
@@ -437,9 +437,9 @@ dependencies = [
[[package]]
name = "arrow-cast"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "635c9c635668ad26adf76cce8fb276c4be7cf06e63bd516de7da514f9680ee53"
checksum = "ee510bc252781d700400979a96bd1eadebeae23901132bbabd92118da1b376c5"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -459,9 +459,9 @@ dependencies = [
[[package]]
name = "arrow-csv"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c2ebf8d631e79b02c16cf5ae860561272c26024ec88fce389a56aaddd558e86"
checksum = "ace83d5935421b950fea3a0a3f9972c0837c297ef98ade57127a5661b681112c"
dependencies = [
"arrow-array",
"arrow-cast",
@@ -474,9 +474,9 @@ dependencies = [
[[package]]
name = "arrow-data"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ba2f832eaeca24b8f26143dba750e42ee4ab51cf7d65e701ca9607cfda9f358"
checksum = "54f128c73bb7fe9f7137218f910edd5b436f390e0f040b1956f5028390787897"
dependencies = [
"arrow-buffer",
"arrow-schema",
@@ -487,9 +487,9 @@ dependencies = [
[[package]]
name = "arrow-ipc"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dcc41681ea80f521df14c36725b74d4c60702c47f0793af2be469c04527e2599"
checksum = "8e244f39b82fa7a823e8b5984a0bd988234f9c92c1df79378fb85090e6e4c821"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -503,9 +503,9 @@ dependencies = [
[[package]]
name = "arrow-json"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2f57d7a81969f24ccf80809587b76c09897e6f829d2d65a5976bfb3218851f1"
checksum = "727b21ebf8894dcd9504d6ab66bffdac338391e1f85cde594526a0210299ea40"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -528,9 +528,9 @@ dependencies = [
[[package]]
name = "arrow-ord"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c900759f3bd8354fd4196bc4403eee846894dc2adf66b4225472006a0bf18c5"
checksum = "70d376b9b883239ae0f9963cdeeb59126ccaac08d60adf208a6ad3e2dd4048c3"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -541,9 +541,9 @@ dependencies = [
[[package]]
name = "arrow-row"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4c6425032e28266e3fc4ff680805e57e670d6ea92473043f3e65b7ed6ac79f2"
checksum = "48096811f92b5c3a0c59d33cfecd7d7d2ed037d81e54a27a42d232e7700e6de2"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -554,9 +554,9 @@ dependencies = [
[[package]]
name = "arrow-schema"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10fab8d4563491417ba801fab29d205104d20d4bdf37bda6cd1cf425cff598cd"
checksum = "37b6c8a6b12cf3869d299ce36ed8e1cd6fe9836264b68fb6c6538473a36124a1"
dependencies = [
"serde_core",
"serde_json",
@@ -564,9 +564,9 @@ dependencies = [
[[package]]
name = "arrow-select"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc58569193c2525915f3cc6310edba3792f1200f65d6e9ed330aa33e691493b8"
checksum = "e523e85fa9fb3181a391bf0edfa6a1ae0dc46df8cbf7f6b96034dcb1e2a7a04a"
dependencies = [
"ahash",
"arrow-array",
@@ -578,9 +578,9 @@ dependencies = [
[[package]]
name = "arrow-string"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e0813f3c35c1cfea65e14c20a953440f7783c088b7ad2d0db162ccdeefcec14"
checksum = "b931a905a0e4b8832d9f54fb369cf6e545f9dc03cb3d9611216a7bd2653df60a"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -2100,7 +2100,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2",
"inout 0.2.2",
"inout 0.2.4",
"zeroize",
]
@@ -2179,9 +2179,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.8"
version = "4.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
dependencies = [
"bytes",
"futures-core",
@@ -3608,18 +3608,18 @@ checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
[[package]]
name = "defmt"
version = "0.3.100"
version = "0.3.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
checksum = "4deef977660e373c458df605a913d4ab121db0ccd18d1ad6976dff7d37cddb53"
dependencies = [
"defmt 1.1.1",
"defmt 1.2.0",
]
[[package]]
name = "defmt"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
@@ -3627,21 +3627,21 @@ dependencies = [
[[package]]
name = "defmt-macros"
version = "1.1.1"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
"syn 2.0.119",
"syn 3.0.9",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
dependencies = [
"thiserror 2.0.21",
]
@@ -4371,7 +4371,7 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d8d873f78e1d3e1998ed2084da9147e3cd2bb34b67814cd49a3902b970eb124"
dependencies = [
"defmt 0.3.100",
"defmt 0.3.101",
"serde",
]
@@ -5707,9 +5707,9 @@ dependencies = [
[[package]]
name = "inout"
version = "0.2.2"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
checksum = "3ce983bb6fb399067d101fbfc9e74174e07e9f29a2bc679efefeb8c645819c3f"
dependencies = [
"block-padding 0.4.2",
"hybrid-array",
@@ -5852,7 +5852,7 @@ version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383"
dependencies = [
"defmt 1.1.1",
"defmt 1.2.0",
"jiff-core",
"jiff-static",
"jiff-tzdb-platform",
@@ -5869,7 +5869,7 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
dependencies = [
"defmt 1.1.1",
"defmt 1.2.0",
"log",
]
@@ -7681,9 +7681,9 @@ dependencies = [
[[package]]
name = "parquet"
version = "59.3.0"
version = "59.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff322f54b1a0f9288e614ed1f2d329b380af5476420db19f46ffb865e1163d73"
checksum = "8b3d2ef0fb136e9341570f219ecb168902e76079c6d81ad555f0a4840b1e08a3"
dependencies = [
"ahash",
"arrow-array",
@@ -9380,7 +9380,7 @@ dependencies = [
"ghash",
"hex-literal",
"hmac 0.13.0",
"inout 0.2.2",
"inout 0.2.4",
"keccak",
"log",
"md5",
@@ -9893,6 +9893,7 @@ dependencies = [
"rustfs-rio",
"rustfs-rio-v2",
"rustfs-s3-client",
"rustfs-s3-ops",
"rustfs-s3-types",
"rustfs-scanner-metrics",
"rustfs-storage-api",
@@ -10000,7 +10001,6 @@ dependencies = [
"rustfs-common",
"rustfs-concurrency",
"rustfs-config",
"rustfs-data-usage",
"rustfs-ecstore",
"rustfs-filemeta",
"rustfs-heal-contracts",
@@ -11180,9 +11180,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"aws-lc-rs",
"log",
@@ -12294,9 +12294,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "suppaftp"
version = "12.2.0"
version = "12.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a560e9f42cc5bd4a92295eca3c7140d51eb685d688087f547f27e6426fbe97da"
checksum = "2b58fcf921745cca6f0127c9f822069947da8c6dbeff52e9004750bcc448171f"
dependencies = [
"async-trait",
"chrono",
@@ -12470,9 +12470,9 @@ dependencies = [
[[package]]
name = "tar-codec"
version = "0.0.14"
version = "0.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ea42eb144d30fcbf32c26dfea8959175bb8335bfb7b18d20c6ed32edecf0551"
checksum = "b9dad615e50d181afea4bafea23dacb8e33ed52dde06503813570634b932d467"
dependencies = [
"archive-trait",
"tar-framing",
@@ -12482,9 +12482,9 @@ dependencies = [
[[package]]
name = "tar-framing"
version = "0.0.14"
version = "0.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "783223a7a6590be4227cb821e7ce80372575511f67c824921aba0752d8ad5573"
checksum = "563e8181b47c9cdcce569eae3ad431eec71543d6269dd60c7bd2350f70bcd233"
dependencies = [
"thiserror 2.0.21",
"tokio",
+5 -5
View File
@@ -155,7 +155,7 @@ futures-core = "0.3.34"
futures-lite = "2.6.1"
futures-util = "0.3.34"
pollster = "1.0.1"
pulsar = { default-features = false, version = "6.9.0" }
pulsar = { default-features = false, version = "=6.9.0" }
lapin = { default-features = false, version = "4.12.2" }
hyper = { version = "1.12.0" }
h2 = { version = "0.4.20" }
@@ -220,7 +220,7 @@ openidconnect = { default-features = false, version = "4.0" }
pbkdf2 = "0.13.0"
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
rsa = { version = "=0.10.0-rc.18" }
rustls = { default-features = false, version = "0.23.45" }
rustls = { default-features = false, version = "0.23.46" }
rustls-native-certs = "0.8"
rustls-pki-types = "1.15.1"
x509-parser = "0.18.1"
@@ -246,8 +246,8 @@ anyhow = "1.0.104"
arc-swap = "1.9.2"
rustfs-tokio-tar = { version = "0.7.1" }
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
tar-codec = "0.0.14"
tar-framing = "0.0.14"
tar-codec = "0.0.15"
tar-framing = "0.0.15"
atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.12.0" }
@@ -373,7 +373,7 @@ pyroscope = { version = "2.1.2" }
# FTP and SFTP
libunftp = { version = "0.23.1" }
unftp-core = "0.1.0"
suppaftp = { version = "12.2.0" }
suppaftp = { version = "12.2.1" }
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.64.1" }
russh-sftp = "3.0.1"
+2 -2
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM alpine:3.24.1 AS build
FROM alpine:3.24.2 AS build
ARG TARGETARCH
ARG RELEASE=latest
@@ -70,7 +70,7 @@ RUN set -eux; \
rm -rf rustfs.zip /build/.tmp || true
FROM alpine:3.24.1
FROM alpine:3.24.2
ARG RELEASE=latest
ARG BUILD_DATE
+3 -3
View File
@@ -141,7 +141,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.1
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
@@ -220,7 +220,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
./docker-buildx.sh --push
# Build specific version
./docker-buildx.sh --release 1.0.1 --push
./docker-buildx.sh --release 1.0.3 --push
# Build for custom registry
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -238,7 +238,7 @@ You can also use Make targets for convenience:
```bash
make docker-buildx # Build locally
make docker-buildx-push # Build and push
make docker-buildx-version VERSION=1.0.1 # Build specific version
make docker-buildx-version VERSION=1.0.3 # Build specific version
make help-docker # Show all Docker-related commands
```
+3 -3
View File
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.1
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.3
```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
./docker-buildx.sh --push
# 构建指定版本
./docker-buildx.sh --release 1.0.1 --push
./docker-buildx.sh --release 1.0.3 --push
# 构建并推送到自定义仓库
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
```bash
make docker-buildx # 本地构建
make docker-buildx-push # 构建并推送
make docker-buildx-version VERSION=1.0.1 # 构建指定版本
make docker-buildx-version VERSION=1.0.3 # 构建指定版本
make help-docker # 显示所有 Docker 相关命令
```
+9 -1
View File
@@ -119,11 +119,19 @@ when restoring `/rustfs/console` for a custom OEM build.
- `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_OBJECTS` (canonical)
- `RUSTFS_SCANNER_CYCLE_MAX_DIRECTORIES` (canonical)
- `RUSTFS_SCANNER_MAX_CONCURRENT_SET_SCANS` (canonical)
- `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS` (canonical)
- `RUSTFS_SCANNER_WALKERS_PER_DISK` (canonical)
Scanner cycle budget controls:
- When `RUSTFS_SCANNER_CYCLE_MAX_DURATION_SECS` is unset, the finite default is 1800 seconds (30 minutes), matching the scanner benchmark guidance.
- An explicit `0` preserves the compatibility behavior of an unbounded runtime budget. Object and directory budgets likewise remain unbounded when explicitly set to `0`.
Scanner walker controls:
- `RUSTFS_SCANNER_WALKERS_PER_DISK` sets how many bucket walkers each local disk of an erasure set runs; the default `1` walks one bucket per disk at a time, which is the right value for rotational media. On flash, `2` to `4` lets one disk walk several buckets concurrently, which matters most for single-disk deployments with many buckets, where the walk is otherwise fully serial. Values outside `1` through `16` are rejected by `admin config set`; an out-of-range or non-numeric environment value is logged as a scanner runtime config error (`state = "startup_apply_failed"`) and the scanner keeps the default. The walkers of one disk share that disk's scanner admission, so peers still see one scan session per physical disk, and `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS` still caps the total.
- The same value is available as the persistent `scanner.walkers_per_disk` key and is reported under `runtime_config.walkers_per_disk` in the admin scanner status; see [Scanner Runtime Controls](../../docs/operations/scanner-runtime-controls.md).
- A timed-out cycle cancels cooperative scanner work, then fences its leader epoch before releasing the lease. An uncooperative I/O operation is dropped after the bounded shutdown window; its cursor is not claimed to be durable and the scanner reports `recovery-required` when the worker cannot stop cooperatively, the cycle state was not confirmed durable, or epoch fencing cannot be persisted.
## Mmap read environment aliases
@@ -266,7 +274,7 @@ Drive timeout profile preset:
- `warn` (default): log warning and continue startup.
- `fail`: abort startup with an error.
RustFS production guidance remains direct-attached local POSIX filesystems. Network-mounted filesystems (for example `nfs`, `cifs`, `smb2`, and `fuse.*`) are treated as unsupported by this startup guard.
RustFS production guidance remains direct-attached local POSIX filesystems. Network-mounted filesystems (for example `nfs`, `cifs`, `smb2`, and `fuse.*`) are treated as unsupported by this startup guard. On Windows, endpoints on network drives (UNC shares such as `\\server\share` and mapped drive letters) are treated the same way, even though Windows reports the server's filesystem (usually `NTFS`) for them.
## 📄 License
+25 -3
View File
@@ -69,15 +69,17 @@ pub const ENV_CONSOLE_RATE_LIMIT_RPM: &str = "RUSTFS_CONSOLE_RATE_LIMIT_RPM";
pub const DEFAULT_CONSOLE_RATE_LIMIT_RPM: u32 = 100;
/// Set the console authentication timeout in seconds
/// Specifies how long a console authentication session remains valid
/// Bounds how long the console waits for a single authenticated HTTP request before it is
/// considered timed out. This is a request timeout, not the session lifetime: the Console
/// credential lifetime issued by the OIDC callback is `RUSTFS_CONSOLE_OIDC_SESSION_DURATION`.
/// Default: 3600 seconds (1 hour)
/// Minimum: 300 seconds (5 minutes)
/// Maximum: 86400 seconds (24 hours)
pub const ENV_CONSOLE_AUTH_TIMEOUT: &str = "RUSTFS_CONSOLE_AUTH_TIMEOUT";
/// Default console authentication timeout in seconds
/// This is the default timeout for console authentication sessions.
/// After this timeout, users need to re-authenticate to access the console.
/// This is the default timeout for console authentication requests.
/// After this timeout, a pending console request is abandoned.
/// Default value: 3600 seconds (1 hour)
/// Environment variable: RUSTFS_CONSOLE_AUTH_TIMEOUT
/// Command line argument: --console-auth-timeout
@@ -85,6 +87,26 @@ pub const ENV_CONSOLE_AUTH_TIMEOUT: &str = "RUSTFS_CONSOLE_AUTH_TIMEOUT";
/// Example: --console-auth-timeout 3600
pub const DEFAULT_CONSOLE_AUTH_TIMEOUT: u64 = 3600;
/// Console credential lifetime, in seconds, for Console credentials issued by the OIDC callback.
///
/// Process-level, Console-scoped setting: it applies to every OIDC provider and deliberately
/// lives outside the per-provider `RUSTFS_IDENTITY_OPENID_*` namespace, so it is never
/// suffix-scanned into a provider id and has no `identity_openid` KVS equivalent. The same value
/// also sets how long the Console logout token stays valid.
pub const ENV_CONSOLE_OIDC_SESSION_DURATION: &str = "RUSTFS_CONSOLE_OIDC_SESSION_DURATION";
/// Default Console credential lifetime in seconds, used when the environment variable is unset,
/// not a positive integer, or zero. Matches the value this path hard-coded before it was
/// parameterised.
pub const DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS: usize = 3600;
/// Upper bound on the Console credential lifetime in seconds (7 days).
///
/// Values above this are clamped with a warning. The bound keeps the moka logout cache's
/// `time_to_live` well under its 1000-year assertion, and stops an operator from minting an
/// effectively permanent Console STS credential on every node.
pub const CONSOLE_OIDC_SESSION_DURATION_MAX_SECS: usize = 604_800;
/// Toggle update check
/// It controls whether to check for newer versions of rustfs
/// Default value: true
+14
View File
@@ -60,6 +60,20 @@ pub const DEFAULT_HEALTH_LOCK_ONLINE_TIMEOUT_MS: u64 = 1000;
pub const ENV_STARTUP_READINESS_MAX_WAIT_SECS: &str = "RUSTFS_STARTUP_READINESS_MAX_WAIT_SECS";
pub const DEFAULT_STARTUP_READINESS_MAX_WAIT_SECS: u64 = 120;
/// Wait for peer re-admission before a distributed node publishes startup
/// readiness.
///
/// After this node reaches local readiness it asks the peers in each erasure
/// set it serves, over the internode RPC channel, whether they count its
/// drives as online. Startup readiness is published once a strict majority of
/// each set's peers do, or when the startup budget
/// ([`ENV_STARTUP_READINESS_MAX_WAIT_SECS`]) elapses, or when unreachable or
/// older peers make that majority impossible. The gate always fails open; it
/// never affects steady-state `/health/ready` or request handling. Disable it
/// to restore the local-view-only startup readiness.
pub const ENV_STARTUP_PEER_READMISSION_ENABLE: &str = "RUSTFS_STARTUP_PEER_READMISSION_ENABLE";
pub const DEFAULT_STARTUP_PEER_READMISSION_ENABLE: bool = true;
/// Enable minimal health payload mode for GET `/health*` responses.
/// When enabled, only `status` and `ready` fields are returned.
pub const ENV_HEALTH_MINIMAL_RESPONSE_ENABLE: &str = "RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE";
+1 -1
View File
@@ -242,7 +242,7 @@ const _: () = assert!(!DEFAULT_INTERNODE_OFFLINE_BYPASS);
/// read-after-write window can erode the read quorum and surface as a spurious
/// `InsufficientReadQuorum` (issue #2761). One bounded retry absorbs that transient failure.
/// **Only** idempotent reads use this; write/lock RPCs (`WriteAll`/`RenameData`/`Delete*`/`Lock`
/// /`UnLock`) must never auto-retry, to preserve quorum and idempotency semantics (see `CLAUDE.md`).
/// /`UnLock`) must never auto-retry, to preserve quorum and idempotency semantics (see `AGENTS.md`).
pub const ENV_INTERNODE_IDEMPOTENT_READ_RETRIES: &str = "RUSTFS_INTERNODE_IDEMPOTENT_READ_RETRIES";
pub const DEFAULT_INTERNODE_IDEMPOTENT_READ_RETRIES: usize = 1;
+54
View File
@@ -59,6 +59,15 @@ pub const SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "max_concurrent_set_scans";
/// Scanner config key capping concurrent scanner disk bucket walks per set.
pub const SCANNER_MAX_CONCURRENT_DISK_SCANS: &str = "max_concurrent_disk_scans";
/// Scanner config key setting how many bucket walkers each local disk runs.
pub const SCANNER_WALKERS_PER_DISK: &str = "walkers_per_disk";
/// Per-local-disk scanner I/O ceiling in operations per second.
pub const SCANNER_DISK_IO_BUDGET_IOPS: &str = "disk_io_budget_iops";
/// Per-local-disk scanner I/O ceiling in bytes per second.
pub const SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC: &str = "disk_io_budget_bytes_per_sec";
/// Scanner config key controlling how often object loops yield.
pub const SCANNER_YIELD_EVERY_N_OBJECTS: &str = "yield_every_n_objects";
@@ -86,6 +95,9 @@ pub const SCANNER_KEYS: &[&str] = &[
SCANNER_CACHE_SAVE_TIMEOUT,
SCANNER_MAX_CONCURRENT_SET_SCANS,
SCANNER_MAX_CONCURRENT_DISK_SCANS,
SCANNER_WALKERS_PER_DISK,
SCANNER_DISK_IO_BUDGET_IOPS,
SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC,
SCANNER_YIELD_EVERY_N_OBJECTS,
SCANNER_ALERT_EXCESS_VERSIONS,
SCANNER_ALERT_EXCESS_VERSION_SIZE,
@@ -214,6 +226,18 @@ pub const ENV_SCANNER_MAX_CONCURRENT_SET_SCANS: &str = "RUSTFS_SCANNER_MAX_CONCU
/// - Example: `export RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS=1`
pub const ENV_SCANNER_MAX_CONCURRENT_DISK_SCANS: &str = "RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS";
/// Environment variable that sets how many bucket walkers each local disk runs.
/// The walkers of one disk share its scanner admission, so a peer still sees one
/// scan session per physical disk. Remote disks keep one walker. The product of
/// local disks and this factor is what `RUSTFS_SCANNER_MAX_CONCURRENT_DISK_SCANS`
/// caps; the disk count stays the floor. Valid range is `1` through `16`.
/// - Example: `export RUSTFS_SCANNER_WALKERS_PER_DISK=4`
pub const ENV_SCANNER_WALKERS_PER_DISK: &str = "RUSTFS_SCANNER_WALKERS_PER_DISK";
pub const ENV_SCANNER_DISK_IO_BUDGET_IOPS: &str = "RUSTFS_SCANNER_DISK_IO_BUDGET_IOPS";
pub const ENV_SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC: &str = "RUSTFS_SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC";
/// Environment variable that controls how often scanner object loops yield to the async runtime.
/// A value of `0` disables this extra object-count yield.
/// - Example: `export RUSTFS_SCANNER_YIELD_EVERY_N_OBJECTS=32`
@@ -228,6 +252,36 @@ pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 4;
/// Default disk scan concurrency budget.
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
/// Default bucket walkers per local disk.
///
/// One walker per disk keeps today's I/O profile, which is what rotational
/// media needs. Flash is meant to default higher once a local disk can report
/// its media type; `rustfs_utils::os::Info::rotational` is never populated
/// today, so every deployment starts from one walker.
pub const DEFAULT_SCANNER_WALKERS_PER_DISK: usize = 1;
/// Upper bound for `walkers_per_disk`; a larger factor only multiplies
/// contention on one spindle or NVMe queue without more disks to spread it.
pub const MAX_SCANNER_WALKERS_PER_DISK: usize = 16;
/// Default spelling of both per-disk scanner I/O budget keys: resolve the
/// rate from the detected media. `off` removes the cap; a number is explicit.
pub const DEFAULT_SCANNER_DISK_IO_BUDGET: &str = "auto";
/// Rotational media budget, also used when media cannot be detected: about
/// half of a 7200 rpm spindle's random reads stay available to foreground
/// requests, and the byte ceiling sits well above the steady ~2 MB/s a paced
/// single-disk scan was measured at once admission landed (rustfs/rustfs#8377).
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_ROTATIONAL_IOPS: u64 = 100;
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_ROTATIONAL_BYTES_PER_SEC: u64 = 16 * 1024 * 1024;
/// Flash media budget: a few percent of a SATA SSD's random-read capacity,
/// enough to keep metadata walks far from saturating the device.
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_FLASH_IOPS: u64 = 4000;
pub const DEFAULT_SCANNER_DISK_IO_BUDGET_FLASH_BYTES_PER_SEC: u64 = 256 * 1024 * 1024;
/// Default object interval for cooperative scanner yields.
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
+75
View File
@@ -569,6 +569,25 @@ pub struct DataUsageInfo {
/// which last-known-good generation contributed to the view.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub usage_snapshot_set_states: Vec<DataUsageSnapshotSetState>,
/// Buckets whose dirty-usage generation moved while this complete
/// snapshot was being walked. Their `buckets_usage` values are the walk
/// result, which the cycle could not certify as current; the next cycle
/// rescans them. Sorted. Empty on a converged snapshot and on an
/// observational one. A bucket created during the walk may be listed
/// here without a `buckets_usage` entry.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub usage_snapshot_pending_buckets: Vec<String>,
/// Length of `usage_snapshot_pending_buckets`, carried for readers that
/// only need the count. The scanner writes both from one set.
#[serde(default, skip_serializing_if = "u64_is_zero")]
pub usage_snapshot_pending_bucket_count: u64,
/// Set by the scanner on a snapshot whose authoritative publication was
/// vetoed or could not be verified. It is routed to the observed object
/// and never replaces the authoritative snapshot. Absent on snapshots
/// written before per-bucket certification, which keep
/// `usage_snapshot_converged == Some(false)` as their observed marker.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub usage_snapshot_observational: bool,
/// An observational view may contain only the sets that completed this
/// cycle (or retained a compatible last-known-good cache).
#[serde(default)]
@@ -584,6 +603,10 @@ pub struct DataUsageInfo {
pub disk_usage_status: Vec<DiskUsageStatus>,
}
fn u64_is_zero(value: &u64) -> bool {
*value == 0
}
/// Stable identity fields changed by both coordinated scanner publication and
/// backward-compatible bucket namespace cleanup.
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -3011,6 +3034,58 @@ mod tests {
assert_eq!(current.usage_snapshot_converged, Some(false));
}
#[test]
fn pending_bucket_certification_fields_round_trip_and_stay_additive() {
let certified = DataUsageInfo {
last_update: Some(SystemTime::UNIX_EPOCH),
usage_snapshot_complete: true,
usage_snapshot_converged: Some(false),
usage_snapshot_pending_buckets: vec!["hot-a".to_string(), "hot-b".to_string()],
usage_snapshot_pending_bucket_count: 2,
..Default::default()
};
let encoded = rmp_serde::to_vec_named(&certified).expect("encode certified snapshot");
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode certified snapshot");
assert_eq!(decoded, certified);
assert!(!decoded.usage_snapshot_observational);
let legacy: LegacyUsageReader = rmp_serde::from_slice(&encoded).expect("legacy reader should ignore pending fields");
assert_eq!(legacy.buckets_count, 0);
let observed = DataUsageInfo {
usage_snapshot_observational: true,
..certified
};
let encoded = rmp_serde::to_vec_named(&observed).expect("encode observed snapshot");
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode observed snapshot");
assert!(decoded.usage_snapshot_observational);
// Older snapshots carry none of the fields and decode to the defaults;
// a converged snapshot writes none of them (named encoding carries
// field names, so a skipped field leaves no trace in the bytes).
let converged = DataUsageInfo {
last_update: Some(SystemTime::UNIX_EPOCH),
usage_snapshot_complete: true,
usage_snapshot_converged: Some(true),
..Default::default()
};
let encoded = rmp_serde::to_vec_named(&converged).expect("encode converged snapshot");
for key in [
"usage_snapshot_pending_buckets",
"usage_snapshot_pending_bucket_count",
"usage_snapshot_observational",
] {
assert!(
!encoded.windows(key.len()).any(|window| window == key.as_bytes()),
"{key} must be skipped when empty"
);
}
let decoded: DataUsageInfo = rmp_serde::from_slice(&encoded).expect("decode converged snapshot");
assert!(decoded.usage_snapshot_pending_buckets.is_empty());
assert_eq!(decoded.usage_snapshot_pending_bucket_count, 0);
assert!(!decoded.usage_snapshot_observational);
assert!(decoded.is_complete_bucket_usage_snapshot());
}
#[test]
fn convergence_marker_defaults_to_unknown_for_older_snapshots() {
let encoded = rmp_serde::to_vec_named(&DataUsageInfo {
+2
View File
@@ -146,4 +146,6 @@ zip.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
[dev-dependencies]
aws-smithy-http-client = { workspace = true, default-features = false, features = ["test-util"] }
tempfile.workspace = true
tokio = { workspace = true, features = ["test-util"] }
+1 -1
View File
@@ -22,7 +22,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **functional** | top-level `*_test.rs` | S3 data plane: `list_objects_*`, `copy_object_*`, `delete_objects_versioning`, `head_object_*`, `checksum_upload`, `compression`, `content_encoding`, `special_chars`, `leading_slash_key`, `create_bucket_region`, `quota`, `data_usage`, `snowball_auto_extract`, `mc_mirror_small_bucket`, `archive_download_integrity`, `version_id_regression`, `delete_marker_migration_semantics` |
| **object_lock** | [`src/object_lock/`](src/object_lock) | Retention / legal-hold / WORM semantics |
| **kms** | [`src/kms/`](src/kms) | SSE-S3 / SSE-KMS / SSE-C, local + Vault backends, multipart encryption. Own guide: [`src/kms/README.md`](src/kms/README.md) |
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `conditional_write_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
+16
View File
@@ -1037,6 +1037,22 @@ pub async fn awscurl_post_sts_form_urlencoded(
Ok(response)
}
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
/// Returns `(access_key, secret_key, session_token)` from an STS `AssumeRole` XML response.
pub fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String), Box<dyn std::error::Error + Send + Sync>> {
let access_key = extract_xml_tag(xml, "AccessKeyId").ok_or("missing AccessKeyId in AssumeRole response")?;
let secret_key = extract_xml_tag(xml, "SecretAccessKey").ok_or("missing SecretAccessKey in AssumeRole response")?;
let session_token = extract_xml_tag(xml, "SessionToken").ok_or("missing SessionToken in AssumeRole response")?;
Ok((access_key, secret_key, session_token))
}
/// Helper function for POST requests
pub async fn awscurl_post(
url: &str,
@@ -0,0 +1,381 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! S3 wire semantics for per-object conditional bulk deletion.
use crate::common::RustFSTestEnvironment;
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::operation::delete_objects::DeleteObjectsOutput;
use aws_sdk_s3::operation::put_object::PutObjectOutput;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, Delete, ObjectIdentifier, VersioningConfiguration};
use std::collections::BTreeSet;
use std::error::Error;
type TestResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;
type VersionSnapshot = BTreeSet<(String, bool)>;
async fn put(client: &Client, bucket: &str, key: &str, body: &'static [u8]) -> TestResult<PutObjectOutput> {
let result = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(body))
.send()
.await?;
assert!(result.e_tag().is_some(), "fixture PUT must provide an ETag for the following conditions");
Ok(result)
}
fn identifier(key: &str, etag: Option<&str>, version: Option<&str>) -> TestResult<ObjectIdentifier> {
Ok(ObjectIdentifier::builder()
.key(key)
.set_e_tag(etag.map(str::to_owned))
.set_version_id(version.map(str::to_owned))
.build()?)
}
async fn delete_batch(
client: &Client,
bucket: &str,
objects: Vec<ObjectIdentifier>,
quiet: bool,
) -> TestResult<DeleteObjectsOutput> {
Ok(client
.delete_objects()
.bucket(bucket)
.delete(Delete::builder().set_objects(Some(objects)).quiet(quiet).build()?)
.send()
.await?)
}
async fn assert_body(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult {
let result = client.get_object().bucket(bucket).key(key).send().await?;
assert_eq!(result.body.collect().await?.into_bytes().as_ref(), expected);
Ok(())
}
async fn assert_absent(client: &Client, bucket: &str, key: &str) {
let error = client
.head_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("deleted key must be absent");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("NotFound"));
}
async fn versions(client: &Client, bucket: &str, key: &str) -> TestResult<VersionSnapshot> {
let result = client.list_object_versions().bucket(bucket).prefix(key).send().await?;
assert!(!result.is_truncated().unwrap_or_default(), "focused version fixture fits a single page");
let mut snapshot = BTreeSet::new();
for version in result.versions().iter().filter(|version| version.key() == Some(key)) {
snapshot.insert((version.version_id().expect("version has an identity").to_owned(), false));
}
for marker in result.delete_markers().iter().filter(|marker| marker.key() == Some(key)) {
snapshot.insert((marker.version_id().expect("delete marker has an identity").to_owned(), true));
}
Ok(snapshot)
}
#[tokio::test]
async fn sdk_conditional_delete_objects_mixed_results_overwrites_duplicates_and_quiet() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "conditional-bulk-mixed";
client.create_bucket().bucket(bucket).send().await?;
let old = put(&client, bucket, "overwritten", b"generation A").await?;
let current = put(&client, bucket, "overwritten", b"generation B").await?;
let matched = put(&client, bucket, "matched", b"matching object").await?;
put(&client, bucket, "exists", b"existence object").await?;
put(&client, bucket, "unconditional", b"unconditional object").await?;
put(&client, bucket, "empty", b"empty condition must preserve this").await?;
put(&client, bucket, "empty-quoted", b"quoted empty condition must preserve this").await?;
let weak = put(&client, bucket, "weak", b"weak condition must preserve this").await?;
let weak_etag = format!("W/\"{}\"", weak.e_tag().expect("PUT supplies an ETag").trim_matches('"'));
assert_ne!(old.e_tag(), current.e_tag(), "overwrite fixture must have distinct ETags");
let matched_etag = matched.e_tag().expect("PUT supplies an ETag");
let result = delete_batch(
&client,
bucket,
vec![
identifier("matched", Some(matched_etag), None)?,
identifier("overwritten", old.e_tag(), None)?,
identifier("matched", Some("stale-etag"), None)?,
identifier("matched", Some(matched_etag), None)?,
identifier("unconditional", None, None)?,
identifier("exists", Some("*"), None)?,
identifier("missing", Some("*"), None)?,
identifier("empty", Some(""), None)?,
identifier("empty-quoted", Some("\"\""), None)?,
identifier("weak", Some(&weak_etag), None)?,
identifier("unconditional-missing", None, None)?,
identifier("missing-specific", Some("opaque-etag"), None)?,
],
false,
)
.await?;
assert_eq!(
result.deleted().iter().map(|object| object.key()).collect::<Vec<_>>(),
[
Some("matched"),
Some("matched"),
Some("unconditional"),
Some("exists"),
Some("unconditional-missing")
],
"duplicate successful inputs retain their separate response entries"
);
assert_eq!(
result
.errors()
.iter()
.map(|error| (error.key(), error.code()))
.collect::<Vec<_>>(),
[
(Some("overwritten"), Some("PreconditionFailed")),
(Some("matched"), Some("PreconditionFailed")),
(Some("missing"), Some("NoSuchKey")),
(Some("empty"), Some("InvalidArgument")),
(Some("empty-quoted"), Some("InvalidArgument")),
(Some("weak"), Some("InvalidArgument")),
(Some("missing-specific"), Some("NoSuchKey")),
]
);
assert_body(&client, bucket, "overwritten", b"generation B").await?;
assert_body(&client, bucket, "empty", b"empty condition must preserve this").await?;
assert_body(&client, bucket, "empty-quoted", b"quoted empty condition must preserve this").await?;
assert_body(&client, bucket, "weak", b"weak condition must preserve this").await?;
for key in ["matched", "unconditional", "exists"] {
assert_absent(&client, bucket, key).await;
}
let quiet = put(&client, bucket, "quiet-match", b"quiet matching object").await?;
put(&client, bucket, "quiet-rejected", b"quiet rejected object").await?;
let result = delete_batch(
&client,
bucket,
vec![
identifier("quiet-match", quiet.e_tag(), None)?,
identifier("quiet-match", quiet.e_tag(), None)?,
identifier("quiet-rejected", Some("stale-etag"), None)?,
identifier("missing", Some("*"), None)?,
],
true,
)
.await?;
assert!(result.deleted().is_empty(), "Quiet suppresses successful entries");
assert_eq!(
result
.errors()
.iter()
.map(|error| (error.key(), error.code()))
.collect::<Vec<_>>(),
[
(Some("quiet-rejected"), Some("PreconditionFailed")),
(Some("missing"), Some("NoSuchKey"))
]
);
assert_absent(&client, bucket, "quiet-match").await;
assert_body(&client, bucket, "quiet-rejected", b"quiet rejected object").await?;
Ok(())
}
#[tokio::test]
async fn sdk_conditional_delete_objects_versioning_current_markers_and_explicit_versions() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
for suspended in [false, true] {
let bucket = if suspended {
"conditional-bulk-suspended"
} else {
"conditional-bulk-enabled"
};
client.create_bucket().bucket(bucket).send().await?;
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let historical = put(&client, bucket, "history", b"historical payload").await?;
let invalid_historical = put(&client, bucket, "invalid", b"historical rejected payload").await?;
if suspended {
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await?;
}
let current = put(&client, bucket, "history", b"current payload").await?;
let invalid = put(&client, bucket, "invalid", b"current rejected payload").await?;
let invalid_before = versions(&client, bucket, "invalid").await?;
let historical_id = historical.version_id().expect("enabled fixture has a UUID version");
let invalid_historical_id = invalid_historical
.version_id()
.expect("enabled rejected fixture has a UUID version");
let result = delete_batch(
&client,
bucket,
vec![
identifier("history", current.e_tag(), None)?,
identifier("history", None, Some(historical_id))?,
identifier("history", current.e_tag(), None)?,
identifier("invalid", invalid.e_tag(), Some(invalid_historical_id))?,
identifier("invalid", Some("*"), Some("null"))?,
identifier("invalid", Some("stale-etag"), None)?,
],
false,
)
.await?;
assert_eq!(result.deleted().len(), 3, "both current input entries and the historical delete succeed");
assert_eq!(
result
.errors()
.iter()
.map(|error| (error.key(), error.code()))
.collect::<Vec<_>>(),
[
(Some("invalid"), Some("InvalidRequest")),
(Some("invalid"), Some("InvalidRequest")),
(Some("invalid"), Some("PreconditionFailed")),
]
);
assert_eq!(result.errors()[0].version_id(), Some(invalid_historical_id));
assert_eq!(result.errors()[1].version_id(), Some("null"));
assert_eq!(versions(&client, bucket, "invalid").await?, invalid_before);
assert_body(&client, bucket, "invalid", b"current rejected payload").await?;
let marker = result.deleted()[0]
.delete_marker_version_id()
.expect("current conditional delete returns a marker");
assert_eq!(result.deleted()[0].delete_marker(), Some(true));
assert_eq!(result.deleted()[2].delete_marker_version_id(), Some(marker));
assert_eq!(result.deleted()[1].version_id(), Some(historical_id));
if suspended {
assert_eq!(marker, "null", "a suspended current delete returns the created null marker identity");
}
let history_after = versions(&client, bucket, "history").await?;
assert_eq!(
history_after.iter().filter(|(_, marker)| *marker).count(),
1,
"duplicate actions create one marker"
);
assert!(
!history_after.contains(&(historical_id.to_string(), false)),
"distinct historical target is removed"
);
if suspended {
assert_eq!(history_after, BTreeSet::from([("null".to_string(), true)]));
} else {
assert_eq!(
history_after,
BTreeSet::from([
(current.version_id().expect("current enabled object has a UUID").to_string(), false),
(marker.to_string(), true),
]),
"enabled conditional current deletion retains the current data version behind the marker"
);
}
let result = delete_batch(
&client,
bucket,
vec![
identifier("history", Some("*"), None)?,
identifier("history", current.e_tag(), None)?,
identifier("missing", Some("*"), None)?,
],
false,
)
.await?;
assert!(
result.deleted().is_empty(),
"current markers and absent objects cannot satisfy a live-object condition"
);
assert_eq!(result.errors().len(), 3);
assert!(result.errors().iter().all(|error| error.code() == Some("NoSuchKey")));
assert_eq!(
versions(&client, bucket, "history").await?,
history_after,
"failed conditions create no marker"
);
assert!(versions(&client, bucket, "missing").await?.is_empty());
put(&client, bucket, "exists", b"versioned existence payload").await?;
let result = delete_batch(&client, bucket, vec![identifier("exists", Some("*"), None)?], false).await?;
assert!(result.errors().is_empty());
assert_eq!(result.deleted().len(), 1);
assert_eq!(result.deleted()[0].delete_marker(), Some(true));
assert_eq!(
versions(&client, bucket, "exists")
.await?
.iter()
.filter(|(_, marker)| *marker)
.count(),
1
);
if !suspended {
let directory = put(&client, bucket, "dir/", b"directory null-version payload").await?;
assert_eq!(
versions(&client, bucket, "dir/").await?,
BTreeSet::from([("null".to_string(), false)]),
"the directory PUT fixture must identify the synthetic null data version"
);
let result = delete_batch(
&client,
bucket,
vec![identifier("dir/", directory.e_tag(), None)?, identifier("dir/", None, None)?],
false,
)
.await?;
assert!(result.errors().is_empty(), "both distinct directory actions must succeed");
assert_eq!(result.deleted().len(), 2);
assert_eq!(result.deleted()[0].key(), Some("dir/"));
assert_eq!(result.deleted()[1].key(), Some("dir/"));
assert_eq!(result.deleted()[0].delete_marker(), Some(true));
let marker = result.deleted()[0]
.delete_marker_version_id()
.expect("conditional current-directory deletion creates a versioned marker");
assert!(
!uuid::Uuid::parse_str(marker)?.is_nil(),
"enabled current-directory deletion creates a UUID marker"
);
assert_eq!(result.deleted()[1].delete_marker(), None);
assert_eq!(result.deleted()[1].delete_marker_version_id(), None);
assert_eq!(result.deleted()[1].version_id(), None);
assert_eq!(
versions(&client, bucket, "dir/").await?,
BTreeSet::from([(marker.to_string(), true)]),
"current-marker creation and synthetic null-version removal are distinct actions: one marker remains and the null data version is gone"
);
}
}
Ok(())
}
@@ -0,0 +1,377 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! E2E: conditional write enforcement with the `s3:if-none-match` and `s3:if-match`
//! condition keys in a **bucket policy**, an **IAM identity policy**, and an **STS
//! AssumeRole session policy**. Covers PutObject and CompleteMultipartUpload, the two
//! upload operations that accept the conditional headers. The plain precondition
//! semantics without a policy live in `reliant/conditional_writes.rs`.
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging, parse_assume_role_credentials,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::config::http::HttpResponse;
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use tracing::info;
use uuid::Uuid;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
fn assert_error<E: ProvideErrorMetadata + std::fmt::Debug>(
err: &SdkError<E, HttpResponse>,
code: &str,
status: u16,
context: &str,
) {
assert_eq!(
err.as_service_error().and_then(ProvideErrorMetadata::code),
Some(code),
"{context}: {err:?}"
);
assert_eq!(
err.raw_response().map(|response| response.status().as_u16()),
Some(status),
"{context}: {err:?}"
);
}
async fn admin_add_and_attach_policy(
env: &RustFSTestEnvironment,
policy_name: &str,
policy_json: &str,
username: &str,
) -> TestResult {
admin_add_canned_policy_via(
AdminTransport::Signed,
&env.url,
&env.access_key,
&env.secret_key,
policy_name,
policy_json,
)
.await?;
admin_attach_user_policy_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, policy_name, username).await
}
/// Starts a one-part multipart upload as the admin so that only the complete step
/// runs under the policy being tested. CreateMultipartUpload and UploadPart are both
/// authorized as `s3:PutObject` and cannot carry a conditional header, so a policy
/// that requires `s3:if-none-match` denies them. AWS exempts them with
/// `s3:ObjectCreationOperation`, which RustFS does not support yet.
async fn admin_start_upload(
admin: &Client,
bucket: &str,
key: &str,
) -> Result<(String, CompletedMultipartUpload), Box<dyn std::error::Error + Send + Sync>> {
let upload = admin.create_multipart_upload().bucket(bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload id")?.to_string();
let part = admin
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(1)
.body(ByteStream::from_static(b"multipart-conditional-write"))
.send()
.await?;
let completed = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.set_e_tag(part.e_tag().map(str::to_string))
.build(),
)
.build();
Ok((upload_id, completed))
}
/// One server for the three policy kinds; each scenario uses its own principal and bucket.
#[tokio::test]
async fn test_e2e_policies_enforce_conditional_write_keys() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
let suffix = Uuid::new_v4();
bucket_policy_requires_if_none_match(&env, &admin, suffix).await?;
iam_policy_requires_if_match(&env, &admin, suffix).await?;
sts_session_policy_requires_if_none_match(&env, &admin, suffix).await?;
info!("test_e2e_policies_enforce_conditional_write_keys passed");
Ok(())
}
/// Bucket policy in the shape of the AWS enforcement example: PutObject is allowed only
/// when the request carries `If-None-Match`, which makes the bucket create-only.
async fn bucket_policy_requires_if_none_match(env: &RustFSTestEnvironment, admin: &Client, suffix: Uuid) -> TestResult {
let user = format!("e2econdwrite-{suffix}");
let user_secret = "longSecretKeyForCondWrite1!";
let bucket = format!("e2e-cond-write-bkt-{suffix}");
admin_create_user(env, &user, user_secret).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let bucket_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": [user.clone()] },
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")],
"Condition": { "Null": { "s3:if-none-match": "false" } }
}]
})
.to_string();
admin
.put_bucket_policy()
.bucket(&bucket)
.policy(&bucket_policy)
.send()
.await?;
let client = env.create_s3_client_with_credentials(&user, user_secret);
let denied = client
.put_object()
.bucket(&bucket)
.key("plain.txt")
.body(ByteStream::from_static(b"plain"))
.send()
.await
.expect_err("PutObject without If-None-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional PutObject");
let created = client
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("*")
.body(ByteStream::from_static(b"created"))
.send()
.await?;
let etag = created.e_tag().ok_or("missing ETag on conditional PutObject")?.to_string();
let overwrite = client
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("*")
.body(ByteStream::from_static(b"overwrite"))
.send()
.await
.expect_err("If-None-Match: * must not overwrite an existing key");
assert_error(
&overwrite,
"PreconditionFailed",
412,
"allowed PutObject still evaluates the precondition",
);
// A non-`*` value satisfies `Null: false` but must not act as an ETag comparison
// that lets the write overwrite the key.
let overwrite = client
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("\"0123456789abcdef0123456789abcdef\"")
.body(ByteStream::from_static(b"overwrite"))
.send()
.await
.expect_err("If-None-Match with an ETag value must be rejected");
assert_error(&overwrite, "NotImplemented", 501, "PutObject with an ETag If-None-Match");
let current = admin.head_object().bucket(&bucket).key("created.txt").send().await?;
assert_eq!(current.e_tag(), Some(etag.as_str()), "created.txt must not be overwritten");
let (upload_id, completed) = admin_start_upload(admin, &bucket, "multipart.bin").await?;
let denied = client
.complete_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.multipart_upload(completed.clone())
.send()
.await
.expect_err("CompleteMultipartUpload without If-None-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional CompleteMultipartUpload");
client
.complete_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.if_none_match("*")
.multipart_upload(completed)
.send()
.await?;
let (upload_id, completed) = admin_start_upload(admin, &bucket, "multipart.bin").await?;
let overwrite = client
.complete_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.if_none_match("*")
.multipart_upload(completed)
.send()
.await
.expect_err("CompleteMultipartUpload with If-None-Match: * must not overwrite an existing key");
assert_error(
&overwrite,
"PreconditionFailed",
412,
"allowed CompleteMultipartUpload still evaluates the precondition",
);
let _ = admin
.abort_multipart_upload()
.bucket(&bucket)
.key("multipart.bin")
.upload_id(&upload_id)
.send()
.await;
for key in ["created.txt", "multipart.bin"] {
let _ = admin.delete_object().bucket(&bucket).key(key).send().await;
}
let _ = admin.delete_bucket().bucket(&bucket).send().await;
Ok(())
}
/// IAM identity policy: PutObject is allowed only when the request carries `If-Match`.
async fn iam_policy_requires_if_match(env: &RustFSTestEnvironment, admin: &Client, suffix: Uuid) -> TestResult {
let user = format!("e2econdmatch-{suffix}");
let user_secret = "longSecretKeyForCondMatch2!";
let policy_name = format!("e2e-cond-match-pol-{suffix}");
let bucket = format!("e2e-cond-match-bkt-{suffix}");
let key = "guarded.txt";
admin_create_user(env, &user, user_secret).await?;
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")],
"Condition": { "Null": { "s3:if-match": "false" } }
}]
})
.to_string();
admin_add_and_attach_policy(env, &policy_name, &policy, &user).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let original = admin
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"v1"))
.send()
.await?;
let etag = original.e_tag().ok_or("missing ETag on initial PutObject")?.to_string();
let client = env.create_s3_client_with_credentials(&user, user_secret);
let denied = client
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"blind-overwrite"))
.send()
.await
.expect_err("PutObject without If-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional PutObject");
client
.put_object()
.bucket(&bucket)
.key(key)
.if_match(&etag)
.body(ByteStream::from_static(b"v2"))
.send()
.await?;
let _ = admin.delete_object().bucket(&bucket).key(key).send().await;
let _ = admin.delete_bucket().bucket(&bucket).send().await;
Ok(())
}
/// STS session policy: AssumeRole must accept `s3:if-none-match`, and the session may
/// only create objects with `If-None-Match: *`.
async fn sts_session_policy_requires_if_none_match(env: &RustFSTestEnvironment, admin: &Client, suffix: Uuid) -> TestResult {
let parent = format!("e2e-sts-cond-par-{suffix}");
let parent_secret = "longSecretKeyForCondParent3!";
let policy_name = format!("e2e-sts-cond-rw-{suffix}");
let bucket = format!("e2e-sts-cond-bkt-{suffix}");
admin_create_user(env, &parent, parent_secret).await?;
let parent_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:*"], "Resource": ["arn:aws:s3:::*"] },
{ "Effect": "Allow", "Action": ["sts:AssumeRole"], "Resource": ["arn:aws:s3:::*"] }
]
})
.to_string();
admin_add_and_attach_policy(env, &policy_name, &parent_policy, &parent).await?;
admin.create_bucket().bucket(&bucket).send().await?;
let session_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:PutObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/*")],
"Condition": { "StringEquals": { "s3:if-none-match": "*" } }
}]
})
.to_string();
let body = format!(
"Action=AssumeRole&Version=2011-06-15&DurationSeconds=3600&Policy={}",
urlencoding::encode(&session_policy)
);
let url = format!("{}/", env.url.trim_end_matches('/'));
let xml = awscurl_post_sts_form_urlencoded(&url, &body, &parent, parent_secret).await?;
let (access_key, secret_key, session_token) = parse_assume_role_credentials(&xml)?;
let session = Client::from_conf(build_test_s3_config(
&env.url,
&access_key,
&secret_key,
Some(&session_token),
"e2e-sts-session",
));
let denied = session
.put_object()
.bucket(&bucket)
.key("plain.txt")
.body(ByteStream::from_static(b"plain"))
.send()
.await
.expect_err("session PutObject without If-None-Match must be denied");
assert_error(&denied, "AccessDenied", 403, "unconditional session PutObject");
session
.put_object()
.bucket(&bucket)
.key("created.txt")
.if_none_match("*")
.body(ByteStream::from_static(b"created"))
.send()
.await?;
let _ = admin.delete_object().bucket(&bucket).key("created.txt").send().await;
let _ = admin.delete_bucket().bucket(&bucket).send().await;
Ok(())
}
+6 -4
View File
@@ -860,10 +860,12 @@ pub(crate) async fn start_rebalance(cluster: &RustFSTestClusterEnvironment) -> T
if status.is_success() {
break response;
}
if status == StatusCode::INTERNAL_SERVER_ERROR
&& response.contains("requires a live fleet capability proof")
&& Instant::now() < deadline
{
// The init stage answers 503 + Retry-After while the cluster capability
// proof is still missing; older binaries surfaced the same condition as
// a 500 carrying the proof marker.
let waiting_for_readiness = status == StatusCode::SERVICE_UNAVAILABLE
|| (status == StatusCode::INTERNAL_SERVER_ERROR && response.contains("requires a live fleet capability proof"));
if waiting_for_readiness && Instant::now() < deadline {
sleep(Duration::from_secs(1)).await;
continue;
}
@@ -18,7 +18,7 @@
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via,
awscurl_delete, awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging,
awscurl_delete, awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging, parse_assume_role_credentials,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
@@ -41,21 +41,6 @@ fn sts_session_client(env: &RustFSTestEnvironment, access_key: &str, secret_key:
))
}
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String), Box<dyn std::error::Error + Send + Sync>> {
let ak = extract_xml_tag(xml, "AccessKeyId").ok_or("missing AccessKeyId in AssumeRole response")?;
let sk = extract_xml_tag(xml, "SecretAccessKey").ok_or("missing SecretAccessKey in AssumeRole response")?;
let token = extract_xml_tag(xml, "SessionToken").ok_or("missing SessionToken in AssumeRole response")?;
Ok((ak, sk, token))
}
async fn assume_role_with_session_policy(
env: &RustFSTestEnvironment,
parent_ak: &str,
+1 -1
View File
@@ -6,7 +6,7 @@ This module is the shared failure-injection boundary for replication end-to-end
Supported data operations are HeadBucket, GetBucketVersioning, ListObjectsV2, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets created with `create_bucket` are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Three switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Four switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). `ignore_source_etags(true)` ignores the `x-rustfs-source-etag` / `x-minio-source-etag` replication headers and reports the ETag of the received bytes (AWS S3 and other generic S3 services; the default adopts the source ETag like RustFS/MinIO). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
`create_bucket_with_mode(name, BucketMode::Unversioned)` models a plain migration source: PUT overwrites in place, DELETE removes the key without a delete marker, GetBucketVersioning reports no status, and no `x-amz-version-id` is returned by PUT, GET, HEAD, tagging, or multipart completion. The only `versionId` such a bucket accepts is `null`; any other value is rejected with `InvalidArgument`. The mode is fixed at creation.
+18 -3
View File
@@ -537,6 +537,10 @@ struct StoreState {
/// PutObject carrying any `x-amz-object-lock-*` header must also carry
/// `Content-MD5` or an `x-amz-checksum-*` header.
require_checksum_for_object_lock: bool,
/// Models AWS S3 and other generic S3 services: the `x-rustfs-source-etag`
/// / `x-minio-source-etag` replication headers are ignored and every
/// stored object reports the ETag computed from the bytes it received.
ignore_source_etags: bool,
/// Models Wasabi (rustfs/backlog#2340): a version-addressed DELETE of a
/// version id the target never had answers 404 `NoSuchVersion` instead of
/// the idempotent 204 RustFS/MinIO give.
@@ -1052,6 +1056,13 @@ impl FakeS3Target {
lock(&self.backend.store).require_checksum_for_object_lock = enabled;
}
/// AWS-like mode: ignore the replication source-ETag headers and report
/// the ETag of the received bytes, as AWS S3 does for SSE-S3 and
/// unencrypted objects. The default adopts the source ETag (RustFS/MinIO).
pub fn ignore_source_etags(&self, enabled: bool) {
lock(&self.backend.store).ignore_source_etags = enabled;
}
pub fn assign_own_multipart_version_ids(&self, enabled: bool) {
lock(&self.backend.store).assign_own_multipart_version_ids = enabled;
}
@@ -2444,12 +2455,13 @@ impl S3 for FakeBackend {
.map_err(|_| s3s::s3_error!(ServiceUnavailable, "fake target body limiter closed"))?;
let headers = req.headers;
let input = req.input;
let (assign_own, drop_unlisted, require_checksum, limits, versioned) = {
let (assign_own, drop_unlisted, require_checksum, ignore_source_etags, limits, versioned) = {
let state = lock(&self.store);
(
state.assign_own_version_ids,
state.drop_unlisted_replication_headers,
state.require_checksum_for_object_lock,
state.ignore_source_etags,
state.limits,
bucket_versioned(&state, &input.bucket),
)
@@ -2489,7 +2501,7 @@ impl S3 for FakeBackend {
} else {
NULL_VERSION_ID.to_string()
};
let e_tag = match source_etag(&headers)? {
let e_tag = match source_etag(&headers)?.filter(|_| !ignore_source_etags) {
Some(value) => value,
None => {
let (digest, permit) = md5_digest(body.clone(), body_permit).await?;
@@ -3112,7 +3124,10 @@ impl S3 for FakeBackend {
.collect();
let (body, digests, _body_permits) = assemble_multipart(assembly_parts, total_len, _body_permits).await?;
let part_count = requested.len();
let e_tag = source_etag(&headers)?.unwrap_or_else(|| format!("{}-{part_count}", md5_hex(digests)));
let ignore_source_etags = lock(&self.store).ignore_source_etags;
let e_tag = source_etag(&headers)?
.filter(|_| !ignore_source_etags)
.unwrap_or_else(|| format!("{}-{part_count}", md5_hex(digests)));
let version = ObjectVersion {
version_id: upload.version_id.clone(),
body,
@@ -1032,7 +1032,7 @@ mod tests {
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/{}?forceStart=true", env.url, bucket);
signed_admin_post(&heal_url, Some(heal_body), &env.access_key, &env.secret_key)
start_root_heal_when_control_ready(&heal_url, heal_body, &env.access_key, &env.secret_key)
.await
.expect("admin deep heal should be accepted");
+7
View File
@@ -138,6 +138,10 @@ mod admin_auth_test;
#[cfg(test)]
mod existing_object_tag_policy_test;
/// IAM / bucket / STS session policy with `s3:if-none-match` / `s3:if-match` conditions (E2E).
#[cfg(test)]
mod conditional_write_policy_test;
#[cfg(test)]
mod sts_query_compat_test;
@@ -179,6 +183,9 @@ mod compression_test;
#[cfg(test)]
mod delete_objects_versioning_test;
#[cfg(test)]
mod conditional_delete_objects_test;
#[cfg(test)]
mod delete_authorization_test;
+376
View File
@@ -1001,6 +1001,382 @@ async fn test_anonymous_post_object_rejects_sse_kms_params_outside_policy_condit
Ok(())
}
#[tokio::test]
async fn test_object_creation_operation_separates_staging_from_publication() -> Result<(), Box<dyn Error + Send + Sync>> {
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "object-creation-operation";
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("source")
.body(ByteStream::from_static(b"copied-part"))
.send()
.await?;
let policy = |deny_creation: bool| {
serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Principal": "*", "Action": "s3:PutObject", "Resource": format!("arn:aws:s3:::{bucket}/*") },
{ "Effect": "Deny", "Principal": "*", "Action": "s3:PutObject", "Resource": format!("arn:aws:s3:::{bucket}/target*"),
"Condition": { "Bool": { "s3:ObjectCreationOperation": deny_creation.to_string() } } },
{ "Effect": "Deny", "Principal": "*", "Action": "s3:GetObject", "Resource": format!("arn:aws:s3:::{bucket}/*"),
"Condition": { "Null": { "s3:ObjectCreationOperation": "false" } } }
]
}).to_string()
};
client.put_bucket_policy().bucket(bucket).policy(policy(true)).send().await?;
let upload = client
.create_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.send()
.await?;
let upload_id = upload.upload_id().expect("multipart initiation should return upload ID");
let first_body = vec![b'a'; 5 * 1024 * 1024];
let part = client
.upload_part()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(first_body.clone()))
.send()
.await?;
let copy = client
.upload_part_copy()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.part_number(2)
.copy_source(format!("{bucket}/source"))
.send()
.await?;
let completed = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().expect("part ETag"))
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(copy.copy_part_result().and_then(|part| part.e_tag()).expect("copy part ETag"))
.build(),
)
.build();
let error = client
.complete_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.multipart_upload(completed.clone())
.send()
.await
.expect_err("publication must be denied");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
let error = client
.put_object()
.bucket(bucket)
.key("target-put")
.body(ByteStream::from_static(b"blocked"))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("objectcreationoperation", "false");
})
.send()
.await
.expect_err("a spoofed header must not exempt PutObject");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
let error = client
.copy_object()
.bucket(bucket)
.key("target-copy")
.copy_source(format!("{bucket}/source"))
.send()
.await
.expect_err("CopyObject publishes an object");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
let archive = make_tar(&[("target-member", b"blocked member")], &[]).await;
let error = client
.put_object()
.bucket(bucket)
.key("archive.tar")
.body(ByteStream::from(archive))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
})
.send()
.await
.expect_err("each extracted member must authorize object creation");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
let missing = client
.head_object()
.bucket(bucket)
.key("target-member")
.send()
.await
.expect_err("denied archive member must not exist");
assert!(missing.as_service_error().is_some_and(|error| error.is_not_found()));
for deny_creation in [true, false] {
client
.put_bucket_policy()
.bucket(bucket)
.policy(policy(deny_creation))
.send()
.await?;
let form = reqwest::multipart::Form::new()
.text("key", "target-post")
.text("success_action_status", "201")
.part(
"file",
reqwest::multipart::Part::bytes(b"post body".to_vec())
.file_name("upload.txt")
.mime_str("text/plain")?,
);
let response = local_http_client()
.post(format!("{}/{bucket}", env.url))
.multipart(form)
.send()
.await?;
assert_eq!(
response.status(),
if deny_creation {
reqwest::StatusCode::FORBIDDEN
} else {
reqwest::StatusCode::CREATED
},
"POST object creation={deny_creation}"
);
}
let error = client
.create_multipart_upload()
.bucket(bucket)
.key("target-denied")
.send()
.await
.expect_err("Bool=false must deny multipart staging under the inverted policy");
assert_eq!(error.as_service_error().and_then(|error| error.code()), Some("AccessDenied"));
client
.complete_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.multipart_upload(completed)
.send()
.await?;
let object = client
.get_object()
.bucket(bucket)
.key("target-multipart")
.send()
.await?
.body
.collect()
.await?
.into_bytes();
let mut expected = first_body;
expected.extend_from_slice(b"copied-part");
assert_eq!(object.as_ref(), expected, "completion must publish both regular and copied parts");
Ok(())
}
/// A vended STS credential whose inline session policy follows AWS's conditional-write
/// example 1: object publication needs a request header, multipart staging is exempt
/// through `s3:ObjectCreationOperation`. `s3:x-amz-storage-class` stands in for
/// `s3:if-none-match` because only the former is a header SDK requests can carry here.
#[tokio::test]
async fn test_object_creation_operation_in_sts_session_policy() -> Result<(), Box<dyn Error + Send + Sync>> {
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via,
awscurl_post_sts_form_urlencoded, build_test_s3_config,
};
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, StorageClass};
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let admin = env.create_s3_client();
let bucket = "object-creation-sts";
admin.create_bucket().bucket(bucket).send().await?;
admin
.put_object()
.bucket(bucket)
.key("source")
.body(ByteStream::from_static(b"copied-part"))
.send()
.await?;
let (parent, parent_secret, parent_policy) = ("objcreate-sts-parent", "objcreate-sts-parent-secret", "objcreate-sts-rw");
let (url, access_key, secret_key) = (env.url.as_str(), env.access_key.as_str(), env.secret_key.as_str());
admin_create_user_via(AdminTransport::Signed, url, access_key, secret_key, parent, parent_secret).await?;
let read_write = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:*"], "Resource": ["arn:aws:s3:::*"] },
{ "Effect": "Allow", "Action": ["sts:AssumeRole"], "Resource": ["arn:aws:s3:::*"] }
]
})
.to_string();
admin_add_canned_policy_via(AdminTransport::Signed, url, access_key, secret_key, parent_policy, &read_write).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access_key, secret_key, parent_policy, parent).await?;
let objects = format!("arn:aws:s3:::{bucket}/*");
let session_policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": "s3:PutObject", "Resource": objects,
"Condition": { "Null": { "s3:x-amz-storage-class": "false" } } },
{ "Effect": "Allow", "Action": "s3:PutObject", "Resource": objects,
"Condition": { "Bool": { "s3:ObjectCreationOperation": "false" } } },
{ "Effect": "Allow", "Action": "s3:GetObject", "Resource": objects }
]
})
.to_string();
let body = format!(
"Action=AssumeRole&Version=2011-06-15&DurationSeconds=3600&Policy={}",
urlencoding::encode(&session_policy)
);
let xml =
awscurl_post_sts_form_urlencoded(&format!("{}/", env.url.trim_end_matches('/')), &body, parent, parent_secret).await?;
let field = |tag: &str| -> Result<String, Box<dyn Error + Send + Sync>> {
let open = format!("<{tag}>");
let start = xml.find(&open).ok_or(format!("missing {tag} in AssumeRole response"))? + open.len();
let end = xml[start..].find(&format!("</{tag}>")).ok_or(format!("unterminated {tag}"))? + start;
Ok(xml[start..end].to_string())
};
let session = aws_sdk_s3::Client::from_conf(build_test_s3_config(
&env.url,
&field("AccessKeyId")?,
&field("SecretAccessKey")?,
Some(&field("SessionToken")?),
"object-creation-sts-session",
));
let denied = |error: Option<&str>, what: &str| assert_eq!(error, Some("AccessDenied"), "{what}");
let error = session
.put_object()
.bucket(bucket)
.key("target-put")
.body(ByteStream::from_static(b"blocked"))
.send()
.await
.expect_err("PutObject without the header must be denied");
denied(error.as_service_error().and_then(|error| error.code()), "PutObject without the header");
session
.put_object()
.bucket(bucket)
.key("target-put")
.storage_class(StorageClass::Standard)
.body(ByteStream::from_static(b"allowed"))
.send()
.await?;
let error = session
.copy_object()
.bucket(bucket)
.key("target-copy")
.copy_source(format!("{bucket}/source"))
.send()
.await
.expect_err("CopyObject without the header must be denied");
denied(error.as_service_error().and_then(|error| error.code()), "CopyObject without the header");
session
.copy_object()
.bucket(bucket)
.key("target-copy")
.copy_source(format!("{bucket}/source"))
.storage_class(StorageClass::Standard)
.send()
.await?;
let upload = session
.create_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.send()
.await?;
let upload_id = upload.upload_id().expect("multipart initiation should return upload ID");
let first_body = vec![b'a'; 5 * 1024 * 1024];
let part = session
.upload_part()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(first_body.clone()))
.send()
.await?;
let copy = session
.upload_part_copy()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.part_number(2)
.copy_source(format!("{bucket}/source"))
.send()
.await?;
let completed = CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().expect("part ETag"))
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(copy.copy_part_result().and_then(|part| part.e_tag()).expect("copy part ETag"))
.build(),
)
.build();
let error = session
.complete_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.multipart_upload(completed.clone())
.send()
.await
.expect_err("CompleteMultipartUpload without the header must be denied");
denied(
error.as_service_error().and_then(|error| error.code()),
"CompleteMultipartUpload without the header",
);
session
.complete_multipart_upload()
.bucket(bucket)
.key("target-multipart")
.upload_id(upload_id)
.multipart_upload(completed)
.customize()
.mutate_request(|req| {
req.headers_mut()
.insert("x-amz-storage-class", HeaderValue::from_static("STANDARD"));
})
.send()
.await?;
let object = session
.get_object()
.bucket(bucket)
.key("target-multipart")
.send()
.await?
.body
.collect()
.await?
.into_bytes();
let mut expected = first_body;
expected.extend_from_slice(b"copied-part");
assert_eq!(object.as_ref(), expected, "completion must publish both regular and copied parts");
Ok(())
}
#[tokio::test]
async fn test_anonymous_multipart_control_apis_require_auth() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
+8 -1
View File
@@ -851,13 +851,20 @@ mod integration_tests {
.delete(
aws_sdk_s3::types::Delete::builder()
.set_objects(Some(objects))
.quiet(true)
// Quiet mode hides successful entries, so the batch must be
// verbose for the assertion on the deleted list below.
.quiet(false)
.build()
.unwrap(),
)
.send()
.await?;
assert!(
delete_result.errors().is_empty(),
"batch delete must not report errors: {:?}",
delete_result.errors()
);
assert_eq!(delete_result.deleted().len(), 2);
// Now should be able to upload again (quota freed up)
@@ -70,7 +70,7 @@ async fn test_conditional_put_okay() -> TestResult {
let test_key = "conditional-put-ok";
let initial_data = generate_test_data(1024); // 1KB test data
let matching_data = generate_test_data(2048); // 2KB updated data
let non_matching_data = generate_test_data(3072); // 3KB updated data
let created_data = generate_test_data(3072); // 3KB new object
// Upload initial object and get its ETag
let initial_etag = upload_object_with_metadata(&client, TEST_BUCKET, test_key, &initial_data).await?;
@@ -86,17 +86,17 @@ async fn test_conditional_put_okay() -> TestResult {
.await?;
assert_eq!(object_body(&client, test_key).await?.as_ref(), matching_data);
// Test 2: PUT with non-matching If-None-Match condition (should succeed)
let fake_etag = "\"fake-etag-12345\"";
// Test 2: PUT with If-None-Match: * on a missing key (should succeed)
let created_key = "conditional-put-ok-created";
client
.put_object()
.bucket(TEST_BUCKET)
.key(test_key)
.body(Bytes::from(non_matching_data.clone()).into())
.if_none_match(fake_etag)
.key(created_key)
.body(Bytes::from(created_data.clone()).into())
.if_none_match("*")
.send()
.await?;
assert_eq!(object_body(&client, test_key).await?.as_ref(), non_matching_data);
assert_eq!(object_body(&client, created_key).await?.as_ref(), created_data);
Ok(())
}
@@ -130,17 +130,30 @@ async fn test_conditional_put_failed() -> TestResult {
assert_s3_error_code(response1, "PreconditionFailed");
assert_eq!(object_body(&client, test_key).await?.as_ref(), initial_data);
// Test 2: PUT with matching If-None-Match condition (should fail with 412)
let response2 = client
.put_object()
// Test 2: writes accept If-None-Match only as `*`; RustFS rejects an ETag
// value with 501 NotImplemented whether or not it matches.
for if_none_match in [initial_etag.as_str(), "\"fake-etag-12345\""] {
let response = client
.put_object()
.bucket(TEST_BUCKET)
.key(test_key)
.body(Bytes::from(updated_data.clone()).into())
.if_none_match(if_none_match)
.send()
.await;
assert_s3_error_code(response, "NotImplemented");
}
let response = client
.copy_object()
.bucket(TEST_BUCKET)
.key(test_key)
.body(Bytes::from(updated_data.clone()).into())
.if_none_match(&initial_etag)
.copy_source(format!("{TEST_BUCKET}/{test_key}"))
.metadata_directive(aws_sdk_s3::types::MetadataDirective::Replace)
.if_none_match("\"fake-etag-12345\"")
.send()
.await;
assert_s3_error_code(response2, "PreconditionFailed");
assert_s3_error_code(response, "NotImplemented");
assert_eq!(object_body(&client, test_key).await?.as_ref(), initial_data);
Ok(())
@@ -252,7 +265,7 @@ async fn test_conditional_multi_part_upload() -> TestResult {
assert_s3_error_code(complete_response, "PreconditionFailed");
// Test 2: Multipart upload with matching If-None-Match, should fail
// Test 2: Multipart upload with an unsupported ETag If-None-Match
let complete_response = client
.complete_multipart_upload()
.bucket(TEST_BUCKET)
@@ -263,7 +276,7 @@ async fn test_conditional_multi_part_upload() -> TestResult {
.send()
.await;
assert_s3_error_code(complete_response, "PreconditionFailed");
assert_s3_error_code(complete_response, "NotImplemented");
// Test 3: Multipart upload with unmatching If-Match, should fail
let complete_response = client
@@ -113,6 +113,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn acquire_quota_mutation_fence(
&self,
_request: Request<SnapshotLeaseRequest>,
) -> Result<Response<SnapshotLeaseResponse>, Status> {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn renew_snapshot_lease(
&self,
_request: Request<SnapshotLeaseRenewRequest>,
@@ -127,6 +134,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn release_quota_mutation_fence(
&self,
_request: Request<SnapshotLeaseReleaseRequest>,
) -> Result<Response<SnapshotLeaseMutationResponse>, Status> {
Err(Status::unimplemented("MinimalLockNodeService only supports lock RPCs"))
}
async fn acquire_scanner_publication_lease(
&self,
_request: Request<ScannerPublicationLeaseRequest>,
@@ -541,6 +555,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn rename_data_with_quota_fence(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::RenameDataRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::RenameDataResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn make_volumes(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::MakeVolumesRequest>,
@@ -639,6 +660,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn guarded_delete_versions(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::GuardedDeleteVersionsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::DeleteVersionsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn delete_versions(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::DeleteVersionsRequest>,
@@ -933,6 +961,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn peer_drive_admission(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::PeerDriveAdmissionRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::PeerDriveAdmissionResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn get_metacache_listing(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::GetMetacacheListingRequest>,
@@ -14,8 +14,8 @@
use crate::common::{
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user,
awscurl_post_sts_form_urlencoded, init_logging, local_http_client, replication_fast_env, rustfs_binary_path, signed_request,
signed_request_with_client, signed_request_with_session_token,
awscurl_post_sts_form_urlencoded, init_logging, local_http_client, parse_assume_role_credentials, replication_fast_env,
rustfs_binary_path, signed_request, signed_request_with_client, signed_request_with_session_token,
};
use crate::fake_s3_target::{
FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FaultAction as FakeTargetFault, Operation as FakeTargetOperation,
@@ -387,21 +387,6 @@ pub(crate) struct ReplicationResetStatusTarget {
pub(crate) object: String,
}
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = xml.find(&open)? + open.len();
let end = xml[start..].find(&close)? + start;
Some(xml[start..end].to_string())
}
fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String), Box<dyn Error + Send + Sync>> {
let access_key = extract_xml_tag(xml, "AccessKeyId").ok_or("missing AccessKeyId in AssumeRole response")?;
let secret_key = extract_xml_tag(xml, "SecretAccessKey").ok_or("missing SecretAccessKey in AssumeRole response")?;
let session_token = extract_xml_tag(xml, "SessionToken").ok_or("missing SessionToken in AssumeRole response")?;
Ok((access_key, secret_key, session_token))
}
pub(crate) struct ReplicationTargetOptions<'a> {
pub(crate) endpoint: &'a str,
pub(crate) access_key: &'a str,
@@ -45,9 +45,10 @@ use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::{ByteStream, DateTime};
use aws_sdk_s3::types::{
Checksum, ChecksumAlgorithm, CompletedMultipartUpload, CompletedPart, ObjectAttributes, ObjectLockLegalHold,
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, Tag, Tagging,
ObjectLockLegalHoldStatus, ObjectLockMode, ObjectLockRetention, ObjectLockRetentionMode, ServerSideEncryption, Tag, Tagging,
};
use bytes::Bytes;
use md5::{Digest as _, Md5};
use std::error::Error;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{Duration, sleep, timeout};
@@ -73,14 +74,20 @@ enum TargetMode {
/// land, and every version-addressed mutation must resolve the replica
/// through the target-version ledger.
MintOwnVersionIds,
/// AWS S3 and other generic S3 services: ignores the replication
/// source-ETag headers and reports the MD5 of the plaintext it received.
/// Every single-part replica ETag is therefore checked against the source
/// ETag for real (the other modes echo the source ETag back).
ComputesOwnEtags,
}
impl TargetMode {
const ALL: [TargetMode; 4] = [
const ALL: [TargetMode; 5] = [
TargetMode::Baseline,
TargetMode::RejectAwsChunked,
TargetMode::RequireChecksumWithObjectLock,
TargetMode::MintOwnVersionIds,
TargetMode::ComputesOwnEtags,
];
fn apply(self, target: &FakeS3Target) {
@@ -92,6 +99,7 @@ impl TargetMode {
target.assign_own_version_ids(true);
target.reject_unknown_version_deletes(true);
}
TargetMode::ComputesOwnEtags => target.ignore_source_etags(true),
}
}
@@ -101,6 +109,7 @@ impl TargetMode {
TargetMode::RejectAwsChunked => "reject-aws-chunked",
TargetMode::RequireChecksumWithObjectLock => "require-checksum-object-lock",
TargetMode::MintOwnVersionIds => "mint-own-version-ids",
TargetMode::ComputesOwnEtags => "computes-own-etags",
}
}
}
@@ -126,10 +135,14 @@ enum ObjectShape {
/// Single-part object uploaded with `x-amz-checksum-sha256`; the replica
/// must carry the same header (rustfs/backlog#2340).
Checksummed,
/// Single-part SSE-S3 object. Its ETag is a keyed fingerprint, not the
/// plaintext MD5 (docs/operations/encrypted-etags.md), while the replica
/// travels as plaintext under `x-amz-server-side-encryption: AES256`.
SseS3,
}
impl ObjectShape {
const ALL: [ObjectShape; 8] = [
const ALL: [ObjectShape; 9] = [
ObjectShape::Empty,
ObjectShape::Plain,
ObjectShape::Retention,
@@ -138,6 +151,7 @@ impl ObjectShape {
ObjectShape::LockedMultipart,
ObjectShape::OdmPreservedMd5Multipart,
ObjectShape::Checksummed,
ObjectShape::SseS3,
];
fn key(self) -> &'static str {
@@ -150,6 +164,7 @@ impl ObjectShape {
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
ObjectShape::OdmPreservedMd5Multipart => "matrix/odm-preserved-md5.bin",
ObjectShape::Checksummed => "matrix/checksummed.bin",
ObjectShape::SseS3 => "matrix/sse-s3.bin",
}
}
@@ -233,6 +248,24 @@ impl ObjectShape {
.await?;
Ok(body)
}
ObjectShape::SseS3 => {
let body = payload(56 * 1024, 0x77);
let put = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.server_side_encryption(ServerSideEncryption::Aes256)
.send()
.await?;
// The cell only proves something while the source ETag is
// not the content MD5 a generic target reports back.
let plaintext_md5 = hex_simd::encode_to_string(Md5::digest(&body), hex_simd::AsciiCase::Lower);
if put.e_tag().map(|etag| etag.trim_matches('"')) == Some(plaintext_md5.as_str()) {
return Err("SSE-S3 source ETag is the plaintext MD5; the cell no longer exercises encrypted ETags".into());
}
Ok(body)
}
}
}
}
@@ -270,14 +303,40 @@ fn expectation(mode: TargetMode, shape: ObjectShape) -> Expectation {
/// - the first PUT lands but its response is lost, so the object is FAILED
/// and the scanner heal pass re-drives it;
/// - an existing-object resync re-drives a COMPLETED object unconditionally.
///
/// A permanent delete of the healed version then has to remove exactly the
/// replica the heal pass located.
#[tokio::test]
async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult {
run_redrive_does_not_duplicate(RedriveObjects::Plaintext).await
}
/// The same re-drives for SSE-S3 objects against a target that also computes
/// its own ETags (AWS S3). The source ETag is a keyed fingerprint
/// (docs/operations/encrypted-etags.md) while the replica reports the MD5 of
/// the plaintext it received, so no exact-ETag lookup can ever find the
/// replica: the lost-response heal, the resync and the purge must locate it
/// without comparing ETags.
#[tokio::test]
async fn matrix_mint_own_version_ids_encrypted_redrive_does_not_duplicate() -> TestResult {
run_redrive_does_not_duplicate(RedriveObjects::SseS3OnEtagComputingTarget).await
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum RedriveObjects {
Plaintext,
SseS3OnEtagComputingTarget,
}
async fn run_redrive_does_not_duplicate(objects: RedriveObjects) -> TestResult {
init_logging();
let encrypted = objects == RedriveObjects::SseS3OnEtagComputingTarget;
let target = FakeS3Target::start().await?;
let target_bucket = "matrix-mint-own-redrive-dst".to_string();
target.create_bucket_with_object_lock(target_bucket.clone());
target.assign_own_version_ids(true);
target.ignore_source_etags(encrypted);
let mut env_vars = replication_fast_env();
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
@@ -291,6 +350,7 @@ async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult
]);
let env = OdmTestEnv::start_with(OdmEnvOptions {
env: env_vars,
local_kms: encrypted,
..OdmEnvOptions::default()
})
.await?;
@@ -319,45 +379,63 @@ async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult
},
)
.await?;
put_bucket_replication(source_env, source_bucket, &target_arn).await?;
put_bucket_replication_with_delete_statuses(source_env, source_bucket, &target_arn, "Enabled", Some("Enabled")).await?;
let put = |key: &'static str, fill: u8, len: usize| {
let request = source_client
.put_object()
.bucket(source_bucket)
.key(key)
.body(ByteStream::from(payload(len, fill)));
let request = if encrypted {
request.server_side_encryption(ServerSideEncryption::Aes256)
} else {
request
};
async move { request.send().await }
};
// Teach the worker the target's identity contract with one ordinary
// write, exactly as production learns it (the PUT response carries the
// minted id).
let probe_key = "redrive/identity-probe.bin";
source_client
.put_object()
.bucket(source_bucket)
.key(probe_key)
.body(ByteStream::from(payload(4 * 1024, 0x01)))
.send()
.await?;
let probe = put(probe_key, 0x01, 4 * 1024).await?;
assert_eq!(
wait_for_terminal_replication_status(&source_client, source_bucket, probe_key).await?,
"COMPLETED"
);
if encrypted {
// The variant only proves something while the replica's ETag differs
// from the source's encrypted-ETag fingerprint.
let replica = single_target_version(&target, &target_bucket, probe_key)?;
let replica_etag = fake_source_client(&target)
.head_object()
.bucket(&target_bucket)
.key(probe_key)
.version_id(&replica)
.send()
.await?
.e_tag()
.map(str::to_string);
if replica_etag.is_none() || replica_etag.as_deref() == probe.e_tag() {
return Err(format!(
"the replica ETag {replica_etag:?} must differ from the source fingerprint {:?}",
probe.e_tag()
)
.into());
}
}
// Shape 1: the PUT is stored, its response never arrives, heal re-drives.
let heal_key = "redrive/heal.bin";
target.inject_for_key(FakeTargetOperation::PutObject, heal_key, FakeTargetFault::DisconnectAfterResponse, 1);
source_client
.put_object()
.bucket(source_bucket)
.key(heal_key)
.body(ByteStream::from(payload(8 * 1024, 0x02)))
.send()
.await?;
let healed = put(heal_key, 0x02, 8 * 1024).await?;
let healed_version = healed.version_id().ok_or("source PUT returned no version id")?.to_string();
wait_for_replication_status_and_single_version(&source_client, source_bucket, &target, &target_bucket, heal_key).await?;
// Shape 2: an existing-object resync re-drives a COMPLETED object.
let resync_key = "redrive/resync.bin";
source_client
.put_object()
.bucket(source_bucket)
.key(resync_key)
.body(ByteStream::from(payload(8 * 1024, 0x03)))
.send()
.await?;
put(resync_key, 0x03, 8 * 1024).await?;
assert_eq!(
wait_for_terminal_replication_status(&source_client, source_bucket, resync_key).await?,
"COMPLETED"
@@ -388,6 +466,20 @@ async fn matrix_mint_own_version_ids_redrive_does_not_duplicate() -> TestResult
);
}
// The heal pass recorded the replica it located; a permanent delete of
// that version must remove exactly it.
source_client
.delete_object()
.bucket(source_bucket)
.key(heal_key)
.version_id(&healed_version)
.send()
.await?;
wait_until("permanent delete of the healed replica", || async {
Ok(target.stored_versions(&target_bucket, heal_key).is_empty())
})
.await?;
target.shutdown().await;
Ok(())
}
@@ -871,6 +963,11 @@ async fn matrix_mint_own_version_ids_target() -> TestResult {
run_row(TargetMode::MintOwnVersionIds).await
}
#[tokio::test]
async fn matrix_computes_own_etags_target() -> TestResult {
run_row(TargetMode::ComputesOwnEtags).await
}
/// Every known-red entry must name a real cell and an issue, and the lookup
/// must round-trip, so a stale or mistyped entry cannot silently pin nothing.
#[test]
@@ -907,6 +1004,8 @@ async fn run_row(mode: TargetMode) -> TestResult {
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
let env = OdmTestEnv::start_with(OdmEnvOptions {
env: env_vars,
// The SseS3 shape needs a KMS for SSE-S3.
local_kms: true,
..OdmEnvOptions::default()
})
.await?;
File diff suppressed because it is too large Load Diff
+2
View File
@@ -31,6 +31,7 @@ workspace = true
[features]
default = []
diagnostic-read-version-caller = ["rustfs-io-metrics/diagnostic-read-version-caller"]
gcs = ["dep:google-cloud-storage", "dep:google-cloud-auth"]
# Compiles the controlled list-objects namespace-journal chaos injector into a
# production binary (it is always available to tests). Off by default so the
@@ -249,6 +250,7 @@ windows-sys = { workspace = true, features = ["Win32_System_Ioctl"] }
[dev-dependencies]
aws-smithy-async.workspace = true
rustfs-s3-ops.workspace = true
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
criterion = { workspace = true, features = ["html_reports"] }
temp-env = { workspace = true, features = ["async_closure"] }
+36 -16
View File
@@ -14,6 +14,15 @@
//! Explicit ECStore public facades for outer crate compatibility boundaries.
#[cfg(feature = "diagnostic-read-version-caller")]
pub mod diagnostic {
pub use crate::cluster::rpc::runtime_sources::{
current_read_version_entry_source, observe_read_version_context_boundary, with_read_version_entry,
with_read_version_spawn_context,
};
pub use rustfs_io_metrics::internode_metrics::{ReadVersionContextBoundary, ReadVersionEntrySource};
}
pub mod admin {
pub use crate::diagnostics::admin_server_info::{get_local_server_property, get_server_info};
}
@@ -243,11 +252,11 @@ pub mod bucket {
pub mod quota {
pub use crate::bucket::quota::{
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V1, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError,
QuotaOperation,
QuotaMigrationSkipReason, QuotaOperation, QuotaProtocolMigration,
};
pub mod checker {
pub use crate::bucket::quota::checker::QuotaChecker;
pub use crate::bucket::quota::checker::{QuotaChecker, record_reservation_protocol_migration};
}
}
@@ -423,13 +432,14 @@ pub mod disk {
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
pub use crate::disk::{
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
CheckPartsResp, ConditionalFileUpdate, DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction,
DeleteRecoveryCursor, DeleteRecoveryDigest, DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation,
DeleteRecoveryRpcReply, DeleteRecoveryState, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
DiskStore, FileInfoVersions, FileReader, FileWriter, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
PartTransactionAction, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp,
STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, new_disk,
validate_batch_read_version_item_count,
CheckPartsResp, ConditionalFileUpdate, CurrentDeleteGeneration, CurrentDeleteTransition,
DELETE_RECOVERY_PROTOCOL_VERSION, DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest,
DeleteRecoveryInspection, DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DeleteRecoveryState,
Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption, DiskStore, FileInfoVersions, FileReader, FileWriter,
GuardedDeleteVersionsPayload, GuardedFileInfoVersions, HEALING_MARKER_PATH, NsScannerOpenRequest, OldCurrentSize,
PartTransactionAction, QUOTA_MUTATION_FENCE_METADATA_SUFFIX, RUSTFS_META_BUCKET, ReadMultipleReq, ReadMultipleResp,
ReadOptions, RenameDataResp, STORAGE_FORMAT_FILE, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
is_quota_mutation_fence_path, new_disk, validate_batch_read_version_item_count,
};
pub use bytes::Bytes;
pub use endpoint::Endpoint;
@@ -498,17 +508,19 @@ pub mod metrics {
}
pub mod notification {
#[cfg(any(test, feature = "test-util"))]
pub use crate::services::notification_sys::rotate_cross_pool_fence_fleet_proof_for_test;
pub use crate::services::notification_sys::{
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, IlmRecoveryExportFleetProofToken,
LegacyTransitionStateReconcileFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
acquire_cross_pool_fence_fleet_proof, acquire_ilm_recovery_export_fleet_proof,
acquire_legacy_transition_state_reconcile_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
ilm_recovery_export_fleet_proof_matches, ilm_recovery_export_local_process_epoch,
ilm_recovery_export_member_epochs_sha256, ilm_recovery_export_topology_generation,
legacy_transition_state_reconcile_fleet_proof_matches, new_global_notification_sys,
scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
acquire_legacy_transition_state_reconcile_fleet_proof, configured_fleet_topology_fingerprint,
cross_pool_fence_fleet_proof_matches, get_global_notification_sys, ilm_recovery_export_fleet_proof_matches,
ilm_recovery_export_local_process_epoch, ilm_recovery_export_member_epochs_sha256,
ilm_recovery_export_topology_generation, legacy_transition_state_reconcile_fleet_proof_matches,
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
};
#[cfg(any(test, feature = "test-util"))]
pub use crate::services::notification_sys::{
rotate_cross_pool_fence_fleet_proof_for_test, without_cross_pool_fence_fleet_proof_for_test,
};
}
@@ -602,6 +614,14 @@ pub mod set_disk {
pub fn hold_namespace_commit(store: &crate::store::ECStore) -> impl Send + Sync {
store.ctx.begin_namespace_commit()
}
/// Whether an ordinary namespace commit (a PUT rename fanout, including
/// its post-ACK tail) is still pending on this instance. Test fixtures
/// drain tails through this probe; the scanner's publication barrier no
/// longer reports them.
pub fn namespace_commits_pending(store: &crate::store::ECStore) -> bool {
store.ctx.namespace_commits_pending()
}
}
}
+149 -39
View File
@@ -142,11 +142,11 @@ fn same_replication_service(edited: &BucketTarget, previous: &BucketTarget) -> b
&& access_key(edited) == access_key(previous)
}
/// Page size and page budget for [`TargetClient::locate_replica_by_etag`].
/// Page size and page budget for [`TargetClient::replica_candidates`].
const FIND_VERSION_BY_ETAG_PAGE_SIZE: i32 = 1000;
const FIND_VERSION_BY_ETAG_MAX_PAGES: usize = 8;
/// Candidate cap for [`TargetClient::replica_candidates_by_etag`]: more than
/// this many same-content versions of one key is ambiguity by any measure.
/// Candidate cap for [`TargetClient::replica_candidates`]: more than this
/// many matching versions of one key is ambiguity by any measure.
const FIND_VERSION_BY_ETAG_MAX_MATCHES: usize = 16;
pub type GetObjectSdkError = Box<SdkError<GetObjectError>>;
pub type GetObjectTaggingSdkError = Box<SdkError<GetObjectTaggingError>>;
@@ -2030,27 +2030,28 @@ impl TargetClient {
/// Candidate replicas by content identity on a target that mints its own
/// version ids: page `ListObjectVersions` under the exact key and report
/// the live versions whose ETag matches `source_etag`, newest first.
/// Delete markers and prefix siblings never match. Bounded to
/// the live versions that match `identity`, newest first. Delete markers
/// and prefix siblings never match. Bounded to
/// [`FIND_VERSION_BY_ETAG_MAX_PAGES`] pages and
/// [`FIND_VERSION_BY_ETAG_MAX_MATCHES`] candidates so a key with a very
/// deep history cannot turn one convergence check into an unbounded scan;
/// a replica beyond that window reads as missing, which only costs a
/// re-PUT (today's behaviour), never a lost object.
/// [`FIND_VERSION_BY_ETAG_MAX_MATCHES`] candidates per kind so a key with
/// a very deep history cannot turn one convergence check into an
/// unbounded scan; a replica beyond that window reads as missing, which
/// only costs a re-PUT (today's behaviour), never a lost object.
///
/// Content identity is not version identity: two source generations with
/// the same bytes have the same ETag. Callers drop the candidates other
/// source versions already claim through their ledgers and refuse an
/// [`ReplicaLocation::Ambiguous`] remainder before mutating or deleting.
pub async fn replica_candidates_by_etag(
/// the same bytes have the same ETag, and two with the same size have the
/// same shape. Callers drop the candidates other source versions already
/// claim through their ledgers and decide through
/// [`ReplicaCandidates::locate`].
pub async fn replica_candidates(
&self,
bucket: &str,
object: &str,
source_etag: &str,
) -> Result<Vec<String>, Box<SdkError<aws_sdk_s3::operation::list_object_versions::ListObjectVersionsError>>> {
identity: &ReplicaIdentity<'_>,
) -> Result<ReplicaCandidates, Box<SdkError<aws_sdk_s3::operation::list_object_versions::ListObjectVersionsError>>> {
let mut key_marker: Option<String> = None;
let mut version_id_marker: Option<String> = None;
let mut matches: Vec<String> = Vec::new();
let mut candidates = ReplicaCandidates::default();
for _ in 0..FIND_VERSION_BY_ETAG_MAX_PAGES {
let page = self
.client
@@ -2063,19 +2064,27 @@ impl TargetClient {
.send()
.await
.map_err(Box::new)?;
matches.extend(
page.versions()
.iter()
.filter(|version| {
version.key() == Some(object)
&& version.version_id().is_some_and(|id| !id.is_empty())
&& replication_etags_match(Some(source_etag), version.e_tag())
})
.filter_map(|version| version.version_id().map(str::to_string)),
);
for version in page.versions() {
let Some(version_id) = version.version_id().filter(|id| !id.is_empty()) else {
continue;
};
if version.key() != Some(object) {
continue;
}
match identity.evidence(version.e_tag(), version.size(), version.last_modified()) {
Some(ReplicaEvidence::Etag) => candidates.exact.push(version_id.to_string()),
// An unversioned target overwrites the key in place: its
// `null` version holds whichever generation was written
// last, so a shape match there identifies nothing.
Some(ReplicaEvidence::Shape) if version_id != "null" => candidates.shaped.push(version_id.to_string()),
Some(ReplicaEvidence::Shape) => {}
None => {}
}
}
// A listing that moved past the exact key (every listed key is >=
// the prefix), ended, or already filled the candidate cap decides.
if matches.len() >= FIND_VERSION_BY_ETAG_MAX_MATCHES
// the prefix), ended, or already filled a candidate cap decides.
if candidates.exact.len() >= FIND_VERSION_BY_ETAG_MAX_MATCHES
|| candidates.shaped.len() >= FIND_VERSION_BY_ETAG_MAX_MATCHES
|| page
.versions()
.iter()
@@ -2090,8 +2099,9 @@ impl TargetClient {
break;
}
}
matches.truncate(FIND_VERSION_BY_ETAG_MAX_MATCHES);
Ok(matches)
candidates.exact.truncate(FIND_VERSION_BY_ETAG_MAX_MATCHES);
candidates.shaped.truncate(FIND_VERSION_BY_ETAG_MAX_MATCHES);
Ok(candidates)
}
/// PutObjectRetention against a replica version on a target that does not
@@ -2614,19 +2624,18 @@ impl TargetClient {
}
/// Where a replica stands on a target that mints its own version ids, by
/// content identity (exact key + ETag) after the candidates other source
/// versions claim were removed. See
/// [`TargetClient::replica_candidates_by_etag`].
/// content identity after the candidates other source versions claim were
/// removed. See [`TargetClient::replica_candidates`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplicaLocation {
/// No live version under the key carries the source ETag.
/// No live version under the key matches the source.
Missing,
/// Exactly one live version carries it: safe to address.
/// Exactly one live version matches: safe to address.
Unique(String),
/// More than one live version carries it (same bytes replicated for
/// several source generations). `newest` is the most recently listed
/// one — good enough to prove the replica exists, never good enough to
/// pick which one to mutate or delete.
/// More than one live version carries the source ETag (same bytes
/// replicated for several source generations). `newest` is the most
/// recently listed one — good enough to prove the replica exists, never
/// good enough to pick which one to mutate or delete.
Ambiguous { newest: String },
}
@@ -2652,6 +2661,107 @@ impl ReplicaLocation {
}
}
/// What a replica of an encrypted source version still has in common with
/// it on a target that computes its own ETags. The source ETag is a keyed
/// fingerprint (docs/operations/encrypted-etags.md) or a legacy plaintext
/// MD5 the target's re-encrypted replica never reports, so the replica is
/// recognised by the byte count the replication PUT sent and by a write time
/// no earlier than the source version's own.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EncryptedReplicaShape {
/// Bytes on the replication wire: the plaintext size for managed SSE,
/// the stored ciphertext size for an SSE-C passthrough.
pub size: i64,
/// The source version's mtime in whole seconds; listings and HEAD report
/// second-precision times.
pub not_before_secs: i64,
}
impl EncryptedReplicaShape {
pub fn matches(&self, size: Option<i64>, last_modified: Option<&aws_sdk_s3::primitives::DateTime>) -> bool {
size == Some(self.size) && last_modified.is_some_and(|written| written.secs() >= self.not_before_secs)
}
}
/// How a source version recognises its replica among a key's target
/// versions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplicaIdentity<'a> {
/// Unencrypted source: its ETag is the MD5 of the replicated bytes, which
/// every target reports back — exact identity.
Etag(&'a str),
/// Encrypted source: a target that adopts the source ETag (RustFS,
/// MinIO) still reports `etag`; any other target only keeps the shape.
Encrypted {
etag: Option<&'a str>,
shape: EncryptedReplicaShape,
},
}
/// Which kind of evidence tied a target version to the source.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplicaEvidence {
/// The target reports the source ETag.
Etag,
/// Encrypted source on a target that computes its own ETag: same size,
/// written no earlier than the source version.
Shape,
}
impl ReplicaIdentity<'_> {
pub fn evidence(
&self,
etag: Option<&str>,
size: Option<i64>,
last_modified: Option<&aws_sdk_s3::primitives::DateTime>,
) -> Option<ReplicaEvidence> {
match *self {
Self::Etag(source_etag) => replication_etags_match(Some(source_etag), etag).then_some(ReplicaEvidence::Etag),
Self::Encrypted {
etag: source_etag,
shape,
} => {
if replication_etags_match(source_etag, etag) {
Some(ReplicaEvidence::Etag)
} else {
shape.matches(size, last_modified).then_some(ReplicaEvidence::Shape)
}
}
}
}
}
/// Content-identity candidates of one key, newest first, by evidence kind.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReplicaCandidates {
/// Versions that report the source ETag.
pub exact: Vec<String>,
/// Encrypted sources only: versions without the source ETag whose shape
/// matches.
pub shaped: Vec<String>,
}
impl ReplicaCandidates {
pub fn is_empty(&self) -> bool {
self.exact.is_empty() && self.shaped.is_empty()
}
/// Drop the candidates other source versions claim and decide. An ETag
/// match outranks any shape match. A shape is weaker evidence than an
/// ETag — two generations of one size share it — so several shape
/// matches are never resolved by picking one: they read as
/// [`ReplicaLocation::Missing`] for convergence (the caller re-PUTs, a
/// duplicate at worst) and as ambiguous for a purge (it refuses).
pub fn locate(mut self, claimed: &HashSet<String>) -> (ReplicaLocation, ReplicaEvidence) {
self.exact.retain(|candidate| !claimed.contains(candidate));
if !self.exact.is_empty() {
return (ReplicaLocation::from_matches(self.exact), ReplicaEvidence::Etag);
}
self.shaped.retain(|candidate| !claimed.contains(candidate));
(ReplicaLocation::from_matches(self.shaped), ReplicaEvidence::Shape)
}
}
#[derive(Debug)]
pub enum BucketTargetError {
BucketRemoteTargetNotFound {
@@ -2461,7 +2461,13 @@ fn spawn_recovery_disposition_maintenance_once(api: Arc<ECStore>) -> Option<Join
return None;
}
Some(tokio::spawn(async move {
run_recovery_disposition_maintenance_loop(api, cancel_token).await;
let maintenance = run_recovery_disposition_maintenance_loop(api, cancel_token);
#[cfg(feature = "diagnostic-read-version-caller")]
let maintenance = crate::cluster::rpc::runtime_sources::with_read_version_entry(
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::RecoveryDispositionMaintenance,
maintenance,
);
maintenance.await;
}))
}
@@ -3344,7 +3350,13 @@ fn spawn_tier_delete_journal_recovery_once(api: Arc<ECStore>) {
return;
}
tokio::spawn(async move {
run_tier_delete_journal_recovery_loop(api, cancel_token).await;
let recovery = run_tier_delete_journal_recovery_loop(api, cancel_token);
#[cfg(feature = "diagnostic-read-version-caller")]
let recovery = crate::cluster::rpc::runtime_sources::with_read_version_entry(
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::LifecycleRecovery,
recovery,
);
recovery.await;
});
}
@@ -3363,7 +3375,13 @@ fn spawn_transition_transaction_recovery_once(api: Arc<ECStore>) {
return;
}
tokio::spawn(async move {
run_transition_transaction_recovery_loop(api, cancel_token).await;
let recovery = run_transition_transaction_recovery_loop(api, cancel_token);
#[cfg(feature = "diagnostic-read-version-caller")]
let recovery = crate::cluster::rpc::runtime_sources::with_read_version_entry(
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::LifecycleRecovery,
recovery,
);
recovery.await;
});
}
@@ -3948,44 +3966,28 @@ pub(crate) async fn enqueue_immediate_expiry(api: Arc<ECStore>, oi: &ObjectInfo,
return;
};
let mut marker = None;
let mut version_marker = None;
let mut object_infos = Vec::new();
loop {
let page = match api
.clone()
.list_object_versions_for_lifecycle(&oi.bucket, &oi.name, marker.clone(), version_marker.clone(), None, 1000)
.await
{
Ok(page) => page,
Err(err) => {
warn!(
event = EVENT_LIFECYCLE_EVALUATION_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
bucket = %oi.bucket,
object = %oi.name,
error = %err,
reason = "list_versions_failed",
"Failed to load lifecycle version group"
);
return;
}
};
object_infos.extend(page.objects.into_iter().filter(|object| object.name == oi.name));
if !page.is_truncated {
break;
let object_infos = match api
.get_object_versions_for_lifecycle(&oi.bucket, &oi.name, opts, configs.bucket_incarnation_id)
.await
{
Ok(objects) => objects,
Err(err) => {
warn!(
event = EVENT_LIFECYCLE_EVALUATION_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
result = "failed",
bucket = %oi.bucket,
object = %oi.name,
error = %err,
reason = "read_versions_failed",
"Failed to load lifecycle version group"
);
return;
}
marker = page.next_marker;
version_marker = page.next_version_idmarker;
}
};
if object_infos.is_empty() {
object_infos.push(oi.clone());
return;
}
let object_opts = object_infos
@@ -4910,6 +4912,14 @@ async fn expire_transitioned_object_with_lock_lost_signal(
let (versioned, version_suspended) = snapshot.versioning_config().delete_state(&oi.name);
let mut opts = transitioned_object_delete_opts(oi, lc_event.action, versioned, version_suspended, bucket_incarnation_id)
.map_err(std::io::Error::other)?;
if lc_event.requires_expiry_policy() {
opts.lifecycle_expiry_policy = Some(
lc_event
.expiry_policy
.clone()
.ok_or_else(|| std::io::Error::other("lifecycle expiration is missing its policy snapshot"))?,
);
}
opts.add_namespace_lock_guard(&publication_guard);
if let Some(signal) = lock_lost_signal {
opts.add_namespace_lock_lost_signal(signal);
@@ -5760,6 +5770,9 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
bucket_incarnation_id: Uuid,
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
) -> bool {
let Some(expiry_policy) = lc_event.expiry_policy.clone() else {
return false;
};
let Some(publication_guard) = lifecycle_expiry_publication_guard(&api, &oi.bucket, &oi.name, bucket_incarnation_id).await
else {
return false;
@@ -5790,6 +5803,7 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
versioned,
version_suspended,
expiration: ExpirationOptions { expire: true },
lifecycle_expiry_policy: Some(expiry_policy),
delete_replication_config_snapshot: Some(Arc::new(snapshot)),
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
..Default::default()
@@ -6071,6 +6085,7 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
mod tests {
use super::expiry_worker_count;
use super::lifecycle_transition_publication_guard;
use super::metadata_boundary::get_expiry_configs;
use super::{
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EVALUATION_FAILED, EVENT_LIFECYCLE_EXPIRED_DETECTED,
@@ -6119,7 +6134,7 @@ mod tests {
save_manual_transition_scope_admission_if_current, save_manual_transition_task_if_absent,
save_manual_transition_worker_result_if_absent,
};
use crate::bucket::lifecycle::replication_sink::{ReplicationStatusType, VersionPurgeStatusType};
use crate::bucket::lifecycle::replication_sink::{self, ReplicationStatusType, VersionPurgeStatusType};
use crate::bucket::lifecycle::runtime_boundary as runtime_sources;
use crate::bucket::lifecycle::tier_free_version_recovery::{
FreeVersionRecoveryStats, RecoveryWalkTestAction, RecoveryWorkBudget, list_tier_free_versions,
@@ -12893,6 +12908,7 @@ mod tests {
.await
.expect("bucket incarnation should be available");
let expiry_policy = install_test_expiry_policy(&ecstore, &bucket).await;
test_recorder::install();
// One version that exists and one that never did: `delete_objects`
@@ -12911,15 +12927,24 @@ mod tests {
..Default::default()
},
];
let _ = replication_sink::take_scheduled_deletes_for_test();
let failed = crate::bucket::lifecycle::object_handlers_common::delete_object_versions(
&ecstore,
&bucket,
&targets,
lifecycle::Event::default(),
lifecycle::Event {
action: IlmAction::DeleteVersionAction,
expiry_policy: Some(expiry_policy.into()),
..Default::default()
},
incarnation,
)
.await;
assert_eq!(failed, 0, "a missing version is not a batch failure");
assert!(
replication_sink::take_scheduled_deletes_for_test().is_empty(),
"a bucket without replication must not schedule deletes for removed or missing versions"
);
let events = test_recorder::recorded_for_bucket(&bucket);
let announced = events.iter().map(|event| event.version_id).collect::<Vec<_>>();
@@ -12946,6 +12971,97 @@ mod tests {
assert_eq!(remaining[0].version_id, Some(current_version_id));
}
#[tokio::test]
#[serial]
async fn lifecycle_noncurrent_batch_expiry_schedules_only_matching_delete_rules() {
use crate::bucket::metadata::BUCKET_REPLICATION_CONFIG;
let (_disk_paths, ecstore) = setup_test_env().await;
for (prefix, delete_status, should_replicate) in [
("other/", "Enabled", false),
("logs/", "Disabled", false),
("logs/", "Enabled", true),
] {
let bucket = format!("lifecycle-repl-admission-{}", Uuid::new_v4().simple());
let object = "logs/object";
create_test_bucket(&ecstore, &bucket).await;
metadata_sys::update_in(
&ecstore.ctx,
&bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.expect("versioning should be enabled");
let mut versions = Vec::new();
for body in [b"noncurrent".as_slice(), b"current".as_slice()] {
let mut reader = PutObjReader::from_vec(body.to_vec());
let version = ecstore
.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
..Default::default()
},
)
.await
.expect("source version should be created");
versions.push(version.version_id.expect("versioned PUT must have an identity"));
}
let config = format!(
"<ReplicationConfiguration><Role></Role><Rule><ID>delete-rule</ID><Status>Enabled</Status>\
<Prefix>{prefix}</Prefix><Destination><Bucket>arn:test</Bucket></Destination>\
<DeleteReplication><Status>{delete_status}</Status></DeleteReplication>\
</Rule></ReplicationConfiguration>"
);
metadata_sys::update_in(&ecstore.ctx, &bucket, BUCKET_REPLICATION_CONFIG, config.into_bytes())
.await
.expect("replication configuration should be persisted");
let expiry_policy = install_test_expiry_policy(&ecstore, &bucket).await;
let incarnation = ecstore
.bucket_incarnation_id_from_disk(&bucket)
.await
.expect("bucket incarnation should be available");
let _ = replication_sink::take_scheduled_deletes_for_test();
let failed = crate::bucket::lifecycle::object_handlers_common::delete_object_versions(
&ecstore,
&bucket,
&[ObjectToDelete {
object_name: object.to_string(),
version_id: Some(versions[0]),
..Default::default()
}],
lifecycle::Event {
action: IlmAction::DeleteVersionAction,
expiry_policy: Some(expiry_policy.into()),
..Default::default()
},
incarnation,
)
.await;
assert_eq!(failed, 0, "batch expiry should commit");
let scheduled = replication_sink::take_scheduled_deletes_for_test();
assert_eq!(scheduled.len(), usize::from(should_replicate), "prefix={prefix}, delete={delete_status}");
if should_replicate {
assert_eq!(scheduled[0].version_id, Some(versions[0]));
let state = scheduled[0]
.replication_state
.as_ref()
.expect("admitted purge state must survive");
assert_eq!(state.version_purge_status_internal.as_deref(), Some("arn:test=PENDING;"));
assert!(state.purge_targets.contains_key("arn:test"));
}
let current = ecstore
.get_object_info(&bucket, object, &ObjectOptions::default())
.await
.expect("batch expiry must retain the current version");
assert_eq!(current.version_id, Some(versions[1]));
}
}
#[tokio::test]
#[serial]
async fn lifecycle_deletes_only_the_historical_null_version_after_versioning_is_reenabled() {
@@ -13033,6 +13149,14 @@ mod tests {
assert!(!object_infos[null_index].is_latest);
let lifecycle = current_and_noncurrent_expiration_lifecycle();
metadata_sys::update_in(
&ecstore.ctx,
&bucket,
BUCKET_LIFECYCLE_CONFIG,
crate::bucket::utils::serialize(&lifecycle).unwrap(),
)
.await
.unwrap();
let object_opts = object_infos
.iter()
.map(lifecycle::object_opts_from_object_info)
@@ -13203,6 +13327,81 @@ mod tests {
assert_eq!(event.action, IlmAction::NoneAction);
}
#[tokio::test]
#[serial]
async fn lifecycle_exact_immediate_expiry_preserves_guards_and_skips_stale_writes() {
let (paths, ecstore) = setup_test_env().await;
let bucket = format!("lifecycle-exact-expiry-{}", Uuid::new_v4().simple());
let object = "object";
create_test_bucket(&ecstore, &bucket).await;
let oi = ecstore
.put_object(
&bucket,
object,
&mut PutObjReader::from_vec(b"payload".to_vec()),
&ObjectOptions::default(),
)
.await
.expect("create object before configuring expiry");
// Exercise persisted legacy immediate rules without relaxing the S3
// validator, which correctly rejects newly submitted Expiration.Days=0.
let sys = metadata_sys::bucket_metadata_sys_of(&ecstore.ctx).expect("metadata system");
let sys = sys.read().await.clone();
let mut metadata = (*sys.get(&bucket).await.expect("bucket metadata")).clone();
let mut lifecycle = latest_expiration_lifecycle();
lifecycle.rules[0].expiration.as_mut().expect("expiration rule").days = Some(0);
metadata.lifecycle_config_xml = crate::bucket::utils::serialize(&lifecycle).expect("encode legacy policy");
metadata.lifecycle_config = Some(lifecycle);
sys.persist_and_set(metadata).await.expect("persist legacy policy");
let state = install_unconsumed_runtime_expiry_worker(&ecstore, 4).await;
super::enqueue_immediate_expiry(ecstore.clone(), &oi, LcEventSrc::S3PutObject, &ObjectOptions::default()).await;
assert_eq!(state.read().await.stats.pending_tasks(), 1, "exact reads must preserve immediate expiry");
let state = install_unconsumed_runtime_expiry_worker(&ecstore, 4).await;
for path in &paths {
let metadata_path = path.join(&bucket).join(object).join(crate::disk::STORAGE_FORMAT_FILE);
let mut meta = rustfs_filemeta::FileMeta::load(&fs::read(&metadata_path).await.expect("read object metadata"))
.expect("decode object metadata");
let mut version = meta
.get_all_file_info_versions(&bucket, object, true)
.expect("version group")
.versions
.remove(0);
version.metadata.insert("x-amz-object-lock-legal-hold".into(), "ON".into());
meta.add_version(version).expect("persist legal hold");
fs::write(metadata_path, meta.marshal_msg().expect("encode held metadata"))
.await
.expect("write legal hold");
}
super::enqueue_immediate_expiry(ecstore.clone(), &oi, LcEventSrc::S3PutObject, &ObjectOptions::default()).await;
assert_eq!(
state.read().await.stats.pending_tasks(),
0,
"use current lock metadata, not the stale write result"
);
for path in &paths {
fs::write(path.join(&bucket).join(object).join(crate::disk::STORAGE_FORMAT_FILE), b"corrupt")
.await
.expect("inject corrupt metadata");
}
super::enqueue_immediate_expiry(ecstore.clone(), &oi, LcEventSrc::S3PutObject, &ObjectOptions::default()).await;
assert_eq!(state.read().await.stats.pending_tasks(), 0, "read failure must never enqueue expiry");
for path in &paths {
fs::remove_dir_all(path.join(&bucket).join(object))
.await
.expect("simulate concurrent object removal");
}
super::enqueue_immediate_expiry(ecstore.clone(), &oi, LcEventSrc::S3PutObject, &ObjectOptions::default()).await;
assert_eq!(
state.read().await.stats.pending_tasks(),
0,
"confirmed absence must not fall back to the old ObjectInfo"
);
}
#[tokio::test]
#[serial]
async fn lifecycle_expiry_fails_closed_on_corrupt_object_lock_metadata() {
@@ -13312,6 +13511,7 @@ mod tests {
.await
.expect("table data object should be created");
let expiry_policy = install_test_expiry_policy(&ecstore, &bucket).await;
let publication_lock = ecstore
.new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
.await
@@ -13326,6 +13526,7 @@ mod tests {
let mut expiry = tokio::spawn(async move {
let event = crate::bucket::lifecycle::lifecycle::Event {
action: IlmAction::DeleteAction,
expiry_policy: Some(expiry_policy.into()),
..Default::default()
};
let bucket_incarnation_id = expiry_store
@@ -13440,6 +13641,7 @@ mod tests {
queued.size = 1;
let event = lifecycle::Event {
action: IlmAction::DeleteAction,
expiry_policy: Some(install_test_expiry_policy(&ecstore, &bucket).await.into()),
..Default::default()
};
let incarnation = ecstore
@@ -13551,6 +13753,7 @@ mod tests {
.bucket_incarnation_id_from_disk(&bucket)
.await
.expect("bucket incarnation should be available");
let expiry_policy = install_test_expiry_policy(&ecstore, &bucket).await;
let publication_lock = ecstore
.new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
.await
@@ -13573,7 +13776,11 @@ mod tests {
&delete_store,
&delete_bucket,
std::slice::from_ref(&target),
lifecycle::Event::default(),
lifecycle::Event {
action: IlmAction::DeleteVersionAction,
expiry_policy: Some(expiry_policy.into()),
..Default::default()
},
incarnation,
)
.await
@@ -13729,6 +13936,7 @@ mod tests {
let queued_event = crate::bucket::lifecycle::lifecycle::Event {
action: IlmAction::DelMarkerDeleteAllVersionsAction,
rule_id: "delete-marker-history".to_string(),
expiry_policy: get_expiry_configs(&ecstore, &bucket).await.unwrap().lifecycle.map(Into::into),
..Default::default()
};
metadata_sys::update(&bucket, BUCKET_LIFECYCLE_CONFIG, lifecycle_xml(30).into_bytes())
@@ -13846,6 +14054,7 @@ mod tests {
&crate::bucket::lifecycle::lifecycle::Event {
action: IlmAction::DeleteAllVersionsAction,
rule_id: "delete-all-versions".to_string(),
expiry_policy: get_expiry_configs(&ecstore, &bucket).await.unwrap().lifecycle.map(Into::into),
..Default::default()
},
&LcEventSrc::Scanner,
@@ -14032,6 +14241,221 @@ mod tests {
(disk_paths, ecstore)
}
#[tokio::test]
#[serial]
async fn queued_expiry_rejects_withdrawn_or_changed_policy() {
let (_disk_paths, ecstore) = setup_test_env().await;
for case in [
"removed",
"disabled",
"longer-days",
"different-prefix",
"unreadable",
"missing-snapshot",
"unchanged",
] {
let bucket = format!("expiry-policy-{case}");
create_test_bucket(&ecstore, &bucket).await;
metadata_sys::update_in(
&ecstore.ctx,
&bucket,
BUCKET_VERSIONING_CONFIG,
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
)
.await
.unwrap();
for object in ["single", "batch"] {
for age in [40, 2] {
let mut reader = PutObjReader::from_vec(vec![1; 8]);
ecstore
.put_object(
&bucket,
object,
&mut reader,
&ObjectOptions {
versioned: true,
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(age)),
..Default::default()
},
)
.await
.unwrap();
}
}
let policy = current_and_noncurrent_expiration_lifecycle();
metadata_sys::update_in(
&ecstore.ctx,
&bucket,
BUCKET_LIFECYCLE_CONFIG,
crate::bucket::utils::serialize(&policy).unwrap(),
)
.await
.unwrap();
let incarnation = ecstore.bucket_incarnation_id_from_disk(&bucket).await.unwrap();
let state = install_unconsumed_runtime_expiry_worker(&ecstore, 3).await;
for object in ["single", "batch"] {
let versions = ecstore
.clone()
.list_object_versions(&bucket, object, None, None, None, 10)
.await
.unwrap()
.objects;
let opts = versions
.iter()
.map(lifecycle::object_opts_from_object_info)
.collect::<Vec<_>>();
let events = Evaluator::new(Arc::new(policy.clone())).eval(&opts).await.unwrap();
let index = versions.iter().position(|version| !version.is_latest).unwrap();
let mut event = events[index].clone();
assert_eq!(event.action, IlmAction::DeleteVersionAction);
if case == "missing-snapshot" {
event.expiry_policy = None;
}
let queued = if object == "single" {
state
.write()
.await
.enqueue_by_days(&versions[index], &event, &LcEventSrc::Scanner, incarnation)
} else {
state.write().await.enqueue_by_newer_noncurrent(
&bucket,
vec![lifecycle_version_delete_target(&versions[index]).unwrap()],
event,
&LcEventSrc::Scanner,
incarnation,
)
};
assert!(queued);
}
let mut changed = policy.clone();
match case {
"removed" => {
metadata_sys::delete(&bucket, BUCKET_LIFECYCLE_CONFIG).await.unwrap();
}
"disabled" => changed.rules[0].status = ExpirationStatus::from_static(ExpirationStatus::DISABLED),
"longer-days" => {
changed.rules[0]
.noncurrent_version_expiration
.as_mut()
.unwrap()
.noncurrent_days = Some(60)
}
"different-prefix" => changed.rules[0].prefix = Some("keep/".to_string()),
"unreadable" => {
let sys = metadata_sys::bucket_metadata_sys_of(&ecstore.ctx)
.unwrap()
.read()
.await
.clone();
let mut metadata = (*sys.get(&bucket).await.unwrap()).clone();
metadata.lifecycle_config_xml = b"<LifecycleConfiguration>".to_vec();
metadata.lifecycle_config = None;
sys.persist_and_set(metadata).await.unwrap();
}
_ => {}
}
if !matches!(case, "removed" | "unchanged" | "missing-snapshot" | "unreadable") {
metadata_sys::update_in(
&ecstore.ctx,
&bucket,
BUCKET_LIFECYCLE_CONFIG,
crate::bucket::utils::serialize(&changed).unwrap(),
)
.await
.unwrap();
}
let (rx, stats, recovery_notify) = {
let state = state.read().await;
state.tasks_tx[0].try_send(None).unwrap();
(state.tasks_rx[0].clone(), state.stats.clone(), state.recovery_notify.clone())
};
tokio::time::timeout(StdDuration::from_secs(30), async {
let mut receiver = rx.lock().await;
ExpiryState::worker(&mut receiver, Arc::downgrade(&ecstore), stats, recovery_notify).await;
})
.await
.expect("queued expiry worker must drain");
let versions = ecstore
.clone()
.list_object_versions(&bucket, "", None, None, None, 10)
.await
.unwrap()
.objects;
assert_eq!(versions.len(), if case == "unchanged" { 2 } else { 4 }, "case: {case}");
assert_eq!(versions.iter().filter(|version| version.is_latest).count(), 2);
}
}
#[tokio::test]
#[serial]
async fn lifecycle_policy_update_waits_for_admitted_delete_commit() {
let (_disk_paths, ecstore) = setup_test_env().await;
let bucket = "expiry-policy-commit-fence";
create_test_bucket(&ecstore, bucket).await;
let mut reader = PutObjReader::from_vec(b"expired".to_vec());
let object = ecstore
.put_object(
bucket,
"object",
&mut reader,
&ObjectOptions {
mod_time: Some(OffsetDateTime::now_utc() - time::Duration::days(40)),
..Default::default()
},
)
.await
.unwrap();
let policy = install_test_expiry_policy(&ecstore, bucket).await;
let event = eval_action_from_lifecycle(&policy, None, &object).await;
assert_eq!(event.action, IlmAction::DeleteAction);
let incarnation = ecstore.bucket_incarnation_id_from_disk(bucket).await.unwrap();
let barrier = crate::store::DeleteAfterObjectLockSnapshotBarrier::install(bucket);
let deleting_store = ecstore.clone();
let deleting = tokio::spawn(async move {
super::apply_expiry_on_non_transitioned_objects(deleting_store, &object, &event, &LcEventSrc::Scanner, incarnation)
.await
});
tokio::time::timeout(StdDuration::from_secs(30), barrier.wait_until_paused())
.await
.unwrap();
let probe = metadata_sys::ConfigWriteLockProbe::install(bucket);
let mut removing = tokio::spawn(async move { metadata_sys::delete(bucket, BUCKET_LIFECYCLE_CONFIG).await });
probe.wait_until_attempted().await;
assert!(
tokio::time::timeout(StdDuration::from_millis(100), &mut removing)
.await
.is_err(),
"configuration removal must wait for the admitted delete"
);
barrier.release();
assert!(
tokio::time::timeout(StdDuration::from_secs(30), deleting)
.await
.unwrap()
.unwrap()
);
tokio::time::timeout(StdDuration::from_secs(30), removing)
.await
.unwrap()
.unwrap()
.unwrap();
assert!(get_expiry_configs(&ecstore, bucket).await.unwrap().lifecycle.is_none());
assert!(
ecstore
.get_object_info(bucket, "object", &ObjectOptions::default())
.await
.is_err()
);
}
async fn install_test_expiry_policy(ecstore: &Arc<ECStore>, bucket: &str) -> Arc<BucketLifecycleConfiguration> {
let xml = br#"<LifecycleConfiguration><Rule><ID>expire</ID><Status>Enabled</Status><Filter><Prefix></Prefix></Filter><Expiration><Days>0</Days></Expiration><NoncurrentVersionExpiration><NoncurrentDays>0</NoncurrentDays></NoncurrentVersionExpiration></Rule></LifecycleConfiguration>"#;
metadata_sys::update_in(&ecstore.ctx, bucket, BUCKET_LIFECYCLE_CONFIG, xml.to_vec())
.await
.expect("install expiration policy");
get_expiry_configs(ecstore, bucket).await.unwrap().lifecycle.unwrap()
}
async fn create_test_bucket(ecstore: &Arc<ECStore>, bucket: &str) {
ecstore
.make_bucket(bucket, &MakeBucketOptions::default())
@@ -33,13 +33,17 @@ pub async fn delete_object_versions(
api: &Arc<ECStore>,
bucket: &str,
to_del: &[ObjectToDelete],
_lc_event: lifecycle::Event,
lc_event: lifecycle::Event,
bucket_incarnation_id: Uuid,
) -> usize {
if to_del.is_empty() {
return 0;
}
let Some(expiry_policy) = lc_event.expiry_policy else {
return to_del.len();
};
if to_del.iter().any(|target| {
target.version_id.is_none()
|| (target.version_id.is_some_and(|version_id| version_id.is_nil()) && target.expected_identity.is_none())
@@ -104,6 +108,7 @@ pub async fn delete_object_versions(
let mut delete_options = ObjectOptions {
delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)),
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
lifecycle_expiry_policy: Some(expiry_policy.clone()),
..Default::default()
};
delete_options.add_namespace_lock_guard(&publication_guard);
@@ -125,7 +130,11 @@ pub async fn delete_object_versions(
// already happened (backlog#2202).
emit_noncurrent_expiration_event(bucket, target, deleted_obj, false);
}
if deleted_obj.replication_state.is_none() {
if !deleted_obj
.replication_state
.as_ref()
.is_some_and(|state| !state.targets.is_empty() || !state.purge_targets.is_empty())
{
continue;
}
replication_sink::schedule_delete(bucket.to_string(), deleted_obj.clone()).await;
@@ -70,6 +70,11 @@ pub(crate) async fn schedule_delete(bucket: String, delete_object: DeletedObject
ReplicationLifecycleBridge::schedule_delete(bucket, delete_object).await;
}
#[cfg(test)]
pub(crate) fn take_scheduled_deletes_for_test() -> Vec<DeletedObject> {
ReplicationLifecycleBridge::take_scheduled_deletes_for_test()
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
@@ -959,7 +959,7 @@ impl PersistedTierDeleteJournalEntry {
fn into_jentry(self) -> Result<Jentry> {
// Empty `version_id` is a legal sentinel for objects transitioned to an
// unversioned remote tier (see CLAUDE.md: a tier version of `None`/`""`
// unversioned remote tier (see AGENTS.md: a tier version of `None`/`""`
// means the tier bucket is unversioned, so the remote delete is issued
// without a versionId). Only reject entries missing the object or tier
// name, which are always populated for a TRANSITION_COMPLETE object.
@@ -626,7 +626,7 @@ fn mark_scan_truncated_if_needed(
fn is_recoverable_tier_free_version(oi: &ObjectInfo) -> bool {
// A free version transitioned to an unversioned remote tier legally carries an
// empty `version_id` (see CLAUDE.md: a tier version of `None`/`""` means the
// empty `version_id` (see AGENTS.md: a tier version of `None`/`""` means the
// tier bucket is unversioned). The worker path handles that by issuing a
// versionless remote delete, so the recovery gate must not treat an empty
// `version_id` as unrecoverable — the object name and tier are sufficient to
+108 -9
View File
@@ -525,6 +525,15 @@ pub(crate) async fn get_bucket_incarnation_id_in(ctx: &crate::runtime::instance:
sys.get_bucket_incarnation_id_from_disk(bucket).await
}
pub(crate) async fn find_bucket_incarnation_id_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<Option<Uuid>> {
let sys = bucket_metadata_sys_of(ctx)?;
let sys = sys.read().await.clone();
sys.find_bucket_incarnation_id_from_disk(bucket).await
}
/// Directory loss is recoverable only for a committed, non-retired generation.
/// The caller retains the bucket lifecycle fence through the ensuing reconstruction.
pub(crate) async fn validate_bucket_reconstruction_in(
@@ -1018,6 +1027,52 @@ async fn update_quota_if_incarnation_stamped(
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data, updated_at).await
}
/// Rewrite the quota configuration only while the bucket still carries
/// `expected_incarnation_id` and `expected_quota_revision` (its
/// `quota_config_updated_at`), under the same guards and fleet proof as
/// [`update_quota_if_incarnation`].
///
/// The revision check turns a stale read into a typed
/// [`Error::PreconditionFailed`] instead of a lost update: the reservation
/// protocol migration (rustfs/backlog#2904) rewrites a configuration it read
/// from the cache, and an admin save landing in between must win. The system
/// is passed explicitly so the write publishes into the cache the caller
/// reads from.
pub async fn update_quota_with_sys_if_incarnation_and_revision(
sys: Arc<RwLock<BucketMetadataSys>>,
bucket: &str,
data: Vec<u8>,
expected_incarnation_id: Uuid,
expected_quota_revision: OffsetDateTime,
proof: &crate::services::notification_sys::CrossPoolFenceFleetProofToken,
) -> Result<OffsetDateTime> {
let guard = Box::pin(acquire_config_write_guard_for_incarnation(
sys.clone(),
bucket,
Some(expected_incarnation_id),
))
.await?;
if !crate::services::notification_sys::cross_pool_fence_fleet_proof_matches(proof) {
return Err(Error::NamespaceLockQuorumUnavailable {
mode: "quota_capability",
bucket: bucket.to_string(),
object: rustfs_config::QUOTA_CONFIG_FILE.to_string(),
required: 1,
achieved: 0,
});
}
let metadata_sys = sys.read().await.clone();
match Box::pin(metadata_sys.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)).await? {
BucketMetadataAuthority::Authoritative(current) if current.quota_config_updated_at == expected_quota_revision => {}
BucketMetadataAuthority::Authoritative(_) => return Err(Error::PreconditionFailed),
BucketMetadataAuthority::MissingBucket => return Err(Error::BucketNotFound(bucket.to_string())),
BucketMetadataAuthority::Fabricated => {
return Err(Error::other("bucket quota metadata is not authoritative"));
}
}
update_under_config_write_guard(sys, &guard, rustfs_config::QUOTA_CONFIG_FILE, data, None).await
}
pub async fn update_bucket_targets_under_transaction_lock(
guard: &BucketMetadataMutationGuard,
bucket: &str,
@@ -1367,6 +1422,18 @@ pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_with_guard_
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
guard: Option<&rustfs_lock::NamespaceLockGuard>,
) -> Result<(ObjectLockConfigState, Uuid, OffsetDateTime)> {
get_delete_config_from_disk_in(ctx, bucket, guard, None).await
}
/// Validate queued expiration against the same fresh metadata used for Object
/// Lock admission. The caller holds the transaction read lock through commit,
/// so a successful lifecycle update cannot be followed by an old-policy delete.
pub(crate) async fn get_delete_config_from_disk_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
guard: Option<&rustfs_lock::NamespaceLockGuard>,
expiry_policy: Option<&rustfs_lifecycle::ExpiryPolicySnapshot>,
) -> Result<(ObjectLockConfigState, Uuid, OffsetDateTime)> {
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
@@ -1387,6 +1454,13 @@ pub(crate) async fn get_object_lock_config_and_incarnation_from_disk_with_guard_
BucketMetadataAuthority::Authoritative(metadata)
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
{
if let Some(expected) = expiry_policy {
let current = ConfigState::of(&metadata.lifecycle_config_xml, &metadata.lifecycle_config)
.require(bucket, BUCKET_LIFECYCLE_CONFIG)?;
if !expected.matches_configuration(current) {
return Err(Error::PreconditionFailed);
}
}
let object_lock_config_updated_at = metadata.object_lock_config_updated_at;
Ok((
object_lock_config_state_from_authoritative_metadata(&metadata)?,
@@ -2604,6 +2678,11 @@ impl BucketMetadataSys {
}
async fn get_bucket_incarnation_id_from_disk(&self, bucket: &str) -> Result<Uuid> {
let incarnation_id = self.find_bucket_incarnation_id_from_disk(bucket).await?;
Self::require_bucket_incarnation_id(bucket, incarnation_id)
}
async fn find_bucket_incarnation_id_from_disk(&self, bucket: &str) -> Result<Option<Uuid>> {
let transaction_lock = self
.object_store()
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
@@ -2611,7 +2690,7 @@ impl BucketMetadataSys {
let _transaction_guard = transaction_lock
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
.await?;
self.get_bucket_incarnation_id_under_transaction_lock(bucket, &_transaction_guard)
self.find_bucket_incarnation_id_under_transaction_lock(bucket, &_transaction_guard)
.await
}
@@ -2620,14 +2699,26 @@ impl BucketMetadataSys {
bucket: &str,
transaction_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<Uuid> {
let incarnation_id = self
.find_bucket_incarnation_id_under_transaction_lock(bucket, transaction_guard)
.await?;
Self::require_bucket_incarnation_id(bucket, incarnation_id)
}
fn require_bucket_incarnation_id(bucket: &str, incarnation_id: Option<Uuid>) -> Result<Uuid> {
incarnation_id.ok_or_else(|| Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}")))
}
async fn find_bucket_incarnation_id_under_transaction_lock(
&self,
bucket: &str,
transaction_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<Option<Uuid>> {
let incarnation_id = load_bucket_incarnation(self.object_store(), bucket).await?;
if transaction_guard.is_lock_lost() {
return Err(Error::other(format!("bucket incarnation metadata transaction lock was lost: {bucket}")));
}
match incarnation_id {
Some(incarnation_id) if !incarnation_id.is_nil() => Ok(incarnation_id),
_ => Err(Error::other(format!("bucket incarnation metadata is not authoritative: {bucket}"))),
}
Ok(incarnation_id)
}
async fn get_metadata_authority(&self, bucket: &str) -> Result<BucketMetadataAuthority> {
@@ -3095,9 +3186,17 @@ pub mod test_support {
use crate::store::init_local_disks_with_instance_ctx;
pub async fn isolated_store_over_temp_disks() -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
let mut dirs = Vec::with_capacity(4);
let mut endpoints = Vec::with_capacity(4);
for disk_idx in 0..4 {
isolated_store_over_temp_disks_with_count(4).await
}
pub async fn isolated_store_over_temp_disks_with_count(disk_count: usize) -> (Vec<tempfile::TempDir>, Arc<ECStore>) {
assert!(
matches!(disk_count, 4 | 16),
"isolated store fixture requires a registered logical set geometry"
);
let mut dirs = Vec::with_capacity(disk_count);
let mut endpoints = Vec::with_capacity(disk_count);
for disk_idx in 0..disk_count {
let dir = tempfile::tempdir().expect("tempdir should be created");
let mut endpoint =
Endpoint::try_from(dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
@@ -3110,7 +3209,7 @@ pub mod test_support {
let endpoint_pools = EndpointServerPools(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
drives_per_set: disk_count,
endpoints: Endpoints::from(endpoints),
cmd_line: "metadata-sys-cache-test".to_string(),
platform: "test".to_string(),
+638 -18
View File
@@ -12,16 +12,33 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
use super::{
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError, QuotaMigrationSkipReason, QuotaOperation,
QuotaProtocolMigration,
};
use crate::bucket::metadata_sys::{
BucketMetadataSys, update, update_if_incarnation, update_quota_with_sys_if_incarnation_and_revision,
};
use crate::data_usage::get_bucket_usage_memory;
use crate::error::StorageError;
use rustfs_config::QUOTA_CONFIG_FILE;
use rustfs_scanner_metrics::metrics::Metric;
use std::sync::Arc;
use std::time::Instant;
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use std::time::{Duration, Instant};
use time::OffsetDateTime;
use tokio::sync::RwLock;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_QUOTA: &str = "quota";
const EVENT_QUOTA_USAGE_REPORT: &str = "quota_usage_report";
const EVENT_QUOTA_PROTOCOL_MIGRATION: &str = "quota_reservation_protocol_migration";
const METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL: &str = "rustfs_quota_usage_report_unavailable_total";
const METRIC_QUOTA_PROTOCOL_MIGRATIONS_TOTAL: &str = "rustfs_quota_reservation_protocol_migrations_total";
/// A lazy migration attempt that did not settle the bucket is retried no
/// sooner than this; the admin command is not rate limited.
const LAZY_MIGRATION_RETRY_INTERVAL: Duration = Duration::from_secs(300);
pub struct QuotaChecker {
metadata_sys: Arc<RwLock<BucketMetadataSys>>,
@@ -74,7 +91,15 @@ impl QuotaChecker {
Some(q) => q,
};
let current_usage = self.get_real_time_usage(bucket).await?;
// A durable quota is admitted by the reservation ledger at commit, so
// scanner usage is reporting input here: an unavailable lookup is
// reported as absent instead of failing the write (rustfs/backlog#2904).
// The snapshot protocol still admits from this value and fails closed.
let current_usage = if uses_durable_reservations {
self.report_only_usage(bucket).await
} else {
Some(self.get_real_time_usage(bucket).await?)
};
// The reporting path projects this operation; storage mutations reserve it at commit.
let admission_size = if uses_durable_reservations && !force_usage_calculation {
@@ -82,27 +107,23 @@ impl QuotaChecker {
} else {
operation_size
};
let expected_usage = match operation {
let expected_usage = current_usage.map(|current_usage| match operation {
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
current_usage.saturating_add(admission_size)
}
QuotaOperation::DeleteObject => current_usage.saturating_sub(operation_size),
};
});
let allowed = match operation {
QuotaOperation::PutObject | QuotaOperation::PostObject | QuotaOperation::CopyObject => {
quota_config.check_operation_allowed(current_usage, admission_size)
current_usage.is_none_or(|current_usage| quota_config.check_operation_allowed(current_usage, admission_size))
}
QuotaOperation::DeleteObject => true,
};
let remaining = if quota_limit >= expected_usage {
Some(quota_limit - expected_usage)
} else {
Some(0)
};
let remaining = expected_usage.map(|expected_usage| quota_limit.saturating_sub(expected_usage));
if !allowed {
if let (false, Some(current_usage)) = (allowed, current_usage) {
warn!(
"Quota exceeded for bucket: {}, current: {}, limit: {}, attempted: {}",
bucket, current_usage, quota_limit, operation_size
@@ -111,7 +132,7 @@ impl QuotaChecker {
let result = QuotaCheckResult {
allowed,
current_usage: Some(current_usage),
current_usage,
quota_limit: Some(quota_limit),
operation_size,
remaining,
@@ -152,6 +173,10 @@ impl QuotaChecker {
reason: format!("Failed to parse quota config: {}", e),
})?;
if quota.needs_reservation_protocol_migration() {
self.schedule_reservation_protocol_migration(bucket).await;
}
Ok(quota)
}
@@ -219,9 +244,146 @@ impl QuotaChecker {
}
let quota = self.get_quota_config(bucket).await?;
let current_usage = self.get_real_time_usage(bucket).await?;
let current_usage = if quota.uses_durable_reservations() {
self.report_only_usage(bucket).await
} else {
Some(self.get_real_time_usage(bucket).await?)
};
Ok((quota, Some(current_usage)))
Ok((quota, current_usage))
}
/// Scanner usage for a bucket the reservation ledger admits is reporting
/// input only, so a failed lookup degrades to "unknown" and is counted
/// instead of becoming an error the write path would fail on.
async fn report_only_usage(&self, bucket: &str) -> Option<u64> {
match self.get_real_time_usage(bucket).await {
Ok(usage) => Some(usage),
Err(err) => {
metrics::counter!(METRIC_QUOTA_USAGE_REPORT_UNAVAILABLE_TOTAL).increment(1);
debug!(
event = EVENT_QUOTA_USAGE_REPORT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
state = "usage_unknown",
bucket = %bucket,
error = %err,
"durable quota usage report degraded"
);
None
}
}
}
/// Re-save `bucket`'s quota under the sharded durable reservation
/// protocol (rustfs/backlog#2904). The limit and type are unchanged. The
/// write takes the incarnation- and revision-checked quota save path with
/// the fleet capability proof, so a mixed-version fleet fails the attempt
/// and a concurrent quota edit or bucket replacement leaves the newer
/// configuration untouched.
pub async fn migrate_reservation_protocol(&self, bucket: &str) -> Result<QuotaProtocolMigration, QuotaError> {
let (meta, _) = self
.metadata_sys
.read()
.await
.get_config(bucket)
.await
.map_err(QuotaError::StorageError)?;
if meta.quota_config_json.is_empty() {
return Ok(QuotaProtocolMigration::NoQuota);
}
let quota: BucketQuota = serde_json::from_slice(&meta.quota_config_json).map_err(|e| QuotaError::InvalidConfig {
reason: format!("Failed to parse quota config: {}", e),
})?;
if let Some(reservation_protocol) = quota.reservation_protocol {
return Ok(if quota.uses_durable_reservations() {
QuotaProtocolMigration::AlreadyDurable { reservation_protocol }
} else {
QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::UnsupportedReservationProtocol,
}
});
}
let Some(migrated) = quota.migrated_to_sharded_reservations() else {
return Ok(QuotaProtocolMigration::NoQuota);
};
if !meta.bucket_incarnation_sidecar || meta.bucket_incarnation_id.is_nil() {
return Err(QuotaError::StorageError(StorageError::other(
"bucket incarnation metadata is not authoritative",
)));
}
// Same gate as the admin save: a durable protocol must not reach a
// fleet that still has nodes that would fail closed on it.
let proof = crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().ok_or_else(|| {
QuotaError::StorageError(StorageError::NamespaceLockQuorumUnavailable {
mode: "quota_capability",
bucket: bucket.to_string(),
object: QUOTA_CONFIG_FILE.to_string(),
required: 1,
achieved: 0,
})
})?;
let json_data = serde_json::to_vec(&migrated).map_err(|e| QuotaError::InvalidConfig {
reason: format!("Failed to serialize quota config: {}", e),
})?;
let start_time = Instant::now();
let saved = update_quota_with_sys_if_incarnation_and_revision(
Arc::clone(&self.metadata_sys),
bucket,
json_data,
meta.bucket_incarnation_id,
meta.quota_config_updated_at,
&proof,
)
.await;
match saved {
Ok(updated_at) => {
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(QuotaProtocolMigration::Migrated {
reservation_protocol: QUOTA_RESERVATION_PROTOCOL_V2,
updated_at,
})
}
Err(StorageError::PreconditionFailed) => Ok(QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::QuotaRevisionChanged,
}),
Err(StorageError::BucketNotFound(_)) => Ok(QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::BucketIncarnationChanged,
}),
Err(err) => Err(QuotaError::StorageError(err)),
}
}
/// Lazy half of the migration: the first quota read of a bucket still on
/// the snapshot protocol re-saves it under the sharded durable protocol
/// in the background; the read itself is neither blocked nor failed by
/// it. Only the metadata system registered on the store's instance
/// context migrates, so the rewrite publishes into the cache every
/// reader of this store consults; a checker over an ad-hoc system
/// (tooling, tests) only reads. An attempt that does not settle the
/// bucket is retried no sooner than `LAZY_MIGRATION_RETRY_INTERVAL`.
async fn schedule_reservation_protocol_migration(&self, bucket: &str) {
let registered = self.metadata_sys.read().await.object_store().ctx.bucket_metadata_sys();
if !registered.is_some_and(|registered| Arc::ptr_eq(&registered, &self.metadata_sys)) {
return;
}
if !lazy_migration_attempt_due(bucket) {
return;
}
let checker = Self::new(Arc::clone(&self.metadata_sys));
let bucket = bucket.to_string();
tokio::spawn(async move {
let outcome = checker.migrate_reservation_protocol(&bucket).await;
record_reservation_protocol_migration(&bucket, "lazy", &outcome);
if matches!(
outcome,
Ok(QuotaProtocolMigration::Migrated { .. }
| QuotaProtocolMigration::AlreadyDurable { .. }
| QuotaProtocolMigration::NoQuota)
) {
lazy_migration_attempt_settled(&bucket);
}
});
}
pub async fn bucket_exists(&self, bucket: &str) -> bool {
@@ -262,10 +424,107 @@ impl QuotaChecker {
}
}
/// One entry per bucket whose lazy migration is in flight or did not settle;
/// the entry is dropped once the bucket settles, so the map is bounded by the
/// number of buckets still on the snapshot protocol.
fn lazy_migration_attempts() -> &'static Mutex<HashMap<String, Instant>> {
static ATTEMPTS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
ATTEMPTS.get_or_init(Mutex::default)
}
fn lazy_migration_attempt_due(bucket: &str) -> bool {
let mut attempts = lazy_migration_attempts().lock().unwrap_or_else(PoisonError::into_inner);
let now = Instant::now();
if attempts
.get(bucket)
.is_some_and(|last| now.duration_since(*last) < LAZY_MIGRATION_RETRY_INTERVAL)
{
return false;
}
attempts.insert(bucket.to_string(), now);
true
}
fn lazy_migration_attempt_settled(bucket: &str) {
lazy_migration_attempts()
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(bucket);
}
#[cfg(test)]
fn lazy_migration_attempt_pending(bucket: &str) -> bool {
lazy_migration_attempts()
.lock()
.unwrap_or_else(PoisonError::into_inner)
.contains_key(bucket)
}
/// One log line and one counter per migration attempt; `trigger` names the
/// caller (`lazy` from the quota read path, `admin` from the admin command).
pub fn record_reservation_protocol_migration(
bucket: &str,
trigger: &'static str,
outcome: &Result<QuotaProtocolMigration, QuotaError>,
) {
let result = match outcome {
Ok(QuotaProtocolMigration::Migrated { .. }) => "migrated",
Ok(QuotaProtocolMigration::AlreadyDurable { .. }) | Ok(QuotaProtocolMigration::NoQuota) => "noop",
Ok(QuotaProtocolMigration::Skipped { .. }) => "skipped",
Err(_) => "failed",
};
metrics::counter!(METRIC_QUOTA_PROTOCOL_MIGRATIONS_TOTAL, "trigger" => trigger, "result" => result).increment(1);
match outcome {
Ok(QuotaProtocolMigration::Migrated {
reservation_protocol, ..
}) => info!(
event = EVENT_QUOTA_PROTOCOL_MIGRATION,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
result,
trigger,
bucket = %bucket,
reservation_protocol,
"quota reservation protocol migrated"
),
Ok(QuotaProtocolMigration::Skipped { reason }) => debug!(
event = EVENT_QUOTA_PROTOCOL_MIGRATION,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
result,
trigger,
bucket = %bucket,
reason = reason.as_str(),
"quota reservation protocol migration skipped"
),
Ok(_) => debug!(
event = EVENT_QUOTA_PROTOCOL_MIGRATION,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
result,
trigger,
bucket = %bucket,
"quota reservation protocol migration not needed"
),
Err(err) => warn!(
event = EVENT_QUOTA_PROTOCOL_MIGRATION,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_QUOTA,
result,
trigger,
bucket = %bucket,
error = %err,
"quota reservation protocol migration failed"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
use crate::bucket::quota::QuotaType;
use crate::error::StorageError;
use serial_test::serial;
use uuid::Uuid;
@@ -378,6 +637,367 @@ mod tests {
);
}
async fn isolated_durable_quota_store() -> (Vec<tempfile::TempDir>, Arc<crate::store::ECStore>, Arc<RwLock<BucketMetadataSys>>)
{
let (dirs, store) = isolated_store_over_temp_disks().await;
crate::bucket::metadata_sys::init_bucket_metadata_sys(Arc::clone(&store), Vec::new()).await;
let sys = crate::bucket::metadata_sys::bucket_metadata_sys_of(&store.ctx).expect("scoped metadata system");
(dirs, store, sys)
}
async fn create_bucket_with_quota_json(store: &Arc<crate::store::ECStore>, bucket: &str, quota_json: Vec<u8>) {
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
store
.make_bucket(bucket, &MakeBucketOptions::default())
.await
.expect("create quota test bucket");
store
.update_bucket_metadata_config(bucket, rustfs_config::QUOTA_CONFIG_FILE, quota_json)
.await
.expect("persist quota config");
}
/// Regression (rustfs/backlog#2904, decision D3 in rustfs/backlog#2872): a
/// bucket whose quota is admitted by the durable reservation ledger must
/// not fail its write because scanner usage is unavailable. The checker
/// reports `current_usage = None` instead of `UsageUnavailable`, while the
/// ledger still rejects an over-limit write and a bucket without a
/// reservation protocol keeps failing closed.
#[tokio::test]
#[serial]
async fn durable_quota_check_reports_unknown_usage_without_failing_the_write() {
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::storage_api_contracts::object::ObjectIO as _;
let (_dirs, store, sys) = isolated_durable_quota_store().await;
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = format!("quota-durable-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(
&store,
&bucket,
serde_json::to_vec(&BucketQuota::new_sharded(4096)).expect("sharded quota should encode"),
)
.await;
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
let result = checker
.check_quota(&bucket, QuotaOperation::PutObject, 512)
.await
.expect("a ledger-admitted bucket must not fail the check without scanner usage");
assert!(result.allowed);
assert!(result.uses_durable_reservations);
assert_eq!(result.quota_limit, Some(4096));
assert_eq!(result.current_usage, None, "unknown usage is reported as absent, never as zero");
assert_eq!(result.remaining, None);
let (_, usage) = checker
.get_quota_stats(&bucket)
.await
.expect("stats for a ledger-admitted bucket degrade to an absent usage");
assert_eq!(usage, None);
// Negative control: the ledger, not the scanner, still refuses an
// over-limit write with the same error.
let mut too_large = PutObjReader::from_vec(vec![0x5a; 8192]);
let err = store
.put_object(&bucket, "too-large", &mut too_large, &ObjectOptions::default())
.await
.expect_err("the ledger must reject a write above the quota limit");
assert!(matches!(err, StorageError::QuotaExceeded { .. }), "{err:?}");
let mut fits = PutObjReader::from_vec(vec![0x5a; 512]);
store
.put_object(&bucket, "fits", &mut fits, &ObjectOptions::default())
.await
.expect("a write within the quota limit commits through the ledger");
// A bucket without a reservation protocol keeps the fail-closed path.
let legacy = format!("quota-legacy-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &legacy, br#"{"quota":4096,"quota_type":"Hard"}"#.to_vec()).await;
assert!(matches!(
checker.check_quota(&legacy, QuotaOperation::PutObject, 512).await,
Err(QuotaError::UsageUnavailable { .. })
));
assert!(matches!(checker.get_quota_stats(&legacy).await, Err(QuotaError::UsageUnavailable { .. })));
crate::data_usage::prepare_bucket_usage_for_namespace_change(&bucket, None)
.await
.expect("test usage cache cleanup should succeed");
crate::data_usage::invalidate_data_usage_snapshot_cache().await;
}
fn legacy_quota_json(limit: u64) -> Vec<u8> {
format!(r#"{{"quota":{limit},"quota_type":"Hard","created_at":"2024-01-01T00:00:00Z"}}"#).into_bytes()
}
async fn persisted_quota(store: &Arc<crate::store::ECStore>, bucket: &str) -> BucketQuota {
let (quota, _, _) = crate::bucket::metadata_sys::get_quota_config_and_incarnation_from_disk_in(&store.ctx, bucket)
.await
.expect("quota metadata should load from disk");
quota.expect("a quota should be persisted")
}
/// rustfs/backlog#2904: a quota saved before the reservation protocols is
/// re-saved under the sharded durable protocol with the same limit, type
/// and audit timestamp; durable and unknown protocols are left alone.
#[tokio::test]
#[serial]
async fn legacy_quota_migrates_to_sharded_reservation_protocol_once() {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = format!("quota-migrate-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
let outcome = checker
.migrate_reservation_protocol(&bucket)
.await
.expect("migration should succeed");
assert!(
matches!(
outcome,
QuotaProtocolMigration::Migrated {
reservation_protocol: QUOTA_RESERVATION_PROTOCOL_V2,
..
}
),
"{outcome:?}"
);
let migrated = persisted_quota(&store, &bucket).await;
assert!(migrated.uses_sharded_reservations());
assert_eq!(migrated.quota, Some(4096));
assert_eq!(migrated.quota_type, QuotaType::Hard);
assert_eq!(migrated.created_at.map(|at| at.unix_timestamp()), Some(1_704_067_200));
assert_eq!(
checker.get_quota_config(&bucket).await.expect("cached quota should load"),
migrated,
"the rewrite publishes into the registered cache"
);
assert_eq!(
checker
.migrate_reservation_protocol(&bucket)
.await
.expect("second attempt should succeed"),
QuotaProtocolMigration::AlreadyDurable {
reservation_protocol: QUOTA_RESERVATION_PROTOCOL_V2
}
);
let no_quota = format!("quota-none-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(
&store,
&no_quota,
serde_json::to_vec(&BucketQuota::new(None)).expect("empty quota should encode"),
)
.await;
assert_eq!(
checker
.migrate_reservation_protocol(&no_quota)
.await
.expect("no quota is a no-op"),
QuotaProtocolMigration::NoQuota
);
let unsupported = format!("quota-unsupported-{}", Uuid::new_v4().simple());
let unsupported_json = br#"{"quota":0,"quota_type":"Hard","reservation_protocol":3,"reservation_quota":2048}"#.to_vec();
create_bucket_with_quota_json(&store, &unsupported, unsupported_json.clone()).await;
assert_eq!(
checker
.migrate_reservation_protocol(&unsupported)
.await
.expect("an unknown protocol is skipped, never rewritten"),
QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::UnsupportedReservationProtocol
}
);
let (raw, _) = sys.read().await.get_config(&unsupported).await.expect("cached metadata");
assert_eq!(raw.quota_config_json, unsupported_json);
}
/// The guarded save refuses a replaced bucket and a quota edited since the
/// cached read; the migration reports the latter as a skip and leaves the
/// newer configuration for the next attempt.
#[tokio::test]
#[serial]
async fn quota_protocol_migration_requires_matching_incarnation_and_revision() {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = format!("quota-cas-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
let (meta, _) = sys.read().await.get_config(&bucket).await.expect("cached metadata");
let proof = crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().expect("fleet proof is installed");
let data = serde_json::to_vec(&BucketQuota::new_sharded(4096)).expect("sharded quota should encode");
let err = update_quota_with_sys_if_incarnation_and_revision(
Arc::clone(&sys),
&bucket,
data.clone(),
Uuid::new_v4(),
meta.quota_config_updated_at,
&proof,
)
.await
.expect_err("a different incarnation must not be rewritten");
assert!(matches!(err, StorageError::BucketNotFound(_)), "{err:?}");
let err = update_quota_with_sys_if_incarnation_and_revision(
Arc::clone(&sys),
&bucket,
data,
meta.bucket_incarnation_id,
meta.quota_config_updated_at + time::Duration::seconds(1),
&proof,
)
.await
.expect_err("a different quota revision must not be rewritten");
assert!(matches!(err, StorageError::PreconditionFailed), "{err:?}");
assert_eq!(persisted_quota(&store, &bucket).await.quota, Some(4096));
assert!(persisted_quota(&store, &bucket).await.needs_reservation_protocol_migration());
// Model a peer whose cache missed a newer save: the migration reads
// the stale entry, the guarded write sees the newer revision, and the
// newer configuration stays in place.
let (stale, _) = sys.read().await.get_config(&bucket).await.expect("cached metadata");
store
.update_bucket_metadata_config(&bucket, QUOTA_CONFIG_FILE, legacy_quota_json(8192))
.await
.expect("newer quota save");
sys.read().await.set(bucket.clone(), stale).await;
assert_eq!(
checker
.migrate_reservation_protocol(&bucket)
.await
.expect("a stale read is a skip, not a failure"),
QuotaProtocolMigration::Skipped {
reason: QuotaMigrationSkipReason::QuotaRevisionChanged
}
);
let on_disk = persisted_quota(&store, &bucket).await;
assert_eq!(on_disk.quota, Some(8192));
assert!(
on_disk.needs_reservation_protocol_migration(),
"the newer save is left for the next attempt"
);
}
/// The first quota read through the instance's registered metadata
/// system migrates the bucket in the background and still answers with
/// the configuration it found; a checker over an ad-hoc system only reads.
#[tokio::test]
#[serial]
async fn lazy_quota_protocol_migration_runs_from_the_quota_read_path() {
let (_dirs, store, sys) = isolated_durable_quota_store().await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = format!("quota-lazy-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(4096)).await;
let first = checker.get_quota_config(&bucket).await.expect("legacy quota should load");
assert!(
first.needs_reservation_protocol_migration(),
"the triggering read answers with what it found"
);
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let on_disk = persisted_quota(&store, &bucket).await;
if on_disk.uses_sharded_reservations() {
assert_eq!(on_disk.quota, Some(4096));
break;
}
assert!(Instant::now() < deadline, "lazy migration did not land");
tokio::time::sleep(Duration::from_millis(50)).await;
}
let settled = Instant::now() + Duration::from_secs(10);
while lazy_migration_attempt_pending(&bucket) {
assert!(Instant::now() < settled, "a migrated bucket must leave the retry map");
tokio::time::sleep(Duration::from_millis(20)).await;
}
let ad_hoc = QuotaChecker::new(Arc::new(RwLock::new(BucketMetadataSys::new(Arc::clone(&store)))));
let other = format!("quota-adhoc-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &other, legacy_quota_json(4096)).await;
assert!(
ad_hoc
.get_quota_config(&other)
.await
.expect("legacy quota should load")
.needs_reservation_protocol_migration()
);
assert!(
!lazy_migration_attempt_pending(&other),
"an ad-hoc metadata system never schedules a migration"
);
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(persisted_quota(&store, &other).await.needs_reservation_protocol_migration());
}
/// Migration while writes are in flight: objects committed under the
/// snapshot protocol before and around the rewrite are accounted exactly
/// once by the sharded ledger's exact bootstrap, so the limit holds to
/// the byte afterwards. (Under `cfg(test)` a handler-supplied snapshot
/// admission short-circuits `begin`, so every concurrent write here takes
/// the snapshot path; the bootstrap accounting is what is verified.)
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[serial]
async fn concurrent_puts_across_protocol_migration_are_accounted_exactly_once() {
use crate::object_api::{ObjectOptions, PutObjReader};
use crate::storage_api_contracts::object::ObjectIO as _;
const OBJECTS: u64 = 8;
const SIZE: u64 = 256;
const SLACK: u64 = 64;
const LIMIT: u64 = OBJECTS * SIZE + SLACK;
let (_dirs, store, sys) = isolated_durable_quota_store().await;
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let bucket = format!("quota-race-{}", Uuid::new_v4().simple());
create_bucket_with_quota_json(&store, &bucket, legacy_quota_json(LIMIT)).await;
let mut writers = Vec::with_capacity(OBJECTS as usize);
for index in 0..OBJECTS {
let store = Arc::clone(&store);
let bucket = bucket.clone();
writers.push(tokio::spawn(async move {
// The admission a request handler supplies on the snapshot
// protocol; ignored once the ledger owns admission.
let mut opts = ObjectOptions::default();
assert!(opts.set_quota_admission(0, LIMIT));
let mut body = PutObjReader::from_vec(vec![0x5a; SIZE as usize]);
store.put_object(&bucket, &format!("object-{index}"), &mut body, &opts).await
}));
}
let migration = {
let checker = QuotaChecker::new(Arc::clone(&sys));
let bucket = bucket.clone();
tokio::spawn(async move { checker.migrate_reservation_protocol(&bucket).await })
};
for writer in writers {
writer
.await
.expect("writer task should not panic")
.expect("every write fits the limit");
}
let outcome = migration
.await
.expect("migration task should not panic")
.expect("migration should succeed");
assert!(matches!(outcome, QuotaProtocolMigration::Migrated { .. }), "{outcome:?}");
let mut over = PutObjReader::from_vec(vec![0x5a; (SLACK + 1) as usize]);
let err = store
.put_object(&bucket, "over", &mut over, &ObjectOptions::default())
.await
.expect_err("one byte above the remaining quota must be rejected");
assert!(matches!(err, StorageError::QuotaExceeded { .. }), "{err:?}");
let mut exact = PutObjReader::from_vec(vec![0x5a; SLACK as usize]);
store
.put_object(&bucket, "exact", &mut exact, &ObjectOptions::default())
.await
.expect("exactly the remaining bytes fit");
}
#[tokio::test]
async fn test_quota_check_no_limit() {
let result = QuotaCheckResult {
+59
View File
@@ -164,6 +164,26 @@ impl BucketQuota {
.is_some_and(|version| !matches!(version, QUOTA_RESERVATION_PROTOCOL_V1 | QUOTA_RESERVATION_PROTOCOL_V2))
}
/// A limit saved before the reservation protocols existed: its writes
/// are still admitted from scanner usage snapshots. Durable and unknown
/// future protocols are never rewritten.
pub fn needs_reservation_protocol_migration(&self) -> bool {
self.quota.is_some() && self.reservation_protocol.is_none()
}
/// The same limit and type re-expressed under the sharded durable
/// reservation protocol; `created_at` is kept for audit continuity.
/// `None` when the quota is not a migration candidate.
pub fn migrated_to_sharded_reservations(&self) -> Option<Self> {
self.needs_reservation_protocol_migration().then(|| Self {
quota: self.quota,
quota_type: self.quota_type.clone(),
reservation_protocol: Some(QUOTA_RESERVATION_PROTOCOL_V2),
created_at: self.created_at,
updated_at: self.updated_at,
})
}
pub fn check_operation_allowed(&self, current_usage: u64, operation_size: u64) -> bool {
if operation_size == 0 {
return true;
@@ -200,6 +220,45 @@ pub enum QuotaOperation {
DeleteObject,
}
/// Outcome of re-saving one bucket's quota under the sharded durable
/// reservation protocol (rustfs/backlog#2904).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QuotaProtocolMigration {
/// No quota limit is configured; nothing to migrate.
NoQuota,
/// The quota already uses a durable reservation protocol.
AlreadyDurable { reservation_protocol: u32 },
/// The quota was re-saved under the sharded protocol at this revision.
Migrated {
reservation_protocol: u32,
updated_at: OffsetDateTime,
},
/// The quota was left untouched for the stated reason.
Skipped { reason: QuotaMigrationSkipReason },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuotaMigrationSkipReason {
/// A reservation protocol this node does not understand; rewriting it
/// would downgrade a newer fleet's quota.
UnsupportedReservationProtocol,
/// The quota configuration changed between the cached read and the
/// guarded write; the newer configuration wins.
QuotaRevisionChanged,
/// The bucket was replaced or removed since the cached read.
BucketIncarnationChanged,
}
impl QuotaMigrationSkipReason {
pub fn as_str(self) -> &'static str {
match self {
Self::UnsupportedReservationProtocol => "unsupported_reservation_protocol",
Self::QuotaRevisionChanged => "quota_revision_changed",
Self::BucketIncarnationChanged => "bucket_incarnation_changed",
}
}
}
#[derive(Debug, Error)]
pub enum QuotaError {
#[error("Bucket quota exceeded: current={current}, limit={limit}, operation={operation}")]
+3 -51
View File
@@ -419,7 +419,6 @@ pub(crate) struct QuotaContext {
quota_revision: Option<OffsetDateTime>,
quota_limit: Option<u64>,
reservation_protocol: Option<u32>,
capability_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
snapshot_admission: Option<QuotaAdmission>,
legacy_data_movement: bool,
metadata_guard: Option<Arc<NamespaceLockGuard>>,
@@ -479,7 +478,6 @@ impl QuotaContext {
reservation: reservation.clone(),
};
let metadata_guard = self.metadata_guard;
let capability_proof = self.capability_proof;
tokio::spawn(async move {
reap_stale_reservations(Arc::clone(&store), &ledger_data.bucket, &ledger_data.ledger_object).await?;
@@ -526,7 +524,6 @@ impl QuotaContext {
ledger: Some(ReservationLedgerData::Single(ledger_data)),
operation_guard: Some(operation_guard),
metadata_guard,
capability_proof,
state: ReservationState::Pending,
})
})
@@ -555,7 +552,6 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
commit_started: false,
};
let metadata_guard = context.metadata_guard;
let capability_proof = context.capability_proof;
let data = ShardedReservationData {
store: Arc::clone(&store),
bucket: context.bucket,
@@ -640,7 +636,6 @@ async fn reserve_sharded(context: QuotaContext, old_size: u64, new_size: u64) ->
ledger: Some(ReservationLedgerData::Sharded(data)),
operation_guard: Some(operation_guard),
metadata_guard,
capability_proof,
state: ReservationState::Pending,
});
}
@@ -725,20 +720,12 @@ impl ReservationLedgerData {
Self::Sharded(data) => &data.bucket,
}
}
fn error_object(&self) -> &str {
match self {
Self::Single(data) => &data.ledger_object,
Self::Sharded(data) => &data.shard_object,
}
}
}
pub(crate) struct QuotaReservation {
ledger: Option<ReservationLedgerData>,
operation_guard: Option<NamespaceLockGuard>,
metadata_guard: Option<Arc<NamespaceLockGuard>>,
capability_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
state: ReservationState,
}
@@ -756,7 +743,6 @@ impl QuotaReservation {
ledger: None,
operation_guard: None,
metadata_guard,
capability_proof: None,
state: ReservationState::Pending,
}
}
@@ -766,17 +752,7 @@ impl QuotaReservation {
|| self.metadata_guard.as_ref().is_some_and(|guard| guard.is_lock_lost())
}
pub(crate) fn capability_proof_matches(&self) -> bool {
self.capability_proof
.as_ref()
.is_none_or(crate::services::notification_sys::cross_pool_fence_fleet_proof_matches)
}
pub(crate) async fn mark_commit_started(&mut self) -> Result<()> {
if !self.capability_proof_matches() {
let ledger = self.ledger.as_ref().ok_or(StorageError::PartMissingOrCorrupt)?;
return Err(quota_capability_error(ledger.bucket(), ledger.error_object()));
}
if let Some(ledger) = self.ledger.as_ref() {
mark_commit_started(ledger).await?;
}
@@ -864,7 +840,6 @@ pub(crate) async fn begin(
quota_revision: None,
quota_limit: None,
reservation_protocol: None,
capability_proof: None,
snapshot_admission: None,
legacy_data_movement: false,
metadata_guard: None,
@@ -883,7 +858,6 @@ pub(crate) async fn begin(
quota_revision: None,
quota_limit: Some(snapshot_admission.quota_limit()),
reservation_protocol: None,
capability_proof: None,
snapshot_admission: Some(snapshot_admission),
legacy_data_movement: false,
metadata_guard: None,
@@ -902,7 +876,6 @@ pub(crate) async fn begin(
quota_revision: None,
quota_limit: None,
reservation_protocol: None,
capability_proof: None,
snapshot_admission: None,
legacy_data_movement: false,
metadata_guard: None,
@@ -994,19 +967,9 @@ pub(crate) async fn begin(
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_POLICY_EVALUATE,
policy_evaluate_started,
);
let capability_proof_started = rustfs_io_metrics::put_stage_timer();
let capability_proof = if durable_quota.is_some() {
Some(
crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof()
.ok_or_else(|| quota_capability_error(bucket, &ledger_object(bucket)))?,
)
} else {
None
};
rustfs_io_metrics::record_put_object_stage_duration_from(
rustfs_io_metrics::PUT_STAGE_PUT_OBJECT_QUOTA_BEGIN_CAPABILITY_PROOF,
capability_proof_started,
);
// Durable writes are fenced by a write quorum of participating disks that
// enforce the configured topology. Fleet-wide proof remains a configuration gate;
// losing an unrelated peer must not revoke an admitted object write.
let durable_quota_limit = durable_quota.and_then(|quota| quota.quota);
let snapshot_admission_started = rustfs_io_metrics::put_stage_timer();
let reservation_protocol = durable_quota.and_then(|quota| quota.reservation_protocol);
@@ -1067,7 +1030,6 @@ pub(crate) async fn begin(
quota_revision: Some(quota_revision),
quota_limit,
reservation_protocol,
capability_proof,
snapshot_admission,
legacy_data_movement,
metadata_guard: Some(metadata_guard),
@@ -1085,16 +1047,6 @@ fn quota_cache_reason(quota: Option<&crate::bucket::quota::BucketQuota>) -> &'st
}
}
fn quota_capability_error(bucket: &str, object: &str) -> StorageError {
StorageError::NamespaceLockQuorumUnavailable {
mode: "quota_capability",
bucket: bucket.to_string(),
object: object.to_string(),
required: 1,
achieved: 0,
}
}
pub(crate) async fn replaced_logical_size(set_disks: &SetDisks, bucket: &str, object: &str, opts: &ObjectOptions) -> Result<u64> {
if opts.versioned && !opts.version_suspended && opts.version_id.is_none() {
return Ok(0);
@@ -3383,7 +3383,7 @@ async fn queue_replicate_deletes(batch: ReplicationHealResyncDeletes) -> Replica
#[cfg(test)]
mod tests {
use super::super::replication_filemeta_boundary::ReplicateTargetDecision;
use super::super::replication_filemeta_boundary::{ReplicateTargetDecision, ReplicationState, VersionPurgeStatusType};
use super::super::replication_resync_boundary::{decode_mrf_file, encode_mrf_file, encode_resync_file};
use super::super::replication_storage_boundary::{
DeletedObject, FileInfo, GetObjectReader, HTTPRangeSpec, ListOperations, ObjectIO, ObjectOperations, PutObjReader,
@@ -3420,6 +3420,7 @@ mod tests {
hold_first_read: AtomicBool,
allow_first_read: Notify,
read_count: AtomicUsize,
delete_storage_calls: AtomicUsize,
reported_size: StdMutex<Option<i64>>,
stream_read_bytes: Arc<AtomicUsize>,
write_count: AtomicUsize,
@@ -3628,6 +3629,7 @@ mod tests {
_object: &str,
_opts: &Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error> {
self.shared.delete_storage_calls.fetch_add(1, Ordering::SeqCst);
Err(EcstoreError::NotImplemented)
}
@@ -3669,6 +3671,7 @@ mod tests {
_object: &str,
_opts: Self::ObjectOptions,
) -> Result<Self::ObjectInfo, Self::Error> {
self.shared.delete_storage_calls.fetch_add(1, Ordering::SeqCst);
Err(EcstoreError::NotImplemented)
}
@@ -3790,6 +3793,7 @@ mod tests {
type NamespaceLock = rustfs_lock::NamespaceLockWrapper;
async fn new_ns_lock(&self, bucket: &str, object: &str) -> Result<Self::NamespaceLock, Self::Error> {
self.shared.delete_storage_calls.fetch_add(1, Ordering::SeqCst);
let lock =
rustfs_lock::NamespaceLock::with_local_manager("load-resync-test".to_string(), self.shared.lock_manager.clone());
Ok(rustfs_lock::NamespaceLockWrapper::new(
@@ -4166,6 +4170,7 @@ mod tests {
hold_first_read: AtomicBool::new(false),
allow_first_read: Notify::new(),
read_count: AtomicUsize::new(0),
delete_storage_calls: AtomicUsize::new(0),
reported_size: StdMutex::new(None),
stream_read_bytes: Arc::new(AtomicUsize::new(0)),
write_count: AtomicUsize::new(0),
@@ -4177,6 +4182,106 @@ mod tests {
})
}
#[tokio::test]
async fn delete_worker_empty_admission_skips_storage() {
let mut declined = ReplicateDecision::default();
declined.set(ReplicateTargetDecision::new("arn:test".to_string(), false, false));
for state in [
None,
Some(ReplicationState::default()),
Some(ReplicationState {
replicate_decision_str: declined.to_string(),
..Default::default()
}),
] {
for delete_marker in [false, true] {
let shared = empty_resync_shared_state();
let storage = Arc::new(LoadResyncNodeStore::new("empty-delete", shared.clone()));
let version_id = Uuid::new_v4();
let delete = DeletedObjectReplicationInfo {
bucket: "no-replication".to_string(),
delete_object: ReplicationDeletedObject {
object_name: "expired-version".to_string(),
version_id: (!delete_marker).then_some(version_id),
delete_marker_version_id: delete_marker.then_some(version_id),
delete_marker,
replication_state: state.clone(),
..Default::default()
},
..Default::default()
};
let completed = replicate_delete_with_outcome(delete, storage).await;
assert_eq!(
shared.delete_storage_calls.load(Ordering::SeqCst),
0,
"empty admission must not lock, read, or write storage (marker={delete_marker}, state={state:?})"
);
assert!(completed, "an empty delete is a completed no-op");
}
}
}
#[tokio::test]
async fn delete_worker_preserves_persisted_admission_without_live_config() {
let arn = "arn:test";
for state in [
ReplicationState {
replicate_decision_str: test_replicate_decision(&[arn]).to_string(),
..Default::default()
},
ReplicationState {
targets: HashMap::from([(arn.to_string(), ReplicationStatusType::Pending)]),
..Default::default()
},
ReplicationState {
purge_targets: HashMap::from([(arn.to_string(), VersionPurgeStatusType::Pending)]),
..Default::default()
},
] {
let shared = empty_resync_shared_state();
let storage = Arc::new(LoadResyncNodeStore::new("pending-delete", shared.clone()));
let delete = DeletedObjectReplicationInfo {
bucket: "no-live-config".to_string(),
delete_object: ReplicationDeletedObject {
object_name: "pending-version".to_string(),
version_id: Some(Uuid::new_v4()),
replication_state: Some(state),
..Default::default()
},
..Default::default()
};
assert!(
!replicate_delete_with_outcome(delete, storage).await,
"the injected storage failure must retain admitted work for retry"
);
assert!(shared.delete_storage_calls.load(Ordering::SeqCst) > 0);
}
}
#[tokio::test]
async fn delete_worker_rejects_malformed_empty_admission() {
let shared = empty_resync_shared_state();
let storage = Arc::new(LoadResyncNodeStore::new("invalid-delete", shared.clone()));
let delete = DeletedObjectReplicationInfo {
bucket: "no-replication".to_string(),
delete_object: ReplicationDeletedObject {
object_name: "invalid-decision".to_string(),
version_id: Some(Uuid::new_v4()),
replication_state: Some(ReplicationState {
replicate_decision_str: "invalid".to_string(),
..Default::default()
}),
..Default::default()
},
..Default::default()
};
assert!(
!replicate_delete_with_outcome(delete, storage).await,
"malformed decisions must not be acknowledged as empty work"
);
assert_eq!(shared.delete_storage_calls.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn durable_resync_read_observes_cancellation_after_cached_pending_intent() {
let bucket = "startup-cancel-race";
@@ -5198,6 +5303,7 @@ mod tests {
hold_first_read: AtomicBool::new(false),
allow_first_read: Notify::new(),
read_count: AtomicUsize::new(0),
delete_storage_calls: AtomicUsize::new(0),
reported_size: StdMutex::new(None),
stream_read_bytes: Arc::new(AtomicUsize::new(0)),
write_count: AtomicUsize::new(0),
File diff suppressed because it is too large Load Diff
@@ -37,8 +37,9 @@ use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
pub(crate) use crate::bucket::bucket_target_sys::{
AdvancedPutOptions, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions, RemotePutObjectResponse, RemoveObjectOptions,
ReplicaLocation, S3ClientError, TargetClient, resolve_read_api_version_id,
AdvancedPutOptions, EncryptedReplicaShape, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions,
RemotePutObjectResponse, RemoveObjectOptions, ReplicaCandidates, ReplicaEvidence, ReplicaIdentity, ReplicaLocation,
S3ClientError, TargetClient, resolve_read_api_version_id,
};
#[cfg(test)]
pub(crate) use crate::bucket::target::BucketTarget;
@@ -157,6 +158,19 @@ pub(crate) fn replication_object_is_ssec_encrypted(user_defined: &HashMap<String
rustfs_replication::is_ssec_encrypted(user_defined)
}
/// Whether the stored ETag of a replication source can be compared with the
/// ETag a target returns for the bytes it received. Only an unencrypted source
/// stores the MD5 of the bytes that go on the wire. A managed-SSE (SSE-S3 /
/// SSE-KMS) or SSE-C source stores a keyed fingerprint (new writes) or a legacy
/// plaintext MD5, and the target re-encrypts what it receives, so neither
/// equality nor inequality of the two ETags proves anything about the replica.
pub(crate) fn replication_source_etag_is_comparable(user_defined: &HashMap<String, String>) -> bool {
matches!(
classify_replication_source_encryption(user_defined),
ReplicationSourceEncryption::Plaintext
)
}
/// HeadObjectOutput adapter over the pure SSE-C passthrough evidence
/// judgment owned by `rustfs-replication`: extract the echoed
/// customer-algorithm header and let the crate-owned policy decide.
@@ -578,6 +592,22 @@ mod tests {
use time::Duration;
use uuid::Uuid;
#[test]
fn replication_source_etag_is_comparable_only_for_plaintext_sources() {
assert!(replication_source_etag_is_comparable(&HashMap::new()));
for (key, value) in [
(metadata_keys::SERVER_SIDE_ENCRYPTION, "AES256"),
(metadata_keys::SERVER_SIDE_ENCRYPTION, "aws:kms"),
(SSEC_ALGORITHM_HEADER, "AES256"),
] {
let metadata = HashMap::from([(key.to_string(), value.to_string())]);
assert!(
!replication_source_etag_is_comparable(&metadata),
"{key}={value} must not expose its stored ETag to the replica audit"
);
}
}
/// backlog#1735 A3b SSE key-case finding. Every RustFS writer persists the
/// SSE intent as lowercase `x-amz-server-side-encryption`
/// (`encryption_material_to_metadata`; header-derived keys come from a
+222 -127
View File
@@ -15,6 +15,7 @@
use crate::disk::{MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
use crate::error::{Error, Result, StorageError};
use regex::Regex;
use rustfs_utils::WindowsFileNameError;
use rustfs_utils::path::SLASH_SEPARATOR;
use s3s::xml;
use tracing::instrument;
@@ -178,8 +179,7 @@ pub fn is_valid_object_name(object: &str) -> bool {
dead_code,
reason = "live on Windows: callers sit inside the #[cfg(target_os = \"windows\")] block in check_object_name_for_length_and_slash (backlog#1823)"
)]
pub const WINDOWS_RESERVED_CHARACTERS_REASON: &str =
"object key contains characters unsupported on Windows hosts (one of ':', '*', '?', '\"', '|', '<', '>')";
pub const WINDOWS_RESERVED_CHARACTERS_REASON: &str = "object key contains characters unsupported on Windows hosts (one of ':', '*', '?', '\"', '|', '<', '>', or a control character)";
/// Client-facing reason for path segments Windows can store but not address
/// afterwards (issue #3449): trailing dot/space or reserved DOS device names.
@@ -187,37 +187,46 @@ pub const WINDOWS_RESERVED_CHARACTERS_REASON: &str =
dead_code,
reason = "live on Windows: callers sit inside the #[cfg(target_os = \"windows\")] block in check_object_name_for_length_and_slash (backlog#1823)"
)]
pub const WINDOWS_RESERVED_SEGMENT_REASON: &str = "object key contains a path segment unsupported on Windows hosts (trailing dot or space, or a reserved device name such as NUL/CON/COM1)";
pub const WINDOWS_RESERVED_SEGMENT_REASON: &str = "object key contains a path segment unsupported on Windows hosts (trailing dot or space, or a reserved device name such as NUL/CON/COM1/CONIN$)";
/// Reserved DOS device names that shadow regular files on Windows, even when
/// an extension is appended (e.g. `NUL.txt` resolves to the `NUL` device).
/// Client-facing reason for keys containing `\`: Windows hosts treat it as a
/// path separator, so `a\b` would land on the same file as `a/b`.
pub const WINDOWS_BACKSLASH_REASON: &str =
"object key contains '\\', which Windows hosts treat as a path separator (it would alias the same key spelled with '/')";
/// Returns the client-facing reason when `object` cannot round-trip through
/// the Win32 path layer, or `None` when it can.
///
/// Each segment is checked with [`rustfs_utils::check_windows_file_name`].
/// `\` splits segments here, like on a Windows disk, so this check never
/// rejects it: an existing `a\b` key must stay readable and deletable. New
/// writes reject `\` separately in [`check_new_object_args`].
#[allow(
dead_code,
reason = "live on Windows: callers sit inside the #[cfg(target_os = \"windows\")] block in check_object_name_for_length_and_slash (backlog#1823)"
)]
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3",
"LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
pub fn windows_object_key_violation(object: &str) -> Option<&'static str> {
// A NUL byte is rejected on every platform as ObjectNameInvalid by
// is_valid_object_prefix; keep that answer on Windows too.
if object.contains('\0') {
return None;
}
/// Returns true when `object` contains a path segment that NTFS can store but
/// the Win32 API cannot address afterwards (issue #3449): segments ending in a
/// dot or a space, and reserved DOS device names — bare or with an extension
/// (`NUL.txt`), matching classic Win32 path resolution semantics.
#[allow(
dead_code,
reason = "live on Windows: callers sit inside the #[cfg(target_os = \"windows\")] block in check_object_name_for_length_and_slash (backlog#1823)"
)]
pub fn object_name_has_windows_incompatible_segment(object: &str) -> bool {
object.split(['/', '\\']).any(|segment| {
if segment.ends_with('.') || segment.ends_with(' ') {
return true;
let mut segment_violation = false;
for segment in object.split(['/', '\\']).filter(|segment| !segment.is_empty()) {
match rustfs_utils::check_windows_file_name(segment) {
Err(WindowsFileNameError::ReservedCharacter | WindowsFileNameError::ControlCharacter) => {
return Some(WINDOWS_RESERVED_CHARACTERS_REASON);
}
Err(WindowsFileNameError::TrailingDotOrSpace | WindowsFileNameError::ReservedDeviceName) => {
segment_violation = true;
}
// Segment length is enforced in bytes by object_key_segments_fit_on_disk,
// which is stricter than the UTF-16 limit and answers ObjectNameInvalid.
Ok(()) | Err(WindowsFileNameError::TooLong | WindowsFileNameError::Empty) => {}
}
// Device names are matched on the part before the first dot, with
// trailing spaces ignored (so `NUL .txt` is also reserved).
let base = segment.split('.').next().unwrap_or(segment).trim_end_matches(' ');
WINDOWS_RESERVED_NAMES.iter().any(|name| base.eq_ignore_ascii_case(name))
})
}
segment_violation.then_some(WINDOWS_RESERVED_SEGMENT_REASON)
}
pub fn check_object_name_for_length_and_slash(bucket: &str, object: &str) -> Result<()> {
@@ -229,33 +238,33 @@ pub fn check_object_name_for_length_and_slash(bucket: &str, object: &str) -> Res
return Err(StorageError::ObjectNamePrefixAsSlash(bucket.to_owned(), object.to_owned()));
}
// Reject names that NTFS would refuse, or create but the Win32 path layer
// cannot read back (os error 3), e.g. `a:b`, `baddir.` or `NUL.txt`.
#[cfg(target_os = "windows")]
{
if object.contains(':')
|| object.contains('*')
|| object.contains('?')
|| object.contains('"')
|| object.contains('|')
|| object.contains('<')
|| object.contains('>')
// || object.contains('\\')
{
return Err(StorageError::InvalidArgument(
bucket.to_owned(),
object.to_owned(),
WINDOWS_RESERVED_CHARACTERS_REASON.to_owned(),
));
}
if let Some(reason) = windows_object_key_violation(object) {
return Err(StorageError::InvalidArgument(bucket.to_owned(), object.to_owned(), reason.to_owned()));
}
// Reject names that NTFS would happily create but the Win32 path
// layer cannot read back (os error 3), e.g. `baddir.` or `NUL.txt`.
if object_name_has_windows_incompatible_segment(object) {
return Err(StorageError::InvalidArgument(
bucket.to_owned(),
object.to_owned(),
WINDOWS_RESERVED_SEGMENT_REASON.to_owned(),
));
}
Ok(())
}
/// Admission check for every request that introduces `object` into the
/// namespace: PutObject, a CopyObject destination, and CreateMultipartUpload.
/// Sharing one entry keeps their rejections (and S3 error codes) identical.
///
/// `data_movement` writes (decommission, rebalance) relocate keys that already
/// exist, so they skip the new-key-only rules and stay able to move old data.
/// Internal meta buckets are exempt too: their keys are produced by RustFS
/// itself and resolve to the same path either way.
pub fn check_new_object_args(bucket: &str, object: &str, data_movement: bool) -> Result<()> {
check_put_object_args(bucket, object)?;
if cfg!(target_os = "windows") && !data_movement && !is_meta_bucketname(bucket) && object.contains('\\') {
return Err(StorageError::InvalidArgument(
bucket.to_owned(),
object.to_owned(),
WINDOWS_BACKSLASH_REASON.to_owned(),
));
}
Ok(())
@@ -316,10 +325,6 @@ pub fn check_bucket_and_object_names(bucket: &str, object: &str) -> Result<()> {
return Err(StorageError::ObjectNameInvalid(bucket.to_string(), object.to_string()));
}
// if cfg!(target_os = "windows") && object.contains('\\') {
// return Err(StorageError::ObjectNameInvalid(bucket.to_string(), object.to_string()));
// }
Ok(())
}
@@ -586,103 +591,193 @@ mod tests {
}
#[test]
fn test_object_name_has_windows_incompatible_segment() {
fn test_windows_object_key_violation_segments() {
let segment = Some(WINDOWS_RESERVED_SEGMENT_REASON);
// Segments ending in a dot are writable on NTFS but unreadable via Win32 paths.
assert!(object_name_has_windows_incompatible_segment("baddir."));
assert!(object_name_has_windows_incompatible_segment("dir1/dir2/baddir."));
assert!(object_name_has_windows_incompatible_segment("dir1/baddir./file.txt"));
assert!(object_name_has_windows_incompatible_segment("file.txt."));
assert_eq!(windows_object_key_violation("baddir."), segment);
assert_eq!(windows_object_key_violation("dir1/dir2/baddir."), segment);
assert_eq!(windows_object_key_violation("dir1/baddir./file.txt"), segment);
assert_eq!(windows_object_key_violation("file.txt."), segment);
// Segments ending in a space.
assert!(object_name_has_windows_incompatible_segment("file.txt "));
assert!(object_name_has_windows_incompatible_segment("dir /file.txt"));
assert_eq!(windows_object_key_violation("file.txt "), segment);
assert_eq!(windows_object_key_violation("dir /file.txt"), segment);
// Reserved DOS device names, bare and case-insensitive.
assert!(object_name_has_windows_incompatible_segment("NUL"));
assert!(object_name_has_windows_incompatible_segment("nul"));
assert!(object_name_has_windows_incompatible_segment("CON"));
assert!(object_name_has_windows_incompatible_segment("prn"));
assert!(object_name_has_windows_incompatible_segment("Aux"));
assert!(object_name_has_windows_incompatible_segment("COM1"));
assert!(object_name_has_windows_incompatible_segment("com9"));
assert!(object_name_has_windows_incompatible_segment("LPT1"));
assert!(object_name_has_windows_incompatible_segment("lpt9"));
assert!(object_name_has_windows_incompatible_segment("dir/NUL/file.txt"));
for object in [
"NUL",
"nul",
"CON",
"prn",
"Aux",
"COM1",
"com9",
"LPT1",
"lpt9",
"dir/NUL/file.txt",
// Superscript port numbers and console aliases are devices too.
"COM\u{b9}",
"dir/lpt\u{b3}",
"CONIN$",
"conout$",
] {
assert_eq!(windows_object_key_violation(object), segment, "{object:?}");
}
// Reserved device names with an extension still resolve to the device.
assert!(object_name_has_windows_incompatible_segment("NUL.txt"));
assert!(object_name_has_windows_incompatible_segment("dir/aux.log"));
assert!(object_name_has_windows_incompatible_segment("con.tar.gz"));
assert!(object_name_has_windows_incompatible_segment("com1.dat"));
assert!(object_name_has_windows_incompatible_segment("NUL .txt"));
for object in [
"NUL.txt",
"dir/aux.log",
"con.tar.gz",
"com1.dat",
"NUL .txt",
"COM\u{b2}.log",
] {
assert_eq!(windows_object_key_violation(object), segment, "{object:?}");
}
// Backslash-separated segments are checked as well.
assert!(object_name_has_windows_incompatible_segment("dir\\baddir.\\file.txt"));
assert!(object_name_has_windows_incompatible_segment("dir\\nul"));
assert_eq!(windows_object_key_violation("dir\\baddir.\\file.txt"), segment);
assert_eq!(windows_object_key_violation("dir\\nul"), segment);
// Valid names must not be flagged.
assert!(!object_name_has_windows_incompatible_segment("file.txt"));
assert!(!object_name_has_windows_incompatible_segment("dir.name/file"));
assert!(!object_name_has_windows_incompatible_segment("path/to/file.tar.gz"));
assert!(!object_name_has_windows_incompatible_segment("nullable"));
assert!(!object_name_has_windows_incompatible_segment("CONSOLE"));
assert!(!object_name_has_windows_incompatible_segment("com10"));
assert!(!object_name_has_windows_incompatible_segment("com0"));
assert!(!object_name_has_windows_incompatible_segment("lpt"));
assert!(!object_name_has_windows_incompatible_segment("aux-data/file"));
assert!(!object_name_has_windows_incompatible_segment("object with spaces inside"));
assert!(!object_name_has_windows_incompatible_segment(""));
for object in [
"file.txt",
"dir.name/file",
"path/to/file.tar.gz",
"nullable",
"CONSOLE",
"com10",
"com0",
"lpt",
"CONIN",
"aux-data/file",
"object with spaces inside",
"dir/",
"",
] {
assert_eq!(windows_object_key_violation(object), None, "{object:?}");
}
}
#[test]
fn test_check_object_name_windows_incompatible_segments() {
// Rejected on Windows (would be written but unreadable, issue #3449);
// valid on non-Windows platforms.
for object in [
"baddir.",
"dir1/dir2/baddir.",
"dir1/baddir./file.txt",
"file.txt ",
"NUL",
"nul.txt",
"COM1",
"dir/LPT9.log",
] {
let result = check_object_name_for_length_and_slash("test-bucket", object);
if cfg!(target_os = "windows") {
assert!(
matches!(&result, Err(StorageError::InvalidArgument(_, _, reason)) if reason == WINDOWS_RESERVED_SEGMENT_REASON),
"object name must be rejected on Windows with a descriptive reason: {object:?}, got {result:?}"
);
} else {
assert!(result.is_ok(), "object name must remain valid on non-Windows: {object:?}");
}
fn test_windows_object_key_violation_characters() {
let characters = Some(WINDOWS_RESERVED_CHARACTERS_REASON);
for object in ["path/*sUt*mykey", "a:b", "what?", "pipe|name", "quote\"d", "lt<gt>"] {
assert_eq!(windows_object_key_violation(object), characters, "{object:?}");
}
// Valid on every platform.
for object in ["file.txt", "dir.name/file", "nullable", "CONSOLE", "com10"] {
assert!(
check_object_name_for_length_and_slash("test-bucket", object).is_ok(),
"object name must be valid on all platforms: {object:?}"
// Control characters 0x01-0x1F cannot appear in a Win32 file name.
for object in ["a\u{1}b", "tab\tname", "dir/line\nbreak", "esc\u{1b}", "unit\u{1f}"] {
assert_eq!(windows_object_key_violation(object), characters, "{object:?}");
}
// NUL keeps its platform-independent ObjectNameInvalid answer.
assert_eq!(windows_object_key_violation("a\0b"), None);
// A character violation outranks a segment violation anywhere in the key.
assert_eq!(windows_object_key_violation("baddir./a:b"), characters);
}
/// Existing `a\b` keys must stay readable, deletable and abortable, so the
/// shared name check never rejects `\` (only new writes do, on Windows).
#[test]
fn test_backslash_passes_the_shared_name_check() {
assert_eq!(windows_object_key_violation("a\\b"), None);
assert_eq!(windows_object_key_violation("dir\\sub\\file.txt"), None);
assert!(check_object_name_for_length_and_slash("test-bucket", "a\\b").is_ok());
assert!(check_object_args("test-bucket", "a\\b").is_ok());
assert!(check_bucket_and_object_names("test-bucket", "a\\b").is_ok());
// Data movement relocates keys that already exist.
assert!(check_new_object_args("test-bucket", "a\\b", true).is_ok());
assert!(check_new_object_args(RUSTFS_META_BUCKET, "config\\a", false).is_ok());
}
#[test]
fn test_check_new_object_args_matches_put_object_args() {
for object in [
"file.txt",
"",
"/leading",
"a//b",
"x/../y",
&"k".repeat(1025),
&"s".repeat(256),
"baddir.",
"a:b",
"NUL.txt",
] {
assert_eq!(
format!("{:?}", check_new_object_args("test-bucket", object, false)),
format!("{:?}", check_put_object_args("test-bucket", object)),
"{object:?}"
);
}
assert!(matches!(
check_new_object_args("Bad_Bucket", "file.txt", false),
Err(StorageError::BucketNameInvalid(_))
));
}
#[test]
fn test_check_object_name_windows_reserved_characters() {
// Keys containing Win32-reserved characters are rejected on Windows
// with a descriptive reason (issue #3299); valid elsewhere.
for object in ["path/*sUt*mykey", "a:b", "what?", "pipe|name", "quote\"d", "lt<gt>"] {
let result = check_object_name_for_length_and_slash("test-bucket", object);
if cfg!(target_os = "windows") {
#[cfg(not(target_os = "windows"))]
fn test_check_new_object_args_keeps_full_key_set_off_windows() {
for object in ["a\\b", "a:b", "baddir.", "NUL", "CONIN$", "tab\tname"] {
assert!(check_new_object_args("test-bucket", object, false).is_ok(), "{object:?}");
}
}
/// `a\b` and `a/b` resolve to the same file on a Windows disk, so a new
/// `a\b` would overwrite (or be overwritten by) `a/b`.
#[test]
#[cfg(target_os = "windows")]
fn windows_new_object_key_rejects_backslash_alias() {
assert!(check_new_object_args("test-bucket", "a/b", false).is_ok());
for object in ["a\\b", "dir\\", "dir/sub\\file"] {
assert!(
matches!(
&check_new_object_args("test-bucket", object, false),
Err(StorageError::InvalidArgument(_, _, reason)) if reason == WINDOWS_BACKSLASH_REASON
),
"{object:?}"
);
}
// Reads, deletes and in-flight multipart uploads of existing keys keep working.
assert!(check_object_args("test-bucket", "a\\b").is_ok());
assert!(check_del_obj_args("test-bucket", "a\\b").is_ok());
assert!(check_new_object_args("test-bucket", "a\\b", true).is_ok());
}
#[test]
#[cfg(target_os = "windows")]
fn windows_new_object_key_rejects_unaddressable_names() {
for (object, expected) in [
("baddir.", WINDOWS_RESERVED_SEGMENT_REASON),
("dir/LPT9.log", WINDOWS_RESERVED_SEGMENT_REASON),
("COM\u{b9}", WINDOWS_RESERVED_SEGMENT_REASON),
("CONOUT$", WINDOWS_RESERVED_SEGMENT_REASON),
("a:b", WINDOWS_RESERVED_CHARACTERS_REASON),
("tab\tname", WINDOWS_RESERVED_CHARACTERS_REASON),
("esc\u{1b}", WINDOWS_RESERVED_CHARACTERS_REASON),
] {
for result in [
check_object_name_for_length_and_slash("test-bucket", object),
check_put_object_args("test-bucket", object),
check_new_object_args("test-bucket", object, false),
check_object_args("test-bucket", object),
] {
assert!(
matches!(&result, Err(StorageError::InvalidArgument(_, _, reason)) if reason == WINDOWS_RESERVED_CHARACTERS_REASON),
"object name must be rejected on Windows with a descriptive reason: {object:?}, got {result:?}"
matches!(&result, Err(StorageError::InvalidArgument(_, _, reason)) if reason == expected),
"{object:?} must be rejected with {expected:?}, got {result:?}"
);
} else {
assert!(result.is_ok(), "object name must remain valid on non-Windows: {object:?}");
}
}
// NUL keeps the platform-independent answer.
assert!(matches!(
check_put_object_args("test-bucket", "a\0b"),
Err(StorageError::ObjectNameInvalid(_, _))
));
}
#[test]
+3 -1
View File
@@ -356,7 +356,9 @@ fn apply_peer_replay_response(
fn rename_data_grpc_stage(path: &str) -> bool {
matches!(
path,
"/node_service.NodeService/RenameData" | "/node_service.NodeService/RenameDataAtIncarnation"
"/node_service.NodeService/RenameData"
| "/node_service.NodeService/RenameDataAtIncarnation"
| "/node_service.NodeService/RenameDataWithQuotaFence"
)
}
@@ -48,14 +48,15 @@ use rustfs_protos::proto_gen::node_service::{
GetPartitionsRequest, GetProcInfoRequest, GetSeLinuxInfoRequest, GetSysConfigRequest, GetSysErrorsRequest,
HealControlRequest, LoadBucketMetadataRequest, LoadGroupRequest, LoadPolicyMappingRequest, LoadPolicyRequest,
LoadRebalanceMetaRequest, LoadServiceAccountRequest, LoadTransitionTierConfigRequest, LoadUserRequest,
LocalStorageInfoRequest, Mss, PingRequest, ReloadPoolMetaRequest, ReloadSiteReplicationConfigRequest,
ReplacementRecoveryStatusRequest, ScannerActivityRequest, ScannerActivityResponse, ScannerDirtyUsageSnapshotRequest,
ScannerDirtyUsageSnapshotResponse, ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest,
ScannerPublicationLeaseResponse, ScannerScopedDirtyUsageAckRequest, ScannerScopedDirtyUsageAckResponse,
ScannerScopedDirtyUsageEntry, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
StartProfilingRequest, StopRebalanceRequest, TierDailyStatsRequest, TierMutationAbortRequest, TierMutationCommitRequest,
TierMutationControlResponse, TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest,
node_service_client::NodeServiceClient, tier_mutation_control_service_client::TierMutationControlServiceClient,
LocalStorageInfoRequest, Mss, PeerDriveAdmissionRequest, PingRequest, ReloadPoolMetaRequest,
ReloadSiteReplicationConfigRequest, ReplacementRecoveryStatusRequest, ScannerActivityRequest, ScannerActivityResponse,
ScannerDirtyUsageSnapshotRequest, ScannerDirtyUsageSnapshotResponse, ScannerPublicationLeaseReleaseRequest,
ScannerPublicationLeaseRequest, ScannerPublicationLeaseResponse, ScannerScopedDirtyUsageAckRequest,
ScannerScopedDirtyUsageAckResponse, ScannerScopedDirtyUsageEntry, ServerInfoRequest, SignalServiceRequest,
SignalServiceResponse, StartDecommissionRequest, StartProfilingRequest, StopRebalanceRequest, TierDailyStatsRequest,
TierMutationAbortRequest, TierMutationCommitRequest, TierMutationControlResponse, TierMutationFailureClass,
TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
tier_mutation_control_service_client::TierMutationControlServiceClient,
};
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
use rustfs_protos::{TierMutationRpcPhase, evict_failed_connection};
@@ -89,6 +90,7 @@ pub const SERVICE_SIGNAL_RELOAD_DYNAMIC: u64 = 2;
pub const KMS_SIGNAL_SUBSYSTEM: &str = "kms";
const BACKGROUND_HEAL_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
const REPLACEMENT_RECOVERY_STATUS_MAX_MESSAGE_SIZE: usize = 64 * 1024;
const PEER_DRIVE_ADMISSION_MAX_MESSAGE_SIZE: usize = 256 * 1024;
const HEAL_CONTROL_FINGERPRINT_MAX_SIZE: usize = 256;
const HEAL_CONTROL_PAYLOAD_MAX_SIZE: usize = 64 * 1024;
const PEER_REST_RECOVERY_MAX_ATTEMPTS: u32 = 60;
@@ -711,6 +713,16 @@ fn scanner_publication_lease_deadline(
.ok_or_else(|| Error::other("scanner publication lease deadline overflowed"))
}
/// A peer's readiness view of one drive served by another host.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PeerDriveAdmissionView {
pub pool_index: i32,
pub set_index: i32,
pub disk_index: i32,
/// True when the peer counts the drive towards its own read quorum.
pub online: bool,
}
#[derive(Clone, Debug)]
pub struct PeerRestClient {
pub host: XHost,
@@ -864,6 +876,11 @@ impl PeerRestClient {
)
}
/// Topology `host:port` this client talks to, as written in the endpoint list.
pub fn topology_member(&self) -> &str {
&self.topology_member
}
pub fn new(host: XHost, grid_host: String) -> Self {
let topology_member = host.to_string();
Self {
@@ -1677,6 +1694,51 @@ impl PeerRestClient {
.await
}
/// Ask the peer how it currently sees the drives served by `host`.
///
/// `Ok(None)` means the peer predates the RPC and cannot answer.
pub async fn peer_drive_admission(&self, host: &str) -> Result<Option<Vec<PeerDriveAdmissionView>>> {
self.finalize_result(
async {
let mut client = self
.get_client()
.await?
.max_decoding_message_size(PEER_DRIVE_ADMISSION_MAX_MESSAGE_SIZE);
let response = match client
.peer_drive_admission(Request::new(PeerDriveAdmissionRequest { host: host.to_string() }))
.await
{
Ok(response) => response.into_inner(),
Err(status) if status.code() == tonic::Code::Unimplemented => {
// RUSTFS_COMPAT_TODO(peer-drive-admission-v1): old peers cannot report how they see a restarting node, so its startup re-admission gate skips them. Remove after the minimum supported RustFS peer version implements PeerDriveAdmission.
return Ok(None);
}
Err(status) => return Err(status.into()),
};
if !response.success {
return Err(match (response.error_code, response.error_info) {
(None, None) => Error::other("peer drive admission failed without an error"),
(error_code, error_info) => control_plane_failure("peer_drive_admission", None, error_code, error_info),
});
}
Ok(Some(
response
.drives
.into_iter()
.map(|drive| PeerDriveAdmissionView {
pool_index: drive.pool_index,
set_index: drive.set_index,
disk_index: drive.disk_index,
online: drive.online,
})
.collect(),
))
}
.await,
)
.await
}
pub async fn prepare_tier_mutation(&self, mutation_id: Uuid, canonical_payload: Bytes) -> Result<PeerTierMutationOutcome> {
self.tier_mutation_control(TierMutationRpcPhase::Prepare, mutation_id, canonical_payload)
.await
+275 -23
View File
@@ -26,8 +26,9 @@ use crate::disk::{
BatchReadVersionReq, BatchReadVersionResp, CheckPartsResp, ConditionalFileUpdate, DELETE_RECOVERY_PROTOCOL_VERSION,
DeleteOptions, DeleteRecoveryAction, DeleteRecoveryCursor, DeleteRecoveryDigest, DeleteRecoveryInspection,
DeleteRecoveryPage, DeleteRecoveryRpcOperation, DeleteRecoveryRpcReply, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation,
DiskOption, FileInfoVersions, FileReader, FileWriter, PartTransactionAction, ReadMultipleReq, ReadMultipleResp, ReadOptions,
RenameDataResp, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one,
DiskOption, FileInfoVersions, FileReader, FileWriter, GuardedDeleteVersionsPayload, GuardedFileInfoVersions,
PartTransactionAction, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, SnapshotLeaseToken,
UpdateMetadataOpts, VolumeInfo, WalkDirOptions, batch_read_version_one_by_one,
disk_store::{
DEFAULT_RUSTFS_DRIVE_ACTIVE_MONITORING, ENV_RUSTFS_DRIVE_ACTIVE_MONITORING, SKIP_IF_SUCCESS_BEFORE,
get_drive_active_check_interval, get_drive_active_check_timeout, get_drive_disk_info_timeout, get_drive_list_dir_timeout,
@@ -59,12 +60,12 @@ use rustfs_protos::proto_gen::node_service::RenamePartRequest;
use rustfs_protos::proto_gen::node_service::{
BatchReadVersionRequest, BatchReadVersionResponse, CheckPartsRequest, CompareAndUpdateFileOutcome,
CompareAndUpdateFileRequest, DeletePathsRequest, DeleteRequest, DeleteVersionRequest, DeleteVersionsRequest,
DeleteVersionsResponse, DeleteVolumeRequest, DiskInfoRequest, ListDirRequest, ListVolumesRequest, MakeVolumeRequest,
MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest, ReadMetadataRequest, ReadMultipleRequest,
ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest, RecoverDeleteTransactionRequest,
RenameDataRequest, RenameFileRequest, SettlePartTransactionRequest, SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest,
SnapshotLeaseRequest, SnapshotLeaseResponse, StatVolumeRequest, UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest,
WriteMetadataRequest, node_service_client::NodeServiceClient,
DeleteVersionsResponse, DeleteVolumeRequest, DiskInfoRequest, GuardedDeleteVersionsRequest, ListDirRequest,
ListVolumesRequest, MakeVolumeRequest, MakeVolumesRequest, PreparePartTransactionRequest, ReadAllRequest,
ReadMetadataRequest, ReadMultipleRequest, ReadMultipleResponse, ReadPartsRequest, ReadVersionRequest, ReadXlRequest,
RecoverDeleteTransactionRequest, RenameDataRequest, RenameFileRequest, SettlePartTransactionRequest,
SnapshotLeaseReleaseRequest, SnapshotLeaseRenewRequest, SnapshotLeaseRequest, SnapshotLeaseResponse, StatVolumeRequest,
UpdateMetadataRequest, VerifyFileRequest, WriteAllRequest, WriteMetadataRequest, node_service_client::NodeServiceClient,
};
use serde::{Serialize, de::DeserializeOwned};
use std::{
@@ -217,6 +218,24 @@ fn attach_mutation_body_digest<T>(
crate::cluster::rpc::set_tonic_rolling_canonical_body_digest(request, &canonical_body).map_err(Error::other)
}
fn attach_quota_mutation_body_digest<T>(
request: &mut Request<T>,
canonical_body: std::result::Result<Vec<u8>, std::num::TryFromIntError>,
) -> Result<()> {
let topology = crate::services::notification_sys::configured_fleet_topology_fingerprint()
.ok_or_else(|| Error::other("quota mutation topology is not initialized or conflicts with startup"))?;
let body = canonical_body.map_err(|_| Error::other("quota request length cannot be represented"))?;
let body = rustfs_protos::canonical_quota_mutation_request_body(topology, &body)
.map_err(|_| Error::other("quota topology envelope length cannot be represented"))?;
request.metadata_mut().insert(
rustfs_protos::QUOTA_MUTATION_TOPOLOGY_HEADER,
topology
.parse()
.map_err(|_| Error::other("quota topology cannot be encoded"))?,
);
crate::cluster::rpc::set_tonic_canonical_body_digest(request, &body).map_err(Error::other)
}
fn decode_volume_infos(volume_infos: Vec<String>) -> Result<Vec<VolumeInfo>> {
volume_infos
.into_iter()
@@ -899,6 +918,63 @@ fn spawn_control_channel_prewarm(addr: String) {
}
impl RemoteDisk {
async fn guarded_delete_versions_rpc(
&self,
volume: &str,
payload: Vec<u8>,
probe_only: bool,
) -> Result<DeleteVersionsResponse> {
if payload.len() > rustfs_protos::DELETE_RECOVERY_MAX_PAYLOAD_BYTES {
return Err(DiskError::FileCorrupt);
}
self.execute_with_timeout(
|| async {
let mut client = self.get_client().await?;
let mut request = Request::new(GuardedDeleteVersionsRequest {
disk: self.endpoint.to_string(),
volume: volume.to_owned(),
payload: payload.into(),
probe_only,
});
let body = rustfs_protos::canonical_guarded_delete_versions_request_body(request.get_ref())
.map_err(|_| DiskError::FileCorrupt)?;
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &body).map_err(Error::other)?;
// The additive method is mandatory: an old peer cannot safely delete
// a guarded request through any legacy batch method.
let response = client
.guarded_delete_versions(request)
.await
.map_err(|status| {
if status.code() == Code::Unimplemented {
DiskError::GuardedDeleteUnsupported
} else {
status.into()
}
})?
.into_inner();
if !response.success {
return Err(response.error.ok_or(DiskError::FileCorrupt)?.into());
}
if response.error.is_some() {
return Err(DiskError::FileCorrupt);
}
Ok(response)
},
get_max_timeout_duration(),
)
.await
}
pub(crate) async fn is_plain_orphan_data_dir(&self, volume: &str, path: &str) -> Result<bool> {
match self
.recover_delete_transaction(volume, DeleteRecoveryRpcOperation::InspectOrphanDataDir { path: path.to_owned() })
.await?
{
DeleteRecoveryRpcReply::OrphanDataDir { plain } => Ok(plain),
_ => Err(Error::other("unexpected orphan data directory inspection reply")),
}
}
async fn recover_delete_transaction(
&self,
volume: &str,
@@ -2300,7 +2376,13 @@ impl RemoteDisk {
});
let canonical_body = rustfs_protos::canonical_rename_data_request_body(request.get_ref());
let incarnation_bound = !request.get_ref().bucket_incarnation_id.is_empty();
if scanner_publication_lease_token.is_some() || incarnation_bound {
let quota_fenced = rustfs_utils::http::metadata_compat::contains_key_str(
&fi.metadata,
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
);
if quota_fenced {
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
} else if scanner_publication_lease_token.is_some() || incarnation_bound {
let canonical_body =
canonical_body.map_err(|_| Error::other("rename_data request length cannot be represented"))?;
crate::cluster::rpc::set_tonic_canonical_body_digest(&mut request, &canonical_body).map_err(Error::other)?;
@@ -2315,7 +2397,10 @@ impl RemoteDisk {
let rpc_started = rustfs_io_metrics::put_stage_timer();
let response = rustfs_io_metrics::observe_put_stage_future(
async {
if incarnation_bound {
if quota_fenced {
// The method itself proves quota-token enforcement; an old peer must fail closed.
client.rename_data_with_quota_fence(request).await
} else if incarnation_bound {
// Older peers return Unimplemented before mutation; never downgrade.
client.rename_data_at_incarnation(request).await
} else {
@@ -2448,6 +2533,39 @@ impl RemoteDisk {
#[async_trait::async_trait]
impl DiskAPI for RemoteDisk {
async fn supports_guarded_delete_versions(&self) -> Result<()> {
let response = self.guarded_delete_versions_rpc("", Vec::new(), true).await?;
if !response.errors.is_empty() || !response.item_errors.is_empty() {
return Err(DiskError::FileCorrupt);
}
Ok(())
}
async fn delete_versions_guarded_recoverable(
&self,
volume: &str,
versions: Vec<GuardedFileInfoVersions>,
opts: DeleteOptions,
) -> Vec<Option<DiskError>> {
let count = versions.len();
let payload = GuardedDeleteVersionsPayload { versions, opts };
if let Err(error) = payload.validate() {
return vec![Some(error); count];
}
let encoded = match rmp_serde::to_vec_named(&payload) {
Ok(encoded) => encoded,
Err(error) => return vec![Some(error.into()); count],
};
match self.guarded_delete_versions_rpc(volume, encoded, false).await {
Ok(response) => {
// New guarded peers must return typed errors, never lossy strings.
if response.item_errors.len() != count {
return vec![Some(DiskError::FileCorrupt); count];
}
decode_delete_versions_errors(response, count)
}
Err(error) => vec![Some(error); count],
}
}
#[tracing::instrument(level = "trace", skip_all)]
fn to_string(&self) -> String {
self.endpoint.to_string()
@@ -2955,8 +3073,15 @@ impl DiskAPI for RemoteDisk {
.map_err(|_| Error::other("snapshot lease TTL cannot be represented"))?,
});
let canonical_body = rustfs_protos::canonical_snapshot_lease_request_body(request.get_ref());
attach_mutation_body_digest(&mut request, canonical_body, "acquire_snapshot_lease")?;
let response = client.acquire_snapshot_lease(request).await?.into_inner();
let quota_fence = volume == crate::disk::RUSTFS_META_BUCKET && crate::disk::is_quota_mutation_fence_path(path);
let response = if quota_fence {
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
client.acquire_quota_mutation_fence(request).await?
} else {
attach_mutation_body_digest(&mut request, canonical_body, "acquire_snapshot_lease")?;
client.acquire_snapshot_lease(request).await?
}
.into_inner();
snapshot_lease_token_from_response(response)
},
get_max_timeout_duration(),
@@ -2997,8 +3122,15 @@ impl DiskAPI for RemoteDisk {
token: token.as_bytes().to_vec().into(),
});
let canonical_body = rustfs_protos::canonical_snapshot_lease_release_request_body(request.get_ref());
attach_mutation_body_digest(&mut request, canonical_body, "release_snapshot_lease")?;
let response = client.release_snapshot_lease(request).await?.into_inner();
let quota_fence = volume == crate::disk::RUSTFS_META_BUCKET && crate::disk::is_quota_mutation_fence_path(path);
let response = if quota_fence {
attach_quota_mutation_body_digest(&mut request, canonical_body)?;
client.release_quota_mutation_fence(request).await?
} else {
attach_mutation_body_digest(&mut request, canonical_body, "release_snapshot_lease")?;
client.release_snapshot_lease(request).await?
}
.into_inner();
if !response.success {
return Err(response.error.unwrap_or_default().into());
}
@@ -3193,7 +3325,7 @@ impl DiskAPI for RemoteDisk {
opts_bin: opts_bin.into(),
});
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_request();
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_request(opts);
if let Some(request_payload_bytes) = request_payload_bytes {
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_sent_bytes(request_payload_bytes);
}
@@ -3271,6 +3403,7 @@ impl DiskAPI for RemoteDisk {
record_batch_read_version_stage(INTERNODE_STAGE_BATCH_READ_VERSION_REQUEST_ENCODE, encode_started);
let request_payload_bytes = batch_read_version_attribution_enabled
.then(|| batch_read_version_request_payload_len(&req, &batch_read_version_req, &batch_read_version_req_bin));
let request_opts = req.opts;
let batch_result = self
.execute_with_timeout_for_op(
"batch_read_version",
@@ -3284,7 +3417,7 @@ impl DiskAPI for RemoteDisk {
batch_read_version_req_bin: batch_read_version_req_bin.into(),
});
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_request();
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_request(&request_opts);
if let Some(request_payload_bytes) = request_payload_bytes {
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_batch_read_version_sent_bytes(
request_payload_bytes.saturating_add(disk_len),
@@ -3823,6 +3956,21 @@ impl DiskAPI for RemoteDisk {
}
}
async fn has_delete_transaction_evidence(&self, volume: &str, object: &str) -> Result<bool> {
match self
.recover_delete_transaction(
volume,
DeleteRecoveryRpcOperation::Exists {
object: object.to_owned(),
},
)
.await?
{
DeleteRecoveryRpcReply::Evidence { present } => Ok(present),
_ => Err(Error::other("unexpected delete recovery existence reply")),
}
}
async fn discover_delete_transactions(
&self,
volume: &str,
@@ -4322,6 +4470,101 @@ mod tests {
static INIT: Once = Once::new();
#[tokio::test]
async fn quota_fence_old_peer_rejects_without_legacy_fallback() {
runtime_sources::ensure_test_rpc_secret();
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
let _without_proof = crate::services::notification_sys::without_live_cross_pool_fence_fleet_proof_for_test();
assert!(crate::services::notification_sys::acquire_cross_pool_fence_fleet_proof().is_none());
assert!(crate::services::notification_sys::configured_fleet_topology_fingerprint().is_some());
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("old peer must bind");
let remote = remote_disk_for_addr(&peer.addr).await;
let fence_path = crate::disk::quota_mutation_fence_path("bucket", "object");
assert!(
remote
.acquire_snapshot_lease(crate::disk::RUSTFS_META_BUCKET, &fence_path)
.await
.is_err()
);
assert!(
remote
.release_snapshot_lease(crate::disk::RUSTFS_META_BUCKET, &fence_path, SnapshotLeaseToken::revoke_all())
.await
.is_err()
);
let mut file_info = sample_rename_data_file_info();
rustfs_utils::http::metadata_compat::insert_str(
&mut file_info.metadata,
crate::disk::QUOTA_MUTATION_FENCE_METADATA_SUFFIX,
Uuid::new_v4().to_string(),
);
assert!(
remote
.rename_data("tmp", "source", file_info, "bucket", "object")
.await
.is_err()
);
let methods = peer
.peer
.unsupported_methods
.lock()
.expect("unsupported method list lock")
.clone();
assert_eq!(
methods,
vec![
"/node_service.NodeService/AcquireQuotaMutationFence",
"/node_service.NodeService/ReleaseQuotaMutationFence",
"/node_service.NodeService/RenameDataWithQuotaFence",
],
"quota operations must never fall back to snapshot or unfenced rename methods"
);
peer.stop().await;
}
#[tokio::test]
async fn guarded_delete_old_peer_rejects_without_legacy_fallback() {
runtime_sources::ensure_test_rpc_secret();
let peer = TestGrpcPeer::spawn(Bytes::new(), Bytes::new())
.await
.expect("old peer must bind");
let remote = remote_disk_for_addr(&peer.addr).await;
assert_eq!(remote.supports_guarded_delete_versions().await, Err(DiskError::GuardedDeleteUnsupported));
let group = GuardedFileInfoVersions {
group: FileInfoVersions {
name: "object".into(),
versions: vec![FileInfo::default()],
..Default::default()
},
expected_current: crate::disk::CurrentDeleteGeneration::Absent,
};
let errors = remote
.delete_versions_guarded_recoverable(
"bucket",
vec![group],
DeleteOptions {
old_data_dir: Some(Uuid::new_v4()),
..Default::default()
},
)
.await;
assert_eq!(errors, vec![Some(DiskError::GuardedDeleteUnsupported)]);
let methods = peer
.peer
.unsupported_methods
.lock()
.expect("unsupported method list lock")
.clone();
assert_eq!(
methods,
vec!["/node_service.NodeService/GuardedDeleteVersions"; 2],
"neither capability failure nor guarded application may call a legacy deletion method"
);
peer.stop().await;
}
#[test]
fn recoverable_batch_delete_rejects_invalid_error_codes_and_length() {
let errors = decode_delete_recovery_batch_errors(vec![None, Some(DiskError::FileNotFound.to_u32())], 2)
@@ -4710,6 +4953,7 @@ mod tests {
format_data: Bytes,
read_all_data: Bytes,
next_format_read_gate: Arc<StdMutex<Option<Arc<TestFormatReadGate>>>>,
unsupported_methods: Arc<StdMutex<Vec<String>>>,
}
#[derive(Debug, Default)]
@@ -4728,6 +4972,7 @@ mod tests {
format_data,
read_all_data,
next_format_read_gate: Arc::default(),
unsupported_methods: Arc::default(),
}
}
@@ -4874,13 +5119,20 @@ mod tests {
Ok(grpc.unary(method, request).await)
})
}
_ => Box::pin(async move {
let mut response = http::Response::new(tonic::body::Body::default());
let headers = response.headers_mut();
headers.insert(tonic::Status::GRPC_STATUS, (tonic::Code::Unimplemented as i32).into());
headers.insert(http::header::CONTENT_TYPE, tonic::metadata::GRPC_CONTENT_TYPE);
Ok(response)
}),
_ => {
self.peer
.unsupported_methods
.lock()
.expect("unsupported method list lock")
.push(request.uri().path().to_owned());
Box::pin(async move {
let mut response = http::Response::new(tonic::body::Body::default());
let headers = response.headers_mut();
headers.insert(tonic::Status::GRPC_STATUS, (tonic::Code::Unimplemented as i32).into());
headers.insert(http::header::CONTENT_TYPE, tonic::metadata::GRPC_CONTENT_TYPE);
Ok(response)
})
}
}
}
}
File diff suppressed because it is too large Load Diff
+107 -14
View File
@@ -574,17 +574,20 @@ where
>,
{
let h = HeaderMap::new();
let mut rd = api
.get_object_reader(RUSTFS_META_BUCKET, file, None, h, opts)
.await
.map_err(|err| {
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
Error::ConfigNotFound
} else {
warn!("read_config_with_metadata: err: {:?}, file: {}", err, file);
err
}
})?;
let read = api.get_object_reader(RUSTFS_META_BUCKET, file, None, h, opts);
#[cfg(feature = "diagnostic-read-version-caller")]
let read = crate::cluster::rpc::runtime_sources::with_read_version_entry(
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::ConfigRead,
read,
);
let mut rd = read.await.map_err(|err| {
if err == Error::FileNotFound || matches!(err, Error::ObjectNotFound(_, _)) {
Error::ConfigNotFound
} else {
warn!("read_config_with_metadata: err: {:?}, file: {}", err, file);
err
}
})?;
let data = if let Some(max_bytes) = max_bytes {
let object_size = usize::try_from(rd.object_info.size).map_err(|_| Error::CorruptedFormat)?;
@@ -807,7 +810,18 @@ where
>,
{
let mut put_data = PutObjReader::from_vec(data);
match api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts).await {
let write = api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts);
#[cfg(feature = "diagnostic-read-version-caller")]
let write = crate::cluster::rpc::runtime_sources::with_read_version_entry(
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::ConfigWrite,
async {
crate::cluster::rpc::runtime_sources::observe_read_version_context_boundary(
rustfs_io_metrics::internode_metrics::ReadVersionContextBoundary::ConfigWrite,
);
write.await
},
);
match write.await {
Ok(object_info) => Ok(object_info),
Err(err) => {
if log_error {
@@ -2839,7 +2853,7 @@ mod tests {
use rustfs_config::{
DEFAULT_DELIMITER, ENABLE_KEY, EnableState, HEAL_BITROT_CYCLE, HEAL_SUB_SYS, MYSQL_DSN_STRING,
MYSQL_MAX_OPEN_CONNECTIONS, MYSQL_QUEUE_DIR, MYSQL_TABLE, SCANNER_CYCLE, SCANNER_IDLE_MODE, SCANNER_KEYS, SCANNER_SPEED,
SCANNER_SUB_SYS,
SCANNER_SUB_SYS, SCANNER_WALKERS_PER_DISK,
};
#[tokio::test]
@@ -2869,6 +2883,11 @@ mod tests {
#[derive(Debug, Default)]
struct MetadataWriteStore {
error: Option<Error>,
#[cfg(feature = "diagnostic-read-version-caller")]
observations:
std::sync::Mutex<Vec<(Option<rustfs_io_metrics::internode_metrics::ReadVersionEntrySource>, Option<&'static str>)>>,
#[cfg(feature = "diagnostic-read-version-caller")]
pending: bool,
}
#[async_trait::async_trait]
@@ -2899,7 +2918,20 @@ mod tests {
_data: &mut Self::PutObjectReader,
_opts: &Self::ObjectOptions,
) -> core::result::Result<Self::ObjectInfo, Self::Error> {
Err(self.error.clone().expect("test store error should be configured"))
#[cfg(feature = "diagnostic-read-version-caller")]
{
self.observations.lock().expect("observations lock").push((
crate::cluster::rpc::runtime_sources::current_read_version_entry_source(),
rustfs_io_metrics::s3_http_metrics::current_s3_http_operation_name(),
));
if self.pending {
std::future::pending::<()>().await;
}
}
match self.error.clone() {
Some(error) => Err(error),
None => Ok(ObjectInfo::default()),
}
}
}
@@ -2907,6 +2939,10 @@ mod tests {
async fn save_config_preserves_retryable_system_volume_errors() {
let store = Arc::new(MetadataWriteStore {
error: Some(Error::BucketNotFound(RUSTFS_META_BUCKET.to_string())),
#[cfg(feature = "diagnostic-read-version-caller")]
observations: Default::default(),
#[cfg(feature = "diagnostic-read-version-caller")]
pending: false,
});
let error =
save_config_with_opts_inner(store, "buckets/example/.metadata.bin", Vec::new(), &ObjectOptions::default(), false)
@@ -2918,6 +2954,59 @@ mod tests {
Error::InsufficientWriteQuorum(RUSTFS_META_BUCKET.to_string(), "buckets/example/.metadata.bin".to_string())
);
}
#[cfg(feature = "diagnostic-read-version-caller")]
#[tokio::test]
async fn diagnostic_config_write_retains_origin_and_api_for_results_and_cancellation() {
use crate::cluster::rpc::runtime_sources::{current_read_version_entry_source, with_read_version_entry};
use rustfs_io_metrics::internode_metrics::ReadVersionEntrySource;
use rustfs_io_metrics::s3_http_metrics::{S3HttpRequestGuard, current_s3_http_operation_context};
let mut guard = S3HttpRequestGuard::new("PUT");
let put = guard.in_scope(|| {
rustfs_io_metrics::record_s3_op(rustfs_s3_ops::S3Operation::PutObject);
current_s3_http_operation_context().expect("captured PutObject")
});
for earlier in [None, Some(ReadVersionEntrySource::TierConfigReload)] {
for api in [None, Some(put)] {
for case in ["success", "error", "cancelled"] {
let store = Arc::new(MetadataWriteStore {
error: (case == "error").then_some(Error::VolumeNotFound),
pending: case == "cancelled",
..Default::default()
});
let opts = ObjectOptions::default();
let write = async {
let result = tokio::time::timeout(
std::time::Duration::from_secs(1),
save_config_with_opts_inner(store.clone(), "buckets/diagnostic/state", Vec::new(), &opts, false),
)
.await;
match case {
"success" => assert!(result.expect("write completes").is_ok()),
"error" => {
assert!(matches!(result.expect("error completes"), Err(Error::InsufficientWriteQuorum(_, _))))
}
_ => assert!(result.is_err(), "pending write is cancelled"),
}
};
rustfs_io_metrics::s3_http_metrics::with_isolated_s3_http_operation_context(api, async {
match earlier {
Some(source) => with_read_version_entry(source, write).await,
None => write.await,
}
})
.await;
assert_eq!(
*store.observations.lock().expect("observations lock"),
vec![(Some(earlier.unwrap_or(ReadVersionEntrySource::ConfigWrite)), api.map(|_| "s3:PutObject"))]
);
assert_eq!(current_read_version_entry_source(), None, "write scope restores: {case}");
assert_eq!(current_s3_http_operation_context(), None, "API scope restores: {case}");
}
}
}
}
use rustfs_lock::client::LockClient;
use rustfs_lock::client::local::LocalClient;
use rustfs_lock::{LockError, LockInfo, LockResponse, LockStats};
@@ -4112,6 +4201,8 @@ mod tests {
let value = match *key {
SCANNER_SPEED => "fast",
SCANNER_IDLE_MODE => "false",
// "1" is this key's default, which the diff encoding omits.
SCANNER_WALKERS_PER_DISK => "2",
_ => "1",
};
scanner_kvs.insert((*key).to_string(), value.to_string());
@@ -4133,6 +4224,8 @@ mod tests {
let expected = match *key {
SCANNER_SPEED => "fast",
SCANNER_IDLE_MODE => "false",
// "1" is this key's default, which the diff encoding omits.
SCANNER_WALKERS_PER_DISK => "2",
_ => "1",
};
assert_eq!(decoded_scanner.get(key), expected, "scanner key {key} should survive roundtrip");
+22 -6
View File
@@ -15,12 +15,13 @@
use rustfs_config::server_config::{KV, KVS};
use rustfs_config::{
DEFAULT_SCANNER_BITROT_CYCLE_SECS, DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS, DEFAULT_SCANNER_CYCLE_MAX_DIRECTORIES,
DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS, DEFAULT_SCANNER_CYCLE_MAX_OBJECTS, DEFAULT_SCANNER_IDLE_MODE,
DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS, DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS, DEFAULT_SCANNER_SPEED,
DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS, SCANNER_ALERT_EXCESS_VERSION_SIZE,
SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT, SCANNER_CYCLE,
SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY, SCANNER_IDLE_MODE,
SCANNER_MAX_CONCURRENT_DISK_SCANS, SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_SPEED, SCANNER_START_DELAY,
DEFAULT_SCANNER_CYCLE_MAX_DURATION_SECS, DEFAULT_SCANNER_CYCLE_MAX_OBJECTS, DEFAULT_SCANNER_DISK_IO_BUDGET,
DEFAULT_SCANNER_IDLE_MODE, DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS, DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS,
DEFAULT_SCANNER_SPEED, DEFAULT_SCANNER_WALKERS_PER_DISK, DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS, SCANNER_ALERT_EXCESS_FOLDERS,
SCANNER_ALERT_EXCESS_VERSION_SIZE, SCANNER_ALERT_EXCESS_VERSIONS, SCANNER_BITROT_CYCLE, SCANNER_CACHE_SAVE_TIMEOUT,
SCANNER_CYCLE, SCANNER_CYCLE_MAX_DIRECTORIES, SCANNER_CYCLE_MAX_DURATION, SCANNER_CYCLE_MAX_OBJECTS, SCANNER_DELAY,
SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC, SCANNER_DISK_IO_BUDGET_IOPS, SCANNER_IDLE_MODE, SCANNER_MAX_CONCURRENT_DISK_SCANS,
SCANNER_MAX_CONCURRENT_SET_SCANS, SCANNER_MAX_WAIT, SCANNER_SPEED, SCANNER_START_DELAY, SCANNER_WALKERS_PER_DISK,
SCANNER_YIELD_EVERY_N_OBJECTS,
};
use std::sync::LazyLock;
@@ -92,6 +93,21 @@ pub static DEFAULT_KVS: LazyLock<KVS> = LazyLock::new(|| {
value: DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS.to_string(),
hidden_if_empty: false,
},
KV {
key: SCANNER_WALKERS_PER_DISK.to_owned(),
value: DEFAULT_SCANNER_WALKERS_PER_DISK.to_string(),
hidden_if_empty: false,
},
KV {
key: SCANNER_DISK_IO_BUDGET_IOPS.to_owned(),
value: DEFAULT_SCANNER_DISK_IO_BUDGET.to_owned(),
hidden_if_empty: false,
},
KV {
key: SCANNER_DISK_IO_BUDGET_BYTES_PER_SEC.to_owned(),
value: DEFAULT_SCANNER_DISK_IO_BUDGET.to_owned(),
hidden_if_empty: false,
},
KV {
key: SCANNER_YIELD_EVERY_N_OBJECTS.to_owned(),
value: DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS.to_string(),
+726 -4
View File
@@ -2983,6 +2983,11 @@ fn resolve_decommission_partial_listing_entry(
if let Some(entry) = entries.resolve(resolver) {
return Ok(entry);
}
if disk_error_count == 0
&& let Some(prefix_dir) = decommission_prefix_directory_candidate(entries.as_ref())
{
return Ok(prefix_dir);
}
let object = entries
.as_ref()
@@ -3005,6 +3010,24 @@ fn resolve_decommission_partial_listing_entry(
})
}
/// The prefix directory a partial listing sample consists of, when every
/// candidate is one.
///
/// The walker emits a directory with empty metadata only when it holds no
/// `xl.meta`, so such a sample is listing structure rather than erasure-coded
/// data: node-local files such as the MRF journal under
/// `.rustfs.sys/buckets/.heal/` create one on a single drive, and the
/// recursive walk lists any object beneath it as its own entry. Below the
/// directory quorum there is nothing to reconcile, so the callback receives the
/// prefix exactly as a quorum-resolved one and skips it. Objects whose metadata
/// is present but unreadable keep their metadata bytes and never match here;
/// mixed samples stay unresolved.
fn decommission_prefix_directory_candidate(candidates: &[Option<MetaCacheEntry>]) -> Option<MetaCacheEntry> {
let mut candidates = candidates.iter().flatten();
let first = candidates.next()?;
(first.is_dir() && candidates.all(MetaCacheEntry::is_dir)).then(|| first.clone())
}
fn validate_decommission_durable_ilm_copy(
path: &str,
source_record: &ValidatedDurableIlmRecord,
@@ -3691,6 +3714,55 @@ fn activation_pool_meta_lock_error(err: rustfs_lock::LockError) -> Error {
}
}
fn rebalance_worker_read_lock_error(err: rustfs_lock::LockError, object: &str) -> Error {
match err {
rustfs_lock::LockError::QuorumNotReached { required, achieved } => Error::NamespaceLockQuorumUnavailable {
mode: "read",
bucket: RUSTFS_META_BUCKET.to_string(),
object: object.to_string(),
required,
achieved,
},
other => Error::Lock(other),
}
}
/// Read-only admission to an already durable rebalance run. Keeping this
/// separate from the activation writer prevents admission from authorizing a
/// pool or rebalance metadata write under shared namespace locks.
pub(crate) struct PoolRebalanceWorkerFence {
pool_meta_guard: rustfs_lock::NamespaceLockGuard,
rebalance_meta_guard: rustfs_lock::NamespaceLockGuard,
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
}
impl PoolRebalanceWorkerFence {
pub(crate) fn set_fleet_proof(
&mut self,
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
) {
self.fleet_proof = fleet_proof;
}
pub(crate) fn ensure_held(&self) -> Result<()> {
if self.pool_meta_guard.is_lock_lost()
|| self.pool_meta_guard.is_released()
|| self.rebalance_meta_guard.is_lock_lost()
|| self.rebalance_meta_guard.is_released()
{
return Err(Error::other("worker admission lock lost before rebalance activation"));
}
if self
.fleet_proof
.as_ref()
.is_some_and(|proof| !crate::services::notification_sys::cross_pool_fence_fleet_proof_matches(proof))
{
return Err(Error::other(POOL_ACTIVATION_FLEET_PROOF_EXPIRED));
}
Ok(())
}
}
pub(crate) struct PoolRebalanceActivationFence {
pool_meta_guard: Arc<rustfs_lock::NamespaceLockGuard>,
rebalance_meta_guard: Arc<rustfs_lock::NamespaceLockGuard>,
@@ -3762,6 +3834,35 @@ tokio::task_local! {
pub(crate) static REBALANCE_ACTIVATION_LOCK_ATTEMPT: Arc<tokio::sync::Notify>;
}
pub(crate) async fn acquire_pool_rebalance_worker_locks<S>(pool: Arc<S>) -> Result<PoolRebalanceWorkerFence>
where
S: crate::storage_api_contracts::namespace::NamespaceLocking<
Error = Error,
NamespaceLock = rustfs_lock::NamespaceLockWrapper,
>,
{
// Match activation and entry admission: rebalance.bin -> pool.bin. Joining
// the same durable run must coexist with entries retaining their run fence.
let rebalance_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, REBAL_META_NAME).await?;
#[cfg(test)]
let _ = REBALANCE_ACTIVATION_LOCK_ATTEMPT.try_with(|attempted| attempted.notify_one());
let rebalance_meta_guard = rebalance_meta_lock
.get_read_lock(get_lock_acquire_timeout())
.await
.map_err(|err| rebalance_worker_read_lock_error(err, REBAL_META_NAME))?;
let pool_meta_lock = pool.new_ns_lock(RUSTFS_META_BUCKET, POOL_META_NAME).await?;
let pool_meta_guard = pool_meta_lock
.get_read_lock(get_lock_acquire_timeout())
.await
.map_err(|err| rebalance_worker_read_lock_error(err, POOL_META_NAME))?;
Ok(PoolRebalanceWorkerFence {
pool_meta_guard,
rebalance_meta_guard,
fleet_proof: None,
})
}
pub(crate) async fn acquire_pool_rebalance_activation_locks<S>(
pool: Arc<S>,
fleet_proof: Option<crate::services::notification_sys::CrossPoolFenceFleetProofToken>,
@@ -6685,12 +6786,12 @@ fn pool_meta_cas_options(object: &str, token: &PoolMetaCasToken, fence: &PoolMet
fence.ensure_held()?;
let mut opts = ObjectOptions {
max_parity: true,
write_completion: crate::object_api::WriteCompletion::TailDrained,
no_lock: true,
http_preconditions: Some(pool_meta_cas_preconditions(token, object)?),
..Default::default()
};
fence.add_to_options(&mut opts, object);
opts.write_completion = crate::object_api::WriteCompletion::TailDrainedWithOwnerRepair;
Ok(opts)
}
@@ -6718,6 +6819,72 @@ async fn execute_pool_meta_object_cas<S>(
fence: &PoolMetaPersistenceFence<'_>,
phase: &'static str,
) -> PoolMetaCasWriteOutcome
where
S: EcstoreObjectIO,
{
// Bound the CAS state retained by callers, including ordinary metadata
// transactions that never enter identity repair. Large inline retry states
// otherwise propagate through startup and cross-crate storage futures.
Box::pin(async move {
if object != POOL_META_IDENTITY_NAME {
return execute_pool_meta_object_cas_once(pool, object, data, opts, fence, phase).await;
}
// Keep the exact identity owned by this transaction. A quorum read alone
// cannot discharge repair responsibility for a missing disk replica.
let identity = data;
let mut outcome = execute_pool_meta_object_cas_once(pool.clone(), object, identity.clone(), opts, fence, phase).await;
for delay_secs in [1, 2, 4] {
if !outcome.source.as_deref().is_some_and(|err| {
matches!(err, Error::Io(inner) if inner.get_ref().is_some_and(|source| source.is::<crate::set_disk::SystemMetadataRepairRequired>()))
}) {
break;
}
// The caller's transaction arm remains live across reconnect waits,
// verification and repair. Cancellation must retain its sticky gate.
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
let repair_options = async {
fence.ensure_held()?;
let (current, info) = read_config_no_lock_preserve_empty_with_metadata(pool.clone(), object).await?;
if current != identity {
return Err(Error::PreconditionFailed);
}
let token = info
.etag
.filter(|etag| !etag.trim().is_empty())
.map(PoolMetaCasToken::Existing)
.unwrap_or(PoolMetaCasToken::Unsafe);
pool_meta_cas_options(object, &token, fence)
}
.await;
outcome = match repair_options {
Ok(opts) => execute_pool_meta_object_cas_once(pool.clone(), object, identity.clone(), opts, fence, phase).await,
Err(err) => PoolMetaCasWriteOutcome::not_dispatched(err),
};
// A rejected repair cannot turn the preceding committed write into a
// harmless CAS conflict that the outer loop could accept on readback.
outcome.may_have_mutated = true;
outcome.result = outcome.result.map_err(|err| {
let original = outcome.source.get_or_insert_with(|| Arc::new(err));
Error::other(pool_metadata_error(
crate::error::PoolMetadataFailure::TransactionUnknown,
phase,
Some(Arc::clone(original)),
))
});
}
outcome
})
.await
}
async fn execute_pool_meta_object_cas_once<S>(
pool: Arc<S>,
object: &str,
data: Vec<u8>,
opts: ObjectOptions,
fence: &PoolMetaPersistenceFence<'_>,
phase: &'static str,
) -> PoolMetaCasWriteOutcome
where
S: EcstoreObjectIO,
{
@@ -6735,7 +6902,11 @@ where
"payload_sha256": rustfs_utils::crypto::hex(Sha256::digest(&data)),
"if_match": opts.http_preconditions.as_ref().and_then(|p| p.if_match.as_deref()),
"if_none_match": opts.http_preconditions.as_ref().and_then(|p| p.if_none_match.as_deref()),
"tail_drained": opts.write_completion == crate::object_api::WriteCompletion::TailDrained,
"tail_drained": matches!(
opts.write_completion,
crate::object_api::WriteCompletion::TailDrained
| crate::object_api::WriteCompletion::TailDrainedWithOwnerRepair
),
"no_lock": opts.no_lock,
})
});
@@ -12430,6 +12601,18 @@ impl ECStore {
Ok(pool_meta)
}
pub(crate) async fn load_runtime_pool_meta_under_worker_fence(
&self,
write_state: &mut PoolMetaWriteState,
worker_fence: &PoolRebalanceWorkerFence,
operation: &str,
) -> Result<PoolMeta> {
worker_fence.ensure_held()?;
let pool_meta = self.load_runtime_pool_meta_observing(write_state, operation).await?;
worker_fence.ensure_held()?;
Ok(pool_meta)
}
pub(crate) async fn load_runtime_pool_meta_under_activation_fence(
&self,
write_state: &mut PoolMetaWriteState,
@@ -24124,7 +24307,7 @@ mod pools_tests {
};
use crate::bucket::metadata_sys;
use crate::data_movement;
use crate::disk::{STORAGE_FORMAT_FILE, endpoint::Endpoint};
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET, STORAGE_FORMAT_FILE, endpoint::Endpoint};
use crate::error::{Error, StorageError};
use crate::layout::endpoints::{EndpointServerPools, Endpoints, PoolEndpoints};
use crate::object_api::{ObjectInfo, ObjectOptions};
@@ -24216,6 +24399,335 @@ mod pools_tests {
identity: StdMutex<Option<(Vec<u8>, String)>>,
}
#[test]
fn pool_meta_cas_future_remains_stack_bounded() {
let pool = Arc::new(PartialPoolMetaWriteStorage::default());
let fence = PoolMetaPersistenceFence::OfflineRecovery;
let future = super::execute_pool_meta_object_cas(
pool.clone(),
POOL_META_IDENTITY_NAME,
b"owned-identity".to_vec(),
ObjectOptions::default(),
&fence,
"identity_cas",
);
let original = super::execute_pool_meta_object_cas_once(
pool,
POOL_META_IDENTITY_NAME,
b"owned-identity".to_vec(),
ObjectOptions::default(),
&fence,
"identity_cas",
);
let future_size = std::mem::size_of_val(&future);
let original_size = std::mem::size_of_val(&original);
assert!(
future_size <= 4 * 1024,
"pool metadata CAS future must remain stack-bounded; measured {future_size} bytes versus {original_size} without repair"
);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum IdentityRepairFault {
Healthy,
Once,
Always,
Changed,
Unreadable,
TypedReadFailure,
MissingEtag,
EmptyEtag,
Reject,
Unknown,
BlockBackoff,
BlockRead,
BlockWrite,
}
#[derive(Debug)]
struct IdentityRepairStorage {
inner: PartialPoolMetaWriteStorage,
fault: IdentityRepairFault,
writes: AtomicUsize,
reads: AtomicUsize,
blocked: tokio::sync::Notify,
}
impl IdentityRepairStorage {
fn new(fault: IdentityRepairFault) -> Arc<Self> {
Arc::new(Self {
inner: PartialPoolMetaWriteStorage::default(),
fault,
writes: AtomicUsize::new(0),
reads: AtomicUsize::new(0),
blocked: tokio::sync::Notify::new(),
})
}
}
#[async_trait::async_trait]
impl ObjectIO for IdentityRepairStorage {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = http::HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = crate::object_api::GetObjectReader;
type PutObjectReader = crate::object_api::PutObjReader;
async fn get_object_reader(
&self,
bucket: &str,
object: &str,
range: Option<HTTPRangeSpec>,
headers: http::HeaderMap,
opts: &ObjectOptions,
) -> std::result::Result<Self::GetObjectReader, Error> {
self.reads.fetch_add(1, Ordering::SeqCst);
if self.fault == IdentityRepairFault::Unreadable {
return Err(Error::ErasureReadQuorum);
}
if self.fault == IdentityRepairFault::TypedReadFailure {
return Err(Error::other(super::pool_metadata_error(
crate::error::PoolMetadataFailure::ReadUnavailable,
"identity_repair_read",
Some(Arc::new(Error::ErasureReadQuorum)),
)));
}
if self.fault == IdentityRepairFault::BlockRead {
self.blocked.notify_one();
std::future::pending::<()>().await;
}
let mut reader = self.inner.get_object_reader(bucket, object, range, headers, opts).await?;
match self.fault {
IdentityRepairFault::Changed => {
reader.stream = Box::new(Cursor::new(b"other-identity".to_vec()));
reader.object_info.size = 14;
}
IdentityRepairFault::MissingEtag => reader.object_info.etag = None,
IdentityRepairFault::EmptyEtag => reader.object_info.etag = Some(" ".to_owned()),
_ => {}
}
Ok(reader)
}
async fn put_object(
&self,
bucket: &str,
object: &str,
data: &mut Self::PutObjectReader,
opts: &ObjectOptions,
) -> std::result::Result<ObjectInfo, Error> {
let attempt = self.writes.fetch_add(1, Ordering::SeqCst);
assert_eq!(opts.write_completion, crate::object_api::WriteCompletion::TailDrainedWithOwnerRepair);
assert!(opts.max_parity && opts.no_lock);
if attempt > 0 {
assert_eq!(
opts.http_preconditions
.as_ref()
.and_then(HTTPPreconditions::if_none_match_value),
None
);
assert_eq!(
opts.http_preconditions.as_ref().and_then(HTTPPreconditions::if_match_value),
self.inner
.identity
.lock()
.expect("identity lock")
.as_ref()
.map(|(_, etag)| etag.as_str()),
"repair must use the observed post-commit CAS token",
);
if self.fault == IdentityRepairFault::Reject {
return Err(Error::PreconditionFailed);
}
if self.fault == IdentityRepairFault::BlockWrite {
self.blocked.notify_one();
std::future::pending::<()>().await;
}
}
let info = self.inner.put_object(bucket, object, data, opts).await?;
if self.fault == IdentityRepairFault::Unknown {
return Err(Error::Timeout);
}
if self.fault != IdentityRepairFault::Healthy && (attempt == 0 || self.fault == IdentityRepairFault::Always) {
if self.fault == IdentityRepairFault::BlockBackoff {
self.blocked.notify_one();
}
return Err(Error::other(crate::set_disk::SystemMetadataRepairRequired));
}
Ok(info)
}
}
#[tokio::test(start_paused = true)]
async fn pool_identity_owner_repair_preserves_cas_and_fail_closed_boundaries() {
for (fault, object, expected_writes, expected_reads, succeeds) in [
(IdentityRepairFault::Healthy, POOL_META_IDENTITY_NAME, 1, 0, true),
(IdentityRepairFault::Once, POOL_META_IDENTITY_NAME, 2, 1, true),
(IdentityRepairFault::Always, POOL_META_IDENTITY_NAME, 4, 3, false),
(IdentityRepairFault::Changed, POOL_META_IDENTITY_NAME, 1, 1, false),
(IdentityRepairFault::Unreadable, POOL_META_IDENTITY_NAME, 1, 1, false),
(IdentityRepairFault::TypedReadFailure, POOL_META_IDENTITY_NAME, 1, 1, false),
(IdentityRepairFault::MissingEtag, POOL_META_IDENTITY_NAME, 1, 1, false),
(IdentityRepairFault::EmptyEtag, POOL_META_IDENTITY_NAME, 1, 1, false),
(IdentityRepairFault::Reject, POOL_META_IDENTITY_NAME, 2, 1, false),
(IdentityRepairFault::Unknown, POOL_META_IDENTITY_NAME, 1, 0, false),
(IdentityRepairFault::Once, POOL_META_NAME, 1, 0, false),
] {
let pool = IdentityRepairStorage::new(fault);
let state = super::PoolMetaWriteState::default();
let mut transaction = state.arm_transaction();
let started = tokio::time::Instant::now();
let result = save_pool_meta_object_cas(
pool.clone(),
object,
b"owned-identity".to_vec(),
&PoolMetaCasToken::Missing,
&PoolMetaPersistenceFence::OfflineRecovery,
"identity_cas",
&mut transaction,
)
.await;
assert_eq!(result.is_ok(), succeeds, "{fault:?}: {result:?}");
let original_source = result
.as_ref()
.err()
.and_then(|err| err.pool_metadata_failure())
.and_then(|failure| failure.source.clone());
if fault == IdentityRepairFault::TypedReadFailure {
assert_eq!(
original_source
.as_ref()
.expect("read failure source")
.pool_metadata_failure()
.expect("retain the original read context")
.kind,
crate::error::PoolMetadataFailure::ReadUnavailable
);
}
if succeeds {
transaction.disarm();
} else {
assert_eq!(
result
.expect_err("failed repair")
.pool_metadata_failure()
.expect("typed uncertainty")
.kind,
crate::error::PoolMetadataFailure::TransactionUnknown,
"{fault:?}"
);
}
drop(transaction);
let status = state.ensure_write_safe("after repair");
assert_eq!(status.is_ok(), succeeds, "{fault:?}");
if let Err(blocked) = status {
let source = blocked
.pool_metadata_failure()
.expect("blocked transaction context")
.source
.as_ref()
.expect("blocked transaction source");
assert!(
Arc::ptr_eq(source, original_source.as_ref().expect("caller failure source")),
"caller and latch must retain the same original cause: {fault:?}"
);
}
assert_eq!(pool.writes.load(Ordering::SeqCst), expected_writes, "{fault:?}");
assert_eq!(pool.reads.load(Ordering::SeqCst), expected_reads, "{fault:?}");
if fault == IdentityRepairFault::Always {
assert_eq!(started.elapsed(), StdDuration::from_secs(7), "repair backoff must be bounded");
}
}
}
#[tokio::test(start_paused = true)]
async fn pool_identity_owner_repair_cancellation_keeps_the_transaction_armed() {
for fault in [
IdentityRepairFault::BlockBackoff,
IdentityRepairFault::BlockRead,
IdentityRepairFault::BlockWrite,
] {
let pool = IdentityRepairStorage::new(fault);
let state = Arc::new(tokio::sync::Mutex::new(super::PoolMetaWriteState::default()));
let task = tokio::spawn({
let pool = pool.clone();
let state = state.clone();
async move {
let state = state.lock().await;
let mut transaction = state.arm_transaction();
save_pool_meta_object_cas(
pool,
POOL_META_IDENTITY_NAME,
b"owned-identity".to_vec(),
&PoolMetaCasToken::Missing,
&PoolMetaPersistenceFence::OfflineRecovery,
"identity_cas",
&mut transaction,
)
.await
}
});
pool.blocked.notified().await;
task.abort();
assert!(task.await.expect_err("cancelled repair").is_cancelled());
let state = state.lock().await;
let error = state
.ensure_write_safe("cancelled repair")
.expect_err("partial identity remains blocked");
assert_eq!(error.pool_metadata_failure().expect("typed uncertainty").phase, "identity_cas");
assert_eq!(state.active_transactions.load(Ordering::SeqCst), 0);
assert!(pool.inner.identity.lock().expect("identity lock").is_some());
}
}
#[tokio::test]
async fn pool_identity_owner_repair_stops_after_fence_loss() {
let client = Arc::new(LocalClient::with_manager(Arc::new(GlobalLockManager::new())));
let lock = NamespaceLock::with_clients_and_quorum("identity-repair-fence".to_string(), vec![client], 1);
let request = LockRequest::new(
ObjectKey::new(super::RUSTFS_META_BUCKET, POOL_META_NAME),
LockType::Exclusive,
"identity-repair-owner",
)
.with_acquire_timeout(StdDuration::from_secs(1))
.with_ttl(StdDuration::from_millis(50))
.with_refresh_interval(StdDuration::from_millis(50));
let owner = Arc::new(
lock.acquire_guard(&request)
.await
.expect("acquire fence")
.expect("available fence"),
);
let fence = PoolMetaPersistenceFence::Owned(owner.clone());
let pool = IdentityRepairStorage::new(IdentityRepairFault::Once);
let state = super::PoolMetaWriteState::default();
let mut transaction = state.arm_transaction();
let error = save_pool_meta_object_cas(
pool.clone(),
POOL_META_IDENTITY_NAME,
b"owned-identity".to_vec(),
&PoolMetaCasToken::Missing,
&fence,
"identity_cas",
&mut transaction,
)
.await
.expect_err("expired owner must not repair");
assert!(owner.is_lock_lost());
assert_eq!(
error.pool_metadata_failure().expect("typed uncertainty").kind,
crate::error::PoolMetadataFailure::TransactionUnknown
);
assert_eq!(pool.writes.load(Ordering::SeqCst), 1);
assert_eq!(pool.reads.load(Ordering::SeqCst), 0);
drop(transaction);
state
.ensure_write_safe("lost repair fence")
.expect_err("lost fence retains the write gate");
}
#[test]
fn decommission_target_gate_retry_is_bounded() {
assert!(!decommission_capacity_target_gate_retry_exhausted(0));
@@ -24692,7 +25204,7 @@ mod pools_tests {
_ => panic!("phase fixture must receive valid pool metadata"),
};
assert!(opts.max_parity && opts.no_lock);
assert_eq!(opts.write_completion, crate::object_api::WriteCompletion::TailDrained);
assert_eq!(opts.write_completion, crate::object_api::WriteCompletion::TailDrainedWithOwnerRepair);
self.trace.calls.lock().expect("trace lock").push(WriteCall {
pool: self.pool,
phase,
@@ -28293,6 +28805,122 @@ mod pools_tests {
assert!(message.contains("1 disk error(s)"));
}
fn decommission_listing_resolver() -> MetadataResolutionParams {
MetadataResolutionParams {
dir_quorum: 2,
obj_quorum: 2,
bucket: RUSTFS_META_BUCKET.to_string(),
..Default::default()
}
}
fn node_local_prefix_directory_entry() -> MetaCacheEntry {
MetaCacheEntry {
name: "buckets/.heal/".to_string(),
..Default::default()
}
}
fn single_version_object_entry(name: &str) -> MetaCacheEntry {
let mut meta = rustfs_filemeta::FileMeta::new();
meta.add_version(FileInfo {
volume: RUSTFS_META_BUCKET.to_string(),
name: name.to_string(),
size: 1,
mod_time: Some(OffsetDateTime::now_utc()),
..Default::default()
})
.expect("test file metadata should accept object version");
MetaCacheEntry {
name: name.to_string(),
metadata: meta.marshal_msg().expect("test file metadata should marshal"),
..Default::default()
}
}
#[test]
fn test_resolve_decommission_partial_listing_entry_passes_prefix_directory_below_dir_quorum() {
let entry = resolve_decommission_partial_listing_entry(
MetaCacheEntries(vec![Some(node_local_prefix_directory_entry()), None, None]),
decommission_listing_resolver(),
RUSTFS_META_BUCKET,
BUCKET_META_PREFIX,
0,
1,
0,
OffsetDateTime::now_utc(),
)
.expect("a prefix directory on one healthy drive is listing structure, not unresolved metadata");
assert_eq!(entry.name, "buckets/.heal/");
assert!(entry.is_dir());
}
#[test]
fn test_resolve_decommission_partial_listing_entry_keeps_prefix_directory_fail_closed_on_disk_error() {
let unresolved_entry = resolve_decommission_partial_listing_entry(
MetaCacheEntries(vec![Some(node_local_prefix_directory_entry()), None, None]),
decommission_listing_resolver(),
RUSTFS_META_BUCKET,
BUCKET_META_PREFIX,
1,
1,
0,
OffsetDateTime::now_utc(),
)
.expect_err("a prefix directory sampled beside a drive error must stay fail-closed");
assert_eq!(unresolved_entry.bucket, RUSTFS_META_BUCKET);
assert_eq!(unresolved_entry.object, "buckets/.heal/");
assert_eq!(unresolved_entry.candidate_count, 1);
assert_eq!(unresolved_entry.disk_error_count, 1);
assert_eq!(unresolved_entry.reason, "metadata_resolution_failed");
}
#[test]
fn test_resolve_decommission_partial_listing_entry_rejects_object_below_obj_quorum() {
let object = "buckets/photos/.metadata.bin";
let unresolved_entry = resolve_decommission_partial_listing_entry(
MetaCacheEntries(vec![Some(single_version_object_entry(object)), None, None]),
decommission_listing_resolver(),
RUSTFS_META_BUCKET,
BUCKET_META_PREFIX,
0,
1,
0,
OffsetDateTime::now_utc(),
)
.expect_err("an object sampled below the metadata quorum must stay unresolved");
assert_eq!(unresolved_entry.object, object);
assert_eq!(unresolved_entry.candidate_count, 1);
assert_eq!(unresolved_entry.disk_error_count, 0);
assert_eq!(unresolved_entry.reason, "metadata_resolution_failed");
}
#[test]
fn test_resolve_decommission_partial_listing_entry_rejects_mixed_prefix_and_directory_object_sample() {
let unresolved_entry = resolve_decommission_partial_listing_entry(
MetaCacheEntries(vec![
Some(node_local_prefix_directory_entry()),
Some(single_version_object_entry("buckets/.heal/")),
None,
]),
decommission_listing_resolver(),
RUSTFS_META_BUCKET,
BUCKET_META_PREFIX,
0,
1,
0,
OffsetDateTime::now_utc(),
)
.expect_err("a prefix directory beside a same-name directory object must stay unresolved");
assert_eq!(unresolved_entry.object, "buckets/.heal/");
assert_eq!(unresolved_entry.candidate_count, 2);
assert_eq!(unresolved_entry.disk_error_count, 0);
}
#[test]
fn unresolved_entry_ledger_requires_individual_verification_before_completion() {
let generation = OffsetDateTime::now_utc();
@@ -28458,6 +29086,100 @@ mod pools_tests {
);
}
#[tokio::test]
async fn decommission_listing_skips_node_local_heal_journal_prefix() {
let (dirs, store) = metadata_sys::test_support::isolated_store_over_temp_disks().await;
metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
let generation = OffsetDateTime::now_utc();
{
let mut pool_meta = store.pool_meta.write().await;
pool_meta.dont_save = false;
pool_meta.pools[0].decommission = Some(PoolDecommissionInfo {
start_time: Some(generation),
..Default::default()
});
}
store
.save_current_pool_meta_for_test(&[0])
.await
.expect("active decommission metadata should be persisted before listing");
// The MRF queue writes its journals with raw local-disk writes on the
// node that owns them, so one drive ends up with a plain directory tree
// that holds no xl.meta anywhere.
let journal_dir = dirs[0]
.path()
.join(RUSTFS_META_BUCKET)
.join(BUCKET_META_PREFIX)
.join(".heal")
.join("mrf");
tokio::fs::create_dir_all(&journal_dir)
.await
.expect("node-local journal directory should be created");
tokio::fs::write(journal_dir.join("journal.bin"), b"")
.await
.expect("legacy journal should be written");
tokio::fs::write(journal_dir.join("journal-scoped.bin"), b"scoped-journal")
.await
.expect("scoped journal should be written");
let listed = Arc::new(tokio::sync::Mutex::new(Vec::<MetaCacheEntry>::new()));
let listed_cb = listed.clone();
let callback: ListCallback = Arc::new(move |entry: MetaCacheEntry| {
let listed = listed_cb.clone();
Box::pin(async move {
listed.lock().await.push(entry);
})
});
let entry_error = Arc::new(tokio::sync::Mutex::new(None::<Error>));
store.pools[0].disk_set[0]
.list_objects_to_decommission(
store.clone(),
CancellationToken::new(),
DecomBucketInfo {
name: RUSTFS_META_BUCKET.to_string(),
prefix: BUCKET_META_PREFIX.to_string(),
},
callback,
entry_error.clone(),
0,
0,
generation,
false,
)
.await
.expect("a node-local journal directory must not fail the decommission listing");
assert!(entry_error.lock().await.is_none(), "{:?}", entry_error.lock().await);
assert!(
store.pool_meta.read().await.pools[0]
.decommission
.as_ref()
.expect("decommission should remain active")
.unresolved_entries
.is_empty(),
"a node-local prefix must not be recorded as unresolved object metadata"
);
let listed = listed.lock().await;
let heal_entries = listed
.iter()
.filter(|entry| entry.name.starts_with("buckets/.heal/"))
.collect::<Vec<_>>();
assert!(
heal_entries
.iter()
.any(|entry| entry.name == "buckets/.heal/" && entry.is_dir()),
"the journal prefix should reach the callback as a skippable directory: {heal_entries:?}"
);
assert!(
heal_entries.iter().all(|entry| !entry.is_object()),
"raw journal files must never surface as objects: {heal_entries:?}"
);
}
#[tokio::test]
async fn decommission_metadata_saves_stay_monotonic_across_clock_rollback_before_terminal_restart() {
let (_dirs, store) = metadata_sys::test_support::isolated_store_over_temp_disks().await;
+19
View File
@@ -893,6 +893,8 @@ fn is_data_movement_rewritten_metadata(object_info: &ObjectInfo, key: &str, norm
SUFFIX_ACTUAL_SIZE,
SUFFIX_CRC,
SUFFIX_PART_CHECKSUMS,
// Inline placement is local to each physical copy, like its data directory.
rustfs_utils::http::SUFFIX_INLINE_DATA,
crate::object_api::ENCRYPTED_PART_LAYOUT_QUORUM_SUFFIX,
]
.iter()
@@ -3532,6 +3534,23 @@ mod tests {
assert!(overwrite_resume_for_target(&source, source.clone()));
}
#[test]
fn test_data_movement_identity_accepts_inline_layout_change_only() {
let source = overwrite_equivalence_source();
let mut target = source.clone();
rustfs_utils::http::insert_str(
Arc::make_mut(&mut target.user_defined),
rustfs_utils::http::SUFFIX_INLINE_DATA,
"true".to_string(),
);
assert!(is_equivalent_data_movement_object(&source, &target));
assert!(is_equivalent_data_movement_object(&target, &source));
Arc::make_mut(&mut target.user_defined).insert("x-amz-meta-inline-data".to_string(), "user value".to_string());
assert!(!is_equivalent_data_movement_object(&source, &target));
assert!(!is_equivalent_data_movement_object(&target, &source));
}
#[test]
fn test_data_movement_overwrite_resume_accepts_destination_marker() {
let source = overwrite_equivalence_source();
@@ -110,19 +110,17 @@ async fn read_replica(set: Arc<SetDisks>) -> Result<ScannerPauseBacklogRetiremen
set_index: set.set_index,
data: None,
};
let reader = match set
.get_object_reader(
RUSTFS_META_BUCKET,
SCANNER_PAUSE_BACKLOG_PATH,
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
{
let opts = ObjectOptions {
no_lock: true,
..Default::default()
};
let read = set.get_object_reader(RUSTFS_META_BUCKET, SCANNER_PAUSE_BACKLOG_PATH, None, HeaderMap::new(), &opts);
#[cfg(feature = "diagnostic-read-version-caller")]
let read = crate::cluster::rpc::runtime_sources::with_read_version_entry(
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::ScannerBacklogRead,
read,
);
let reader = match read.await {
Ok(reader) => reader,
Err(err) if is_err_object_not_found(&err) || is_err_version_not_found(&err) => {
return Ok(ScannerPauseBacklogRetirementRead { replica, etag: None });
+399 -14
View File
@@ -1325,6 +1325,18 @@ fn select_admin_data_usage_snapshot(
&& authoritative.is_complete_bucket_usage_snapshot()
&& observed_data_usage_is_newer(&observed, &authoritative) =>
{
// A bucket can span several sets. Without a current or retained
// generation for every live set, its observed sum is only a subset,
// not a replacement for the last complete bucket total. Keep the
// baseline's timestamp and provenance until coverage is available.
if authoritative_format
&& observed
.usage_snapshot_set_states
.iter()
.any(|state| !state.tombstone && state.scanner_cycle.is_none())
{
return (authoritative, authoritative_format);
}
(merge_partial_observation_for_admin(authoritative, observed), true)
}
Some(observed) if observed_data_usage_is_newer(&observed, &authoritative) => (observed, true),
@@ -2466,7 +2478,13 @@ async fn apply_bucket_usage_memory_overlay_if_authoritative(data_usage_info: &mu
}
continue;
}
if !cached.stale_snapshot_pending && persisted_update.is_some_and(|persisted| cached.usage_updated_at <= persisted) {
// An observed scan can finish after mutations it never enumerated. Its
// completion time cannot supersede unreconciled single-process counts.
let observed_dirty_usage = data_usage_info.usage_snapshot_converged == Some(false) && cached.dirty;
if !cached.stale_snapshot_pending
&& !observed_dirty_usage
&& persisted_update.is_some_and(|persisted| cached.usage_updated_at <= persisted)
{
continue;
}
@@ -2529,19 +2547,17 @@ where
use crate::object_api::ObjectOptions;
use http::HeaderMap;
match store
.get_object_reader(
RUSTFS_META_BUCKET,
key,
None,
HeaderMap::new(),
&ObjectOptions {
no_lock: true,
..Default::default()
},
)
.await
{
let opts = ObjectOptions {
no_lock: true,
..Default::default()
};
let read = store.get_object_reader(RUSTFS_META_BUCKET, key, None, HeaderMap::new(), &opts);
#[cfg(feature = "diagnostic-read-version-caller")]
let read = crate::cluster::rpc::runtime_sources::with_read_version_entry(
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::DataUsageCacheRead,
read,
);
match read.await {
// A cache object that fails to decode is treated as absent rather than
// as an error: a corrupt cache should not stall the caller, and the
// next scanner pass rewrites it.
@@ -3064,6 +3080,8 @@ mod tests {
transient_failures: Mutex<usize>,
reads: Mutex<Vec<String>>,
terminal_error: Mutex<Option<Error>>,
#[cfg(feature = "diagnostic-read-version-caller")]
record_context: bool,
}
impl UsageCacheReadStore {
@@ -3072,6 +3090,8 @@ mod tests {
transient_failures: Mutex::new(n),
reads: Mutex::new(Vec::new()),
terminal_error: Mutex::new(None),
#[cfg(feature = "diagnostic-read-version-caller")]
record_context: false,
}
}
@@ -3080,6 +3100,8 @@ mod tests {
transient_failures: Mutex::new(0),
reads: Mutex::new(Vec::new()),
terminal_error: Mutex::new(Some(error)),
#[cfg(feature = "diagnostic-read-version-caller")]
record_context: false,
}
}
@@ -3106,6 +3128,12 @@ mod tests {
_h: Self::HeaderMap,
_opts: &Self::ObjectOptions,
) -> Result<Self::GetObjectReader, Self::Error> {
#[cfg(feature = "diagnostic-read-version-caller")]
if self.record_context {
crate::cluster::rpc::runtime_sources::record_remote_disk_grpc_read_version_request(
&crate::disk::ReadOptions::default(),
);
}
self.reads.lock().await.push(object.to_string());
if let Some(error) = self.terminal_error.lock().await.clone() {
return Err(error);
@@ -3205,6 +3233,89 @@ mod tests {
);
}
#[cfg(feature = "diagnostic-read-version-caller")]
#[test]
fn diagnostic_usage_cache_read_preserves_api_and_earlier_entry() {
use crate::cluster::rpc::runtime_sources::with_read_version_entry;
use metrics_util::debugging::{DebugValue, DebuggingRecorder};
use rustfs_io_metrics::internode_metrics::ReadVersionEntrySource;
use rustfs_io_metrics::s3_http_metrics::{
S3HttpRequestGuard, current_s3_http_operation_context, with_s3_http_operation_context,
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("diagnostic usage cache runtime");
let recorder = DebuggingRecorder::new();
let snapshots = recorder.snapshotter();
rustfs_io_metrics::set_get_stage_metrics_enabled(true);
let mut request = S3HttpRequestGuard::new("GET");
let get = request.in_scope(|| {
rustfs_io_metrics::record_s3_op(rustfs_s3_ops::S3Operation::GetObject);
current_s3_http_operation_context().expect("dispatched GET operation")
});
request.response(200);
metrics::with_local_recorder(&recorder, || {
runtime.block_on(async {
let ambient = crate::cluster::rpc::runtime_sources::ReadVersionDiagnosticContext::capture();
for (operation, source) in [
(None, None),
(Some(get), None),
(None, Some(ReadVersionEntrySource::ServerInfoPools)),
(Some(get), Some(ReadVersionEntrySource::StoreObjectReader)),
] {
let store = UsageCacheReadStore {
record_context: true,
..Default::default()
};
with_s3_http_operation_context(operation, async {
let load = load_data_usage_cache(&store, "usage-cache");
let cache = match source {
Some(source) => with_read_version_entry(source, load).await,
None => load.await,
}
.expect("missing diagnostic cache is harmless");
assert!(cache.cache.is_empty());
assert_eq!(store.read_keys().await.len(), 2, "both prefixed and legacy cache reads are observed");
})
.await;
assert_eq!(
crate::cluster::rpc::runtime_sources::ReadVersionDiagnosticContext::capture(),
ambient,
"cache reads must restore the caller's complete diagnostic scope"
);
}
assert_eq!(current_s3_http_operation_context(), None);
});
});
let rows = snapshots.snapshot().into_vec();
for (source, operation, state) in [
("data_usage_cache_read", "unattributed", "absent"),
("data_usage_cache_read", "s3:GetObject", "preserved"),
("server_info_pools", "unattributed", "absent"),
("store_object_reader", "s3:GetObject", "preserved"),
] {
assert!(
rows.iter().any(|(key, _, _, value)| {
let labels = key
.key()
.labels()
.map(|label| (label.key(), label.value()))
.collect::<std::collections::HashMap<_, _>>();
key.key().name() == "rustfs_storage_diagnostic_context_transitions_total"
&& labels.get("entry_source") == Some(&source)
&& labels.get("boundary") == Some(&"remote_rpc")
&& labels.get("parent_api_operation") == Some(&operation)
&& labels.get("child_api_operation") == Some(&operation)
&& labels.get("api_context") == Some(&state)
&& matches!(value, DebugValue::Counter(2))
}),
"cache reads must retain API and the earliest observed entry: {source}/{operation}"
);
}
}
#[tokio::test]
async fn data_usage_removal_surfaces_missing_system_volume_as_read_quorum() {
for cause in [Error::BucketNotFound(RUSTFS_META_BUCKET.to_string()), Error::VolumeNotFound] {
@@ -3718,6 +3829,169 @@ mod tests {
assert!(selected.unknown_tier_stats.is_none());
}
fn usage_snapshots_with_missing_observed_set() -> (DataUsageInfo, DataUsageInfo) {
let baseline_time = SystemTime::UNIX_EPOCH + Duration::from_secs(10);
let mut authoritative = data_usage_info_for_test("large-bucket", 510_000, 580_000_000_000, baseline_time);
authoritative.scanner_epoch = Some(4);
authoritative.scanner_cycle = Some(10);
authoritative.usage_snapshot_converged = Some(true);
let mut observed = data_usage_info_for_test("large-bucket", 3_000, 3_000_000_000, baseline_time + Duration::from_secs(1));
observed.scanner_epoch = Some(4);
observed.scanner_cycle = Some(11);
observed.usage_snapshot_complete = false;
observed.usage_snapshot_partial = true;
observed.usage_snapshot_converged = Some(false);
observed.usage_snapshot_authoritative_baseline = Some(authoritative.snapshot_identity());
observed.usage_snapshot_set_states = vec![
rustfs_data_usage::DataUsageSnapshotSetState {
pool_index: 0,
set_index: 0,
scanner_epoch: Some(4),
scanner_cycle: Some(11),
scan_plan_digest: Some([1; 32]),
complete: true,
..Default::default()
},
rustfs_data_usage::DataUsageSnapshotSetState {
pool_index: 1,
set_index: 0,
scan_plan_digest: Some([1; 32]),
..Default::default()
},
];
assert!(
observed.is_valid_partial_snapshot(),
"a missing set is valid diagnostic progress, not a bucket total"
);
(authoritative, observed)
}
#[test]
fn partial_admin_observation_does_not_replace_totals_with_a_missing_set() {
let (authoritative, observed) = usage_snapshots_with_missing_observed_set();
let identity = authoritative.snapshot_identity();
let (selected, _) = select_admin_data_usage_snapshot(authoritative, true, Some(observed));
assert_eq!(selected.buckets_usage["large-bucket"].objects_count, 510_000);
assert_eq!(selected.buckets_usage["large-bucket"].size, 580_000_000_000);
assert_eq!(selected.objects_total_count, 510_000);
assert_eq!(selected.objects_total_size, 580_000_000_000);
assert_eq!(
selected.snapshot_identity(),
identity,
"retained counts must not gain a newer timestamp or generation"
);
assert!(selected.is_complete_bucket_usage_snapshot());
}
#[tokio::test]
async fn partial_admin_observation_does_not_promote_a_legacy_baseline() {
let (legacy, observed) = usage_snapshots_with_missing_observed_set();
let (selected, current_format) = select_admin_data_usage_snapshot(legacy, false, Some(observed));
let (normalized, _) = normalize_loaded_data_usage(selected, current_format).await;
assert!(normalized.is_valid_partial_snapshot());
assert!(!normalized.is_complete_bucket_usage_snapshot());
assert_eq!(normalized.usage_snapshot_converged, Some(false));
assert_eq!(normalized.objects_total_count, 3_000);
assert_eq!(normalized.objects_total_size, 3_000_000_000);
}
#[test]
fn partial_admin_observation_accepts_decreases_when_every_set_is_covered() {
let (authoritative, mut observed) = usage_snapshots_with_missing_observed_set();
// A compatible retained generation is useful even when it is not a
// completed scan of the current cycle. A tombstone explicitly removes
// a set; neither case is the same as an unavailable live set.
for tombstone in [false, true] {
observed.usage_snapshot_set_states[1].tombstone = tombstone;
observed.usage_snapshot_set_states[1].scanner_epoch = (!tombstone).then_some(4);
observed.usage_snapshot_set_states[1].scanner_cycle = (!tombstone).then_some(10);
let (selected, _) = select_admin_data_usage_snapshot(authoritative.clone(), true, Some(observed.clone()));
assert_eq!(selected.objects_total_count, 3_000);
assert_eq!(selected.objects_total_size, 3_000_000_000);
assert!(selected.is_valid_partial_snapshot());
assert!(!selected.is_complete_bucket_usage_snapshot());
assert_eq!(selected.usage_snapshot_converged, Some(false));
}
}
#[tokio::test]
#[serial]
async fn partial_admin_observation_preserves_persisted_totals_across_cache_reload() {
use crate::bucket::metadata_sys::test_support::isolated_store_over_temp_disks;
use crate::config::com::save_config;
let (_dirs, store) = isolated_store_over_temp_disks().await;
let (authoritative, observed) = usage_snapshots_with_missing_observed_set();
let primary_bytes = serde_json::to_vec(&authoritative).expect("encode complete usage");
let observed_bytes = serde_json::to_vec(&observed).expect("encode partial observation");
save_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str(), primary_bytes.clone())
.await
.expect("persist complete usage");
save_config(store.clone(), DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str(), observed_bytes.clone())
.await
.expect("persist observation with a missing set");
clear_usage_memory_cache_for_test().await;
invalidate_data_usage_snapshot_cache().await;
for reload in [false, false, true] {
if reload {
// Recreate process-local usage state, not an OS process restart.
clear_usage_memory_cache_for_test().await;
invalidate_data_usage_snapshot_cache().await;
}
let mut displayed = load_admin_data_usage_from_backend_cached(store.clone())
.await
.expect("load admin usage through the real snapshot cache");
apply_bucket_usage_memory_overlay(&mut displayed).await;
assert_eq!(displayed.objects_total_count, 510_000);
assert_eq!(displayed.objects_total_size, 580_000_000_000);
assert_eq!(displayed.bucket_sizes["large-bucket"], 580_000_000_000);
assert_eq!(displayed.snapshot_identity(), authoritative.snapshot_identity());
}
let quota = load_data_usage_from_backend_cached(store.clone())
.await
.expect("load quota authority");
assert_eq!(quota.objects_total_size, 580_000_000_000);
assert_eq!(
read_config(store.clone(), DATA_USAGE_OBJ_NAME_PATH.as_str())
.await
.expect("read primary"),
primary_bytes
);
assert_eq!(
read_config(store.clone(), DATA_USAGE_OBSERVED_OBJ_NAME_PATH.as_str())
.await
.expect("read diagnostic observation"),
observed_bytes,
"admin reads must not rewrite either persisted snapshot"
);
let mut reconciled = data_usage_info_for_test("large-bucket", 2, 42, SystemTime::UNIX_EPOCH + Duration::from_secs(12));
reconciled.scanner_epoch = Some(4);
reconciled.scanner_cycle = Some(12);
reconciled.usage_snapshot_converged = Some(true);
save_config(
store.clone(),
DATA_USAGE_OBJ_NAME_PATH.as_str(),
serde_json::to_vec(&reconciled).expect("encode confirmed deletion"),
)
.await
.expect("persist a later complete scan after deletion");
invalidate_data_usage_snapshot_cache().await;
let displayed = load_admin_data_usage_from_backend_cached(store)
.await
.expect("load confirmed decrease");
assert_eq!((displayed.objects_total_count, displayed.objects_total_size), (2, 42));
assert_eq!(displayed.snapshot_identity(), reconciled.snapshot_identity());
clear_usage_memory_cache_for_test().await;
invalidate_data_usage_snapshot_cache().await;
}
#[tokio::test]
async fn authoritative_save_cleanup_removes_observed_snapshot_best_effort() {
let store = UsageCasStore::default();
@@ -6522,6 +6796,117 @@ mod tests {
);
}
#[tokio::test]
#[serial]
async fn observed_admin_snapshot_preserves_unreconciled_mutations() {
let bucket = "observed-mutations";
for partial in [false, true] {
for delete in [false, true] {
for timestamp_offset in [0, 10] {
clear_usage_memory_cache_for_test().await;
let mut baseline = data_usage_info_for_test(bucket, 2, 20, SystemTime::now() - Duration::from_secs(10));
baseline.scanner_epoch = Some(4);
baseline.scanner_cycle = Some(10);
baseline.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&baseline).await;
let (expected_count, expected_size) = if delete {
record_bucket_object_delete_memory(bucket, 10, true).await;
(1, 10)
} else {
record_bucket_object_write_memory(bucket, None, 10).await;
(3, 30)
};
let mutation_time = memory_cache().read().await[bucket].usage_updated_at;
let mut observed = baseline.clone();
observed.last_update = Some(mutation_time + Duration::from_secs(timestamp_offset));
observed.scanner_cycle = Some(11);
observed.usage_snapshot_complete = !partial;
observed.usage_snapshot_partial = partial;
observed.usage_snapshot_converged = Some(false);
observed.usage_snapshot_authoritative_baseline = Some(baseline.snapshot_identity());
if partial {
observed.usage_snapshot_set_states = vec![rustfs_data_usage::DataUsageSnapshotSetState {
pool_index: 0,
set_index: 0,
scanner_cycle: Some(11),
scanner_epoch: Some(4),
scan_plan_digest: Some([1; 32]),
complete: false,
tombstone: false,
segment_invalidation_proof: None,
}];
assert!(observed.is_valid_partial_snapshot());
}
// Observational saves invalidate the admin cache without reconciling
// memory. Selecting that newer view must not erase local mutations.
let (selected, current_format) = select_admin_data_usage_snapshot(baseline, true, Some(observed));
assert_eq!(selected.scanner_cycle, Some(11));
let (selected, _) = normalize_loaded_data_usage(selected, current_format).await;
let mut distributed = selected.clone();
apply_bucket_usage_memory_overlay_if_authoritative(&mut distributed, false).await;
assert_eq!((distributed.objects_total_count, distributed.objects_total_size), (2, 20));
let mut response = selected;
apply_bucket_usage_memory_overlay_if_authoritative(&mut response, true).await;
assert_eq!(
(response.objects_total_count, response.objects_total_size),
(expected_count, expected_size),
"partial={partial}, delete={delete}, timestamp_offset={timestamp_offset}"
);
assert_eq!(response.versions_total_count, expected_count);
assert_eq!(response.bucket_sizes[bucket], expected_size);
assert_eq!(response.buckets_usage[bucket].objects_count, expected_count);
assert_eq!(response.usage_snapshot_converged, Some(false));
assert_eq!(response.usage_snapshot_partial, partial);
let cache = memory_cache().read().await;
assert!(cache[bucket].dirty, "an admin read cannot acknowledge mutations");
assert!(!cache[bucket].stale_snapshot_pending);
assert_eq!(cache[bucket].pending_scanner_position, None);
drop(cache);
// A later converged scan can legitimately correct the count downward;
// the display fix must not retain dirty counts or a historical maximum.
let mut converged = data_usage_info_for_test(bucket, 0, 0, mutation_time + Duration::from_secs(20));
converged.scanner_epoch = Some(4);
converged.scanner_cycle = Some(12);
converged.usage_snapshot_converged = Some(true);
replace_bucket_usage_memory_from_info(&converged).await;
apply_bucket_usage_memory_overlay_if_authoritative(&mut converged, true).await;
assert_eq!((converged.objects_total_count, converged.objects_total_size), (0, 0));
assert!(!memory_cache().read().await[bucket].dirty);
}
}
}
}
#[tokio::test]
#[serial]
async fn observed_admin_snapshot_does_not_overlay_clean_or_unknown_usage() {
let bucket = "observed-baseline";
for unknown in [false, true] {
clear_usage_memory_cache_for_test().await;
let baseline = data_usage_info_for_test(bucket, 2, 20, SystemTime::now() - Duration::from_secs(10));
replace_bucket_usage_memory_from_info(&baseline).await;
if unknown {
memory_cache().write().await.insert(
bucket.to_string(),
cached_bucket_usage_from_backend(BucketUsageInfo::default(), SystemTime::UNIX_EPOCH, false),
);
record_bucket_object_write_memory(bucket, None, 10).await;
}
let mutation_time = memory_cache().read().await[bucket].usage_updated_at;
let mut observed = data_usage_info_for_test(bucket, 100, 1_000, mutation_time + Duration::from_secs(10));
observed.usage_snapshot_converged = Some(false);
observed.usage_snapshot_authoritative_baseline = Some(baseline.snapshot_identity());
let (mut response, _) = select_admin_data_usage_snapshot(baseline, true, Some(observed));
apply_bucket_usage_memory_overlay_if_authoritative(&mut response, true).await;
assert_eq!((response.objects_total_count, response.objects_total_size), (100, 1_000));
assert_eq!(response.usage_snapshot_complete, !unknown);
assert_eq!(response.usage_snapshot_converged, Some(false));
}
}
#[tokio::test]
#[serial]
async fn scanner_sync_preserves_dirty_memory_update_with_later_partial_snapshot() {
@@ -39,14 +39,14 @@ use rustfs_protos::{
};
use std::{
collections::{HashMap, HashSet},
time::Duration,
time::{Duration, Instant},
};
use time::OffsetDateTime;
use tokio::time::timeout;
use tonic::Request;
use tracing::{error, trace, warn};
const EVENT_SERVER_PING: &str = "server_ping";
const EVENT_SERVER_INFO: &str = "server_info";
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
const LOG_SUBSYSTEM_DIAGNOSTICS: &str = "diagnostics";
@@ -276,16 +276,10 @@ fn collect_runtime_server_stats() -> (MemStats, u64, u64) {
}
pub async fn get_server_info(get_pools: bool) -> InfoMessage {
let nowt: OffsetDateTime = OffsetDateTime::now_utc();
warn!("get_server_info start {:?}", nowt);
let started = tracing::enabled!(tracing::Level::TRACE).then(Instant::now);
let local = get_local_server_property().await;
let after1 = OffsetDateTime::now_utc();
warn!("get_local_server_property end {:?}", after1 - nowt);
let mut servers = {
if let Some(sys) = runtime_sources::notification_sys() {
sys.server_info().await
@@ -294,9 +288,6 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
}
};
let after2 = OffsetDateTime::now_utc();
warn!("server_info end {:?}", after2 - after1);
servers.push(local);
let mut buckets = rustfs_madmin::Buckets::default();
@@ -331,15 +322,8 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
);
}
let after3 = OffsetDateTime::now_utc();
warn!("load_data_usage_from_backend end {:?}", after3 - after2);
let backend_info = StorageAdminApi::backend_info(store.as_ref()).await;
let after4 = OffsetDateTime::now_utc();
warn!("backend_info end {:?}", after4 - after3);
if let Some(endpoints) = runtime_sources::endpoint_pools() {
let (added, report) = reconcile_servers_with_endpoint_topology(&mut servers, &endpoints);
if added > 0 || !report.is_complete() {
@@ -361,9 +345,6 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
}
let (online_disks, offline_disks, unknown_disks) = get_online_offline_disks_stats(&all_disks);
let after5 = OffsetDateTime::now_utc();
warn!("get_online_offline_disks_stats end {:?}", after5 - after4);
backend = rustfs_madmin::ErasureBackend {
backend_type: rustfs_madmin::BackendType::ErasureType,
online_disks: online_disks.sum(),
@@ -376,14 +357,24 @@ pub async fn get_server_info(get_pools: bool) -> InfoMessage {
};
if get_pools {
pools = get_pools_info(&all_disks).await.unwrap_or_default();
let after6 = OffsetDateTime::now_utc();
warn!("get_pools_info end {:?}", after6 - after5);
}
}
let services = rustfs_madmin::Services::default();
if let Some(started) = started {
trace!(
event = EVENT_SERVER_INFO,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_DIAGNOSTICS,
state = "completed",
mode,
include_pools = get_pools,
duration_ms = started.elapsed().as_secs_f64() * 1000.0,
"Server info collected"
);
}
InfoMessage {
mode: Some(mode.to_string()),
domain: None,
@@ -695,12 +686,16 @@ async fn get_pools_info(all_disks: &[Disk]) -> Result<HashMap<i32, HashMap<i32,
if erasure_set.id == 0 {
erasure_set.id = d.set_index;
match load_data_usage_cache(
let load = load_data_usage_cache(
store.pools[d.pool_index as usize].disk_set[d.set_index as usize].as_ref(),
DATA_USAGE_CACHE_NAME,
)
.await
{
);
#[cfg(feature = "diagnostic-read-version-caller")]
let load = crate::cluster::rpc::runtime_sources::with_read_version_entry(
rustfs_io_metrics::internode_metrics::ReadVersionEntrySource::ServerInfoPools,
load,
);
match load.await {
Ok(cache) => apply_erasure_set_usage(&cache, erasure_set),
Err(_) => erasure_set.usage_error = Some(DATA_USAGE_UNAVAILABLE_ERROR.to_string()),
}
+185 -20
View File
@@ -19,8 +19,8 @@ use crate::disk::io_admission::{
};
use crate::disk::{
CheckPartsResp, DataDirDeleteStatus, DeleteOptions, DiskAPI, DiskError, DiskInfo, DiskInfoOptions, DiskLocation, Endpoint,
Error, FileInfoVersions, MmapCopyStageMetrics, ReadMultipleReq, ReadMultipleResp, ReadOptions, RenameDataResp, Result,
SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
Error, FileInfoVersions, GuardedFileInfoVersions, MmapCopyStageMetrics, ReadMultipleReq, ReadMultipleResp, ReadOptions,
RenameDataResp, Result, SnapshotLeaseToken, UpdateMetadataOpts, VolumeInfo, WalkDirOptions,
health_state::{
RuntimeDriveHealthState, classify_drive_recovery, get_drive_returning_probe_interval,
get_drive_returning_success_threshold, get_drive_suspect_failure_threshold, record_drive_offline_duration,
@@ -1829,21 +1829,7 @@ impl LocalDiskWrapper {
}
// Clean up
disk.delete(
test_bucket,
test_filename,
DeleteOptions {
recursive: false,
immediate: false,
undo_write: false,
undo_delete: false,
old_data_dir: None,
expected_delete_marker: None,
recoverable_delete: false,
delete_recovery_bucket_incarnation: None,
},
)
.await?;
disk.delete(test_bucket, test_filename, DeleteOptions::default()).await?;
Ok(())
})
@@ -2078,6 +2064,58 @@ impl LocalDiskWrapper {
result
}
pub(crate) async fn delete_versions_guarded_with_namespace_owner(
&self,
volume: &str,
versions: Vec<GuardedFileInfoVersions>,
opts: DeleteOptions,
namespace_owner: Option<Arc<dyn Send + Sync>>,
) -> Vec<Option<Error>> {
let version_count = versions.len();
self.metrics.record_operation_call("delete_versions");
// Check if disk is faulty before proceeding
if self.health.is_faulty() {
self.metrics.record_availability_error();
return vec![Some(DiskError::FaultyDisk); versions.len()];
}
// Check if disk is stale
if let Err(e) = self.check_disk_stale().await {
self.metrics.record_availability_error();
return vec![Some(e); versions.len()];
}
let _io_permit = match self.acquire_disk_io_permit().await {
Ok(permit) => permit,
Err(error) => return vec![Some(error); version_count],
};
// Record operation start
self.health.last_started.store(current_unix_nanos(), Ordering::Relaxed);
let waiting_guard = self.health.waiting_guard();
let metric_waiting_guard = self.metrics.waiting_guard();
let started = Instant::now();
// Execute the operation
let result = self
.disk
.delete_versions_guarded_with_namespace_owner(volume, versions, opts, namespace_owner)
.await;
self.metrics.record_operation_latency("delete_versions", started.elapsed());
self.record_batch_delete_error_metrics(&result);
drop(waiting_guard);
drop(metric_waiting_guard);
let has_err = result.iter().any(|e| e.is_some());
if !has_err {
// Log success and decrement waiting counter
self.health.record_operation_success(&self.endpoint(), "operation_success");
self.metrics.record_mutation_success(DiskMetricMutation::Delete);
}
result
}
pub async fn get_current_disk_id(&self) -> Option<Uuid> {
*self.disk_id.read().await
}
@@ -2321,6 +2359,21 @@ impl LocalDiskWrapper {
#[async_trait::async_trait]
impl DiskAPI for LocalDiskWrapper {
async fn supports_guarded_delete_versions(&self) -> Result<()> {
// The local implementation shares this binary's capability. Availability
// and disk identity remain mutation checks and write-quorum inputs.
self.disk.supports_guarded_delete_versions().await
}
async fn delete_versions_guarded_recoverable(
&self,
volume: &str,
versions: Vec<GuardedFileInfoVersions>,
opts: DeleteOptions,
) -> Vec<Option<DiskError>> {
self.delete_versions_guarded_with_namespace_owner(volume, versions, opts, None)
.await
}
async fn delete_version_recoverable(
&self,
volume: &str,
@@ -2351,6 +2404,14 @@ impl DiskAPI for LocalDiskWrapper {
opts.recoverable_delete = true;
self.delete_versions(volume, versions, opts).await
}
async fn has_delete_transaction_evidence(&self, volume: &str, object: &str) -> Result<bool> {
self.track_disk_health_with_op(
"has_delete_transaction_evidence",
|| async { self.disk.has_delete_transaction_evidence(volume, object).await },
get_max_timeout_duration(),
)
.await
}
async fn discover_delete_transactions(
&self,
volume: &str,
@@ -2743,6 +2804,7 @@ impl DiskAPI for LocalDiskWrapper {
.await
}
#[track_caller]
async fn read_version(
&self,
org_volume: &str,
@@ -2751,7 +2813,7 @@ impl DiskAPI for LocalDiskWrapper {
version_id: &str,
opts: &ReadOptions,
) -> Result<FileInfo> {
self.track_disk_health_with_op_timeout_action_and_mutation_with_io_permit(
let operation = self.track_disk_health_with_op_timeout_action_and_mutation_with_io_permit(
"read_version",
|io_permit| async move {
with_disk_io_permit(io_permit, self.disk.read_version(org_volume, volume, path, version_id, opts)).await
@@ -2759,8 +2821,20 @@ impl DiskAPI for LocalDiskWrapper {
get_max_timeout_duration(),
TimeoutHealthAction::MarkFailure,
DiskMetricMutation::None,
)
.await
);
#[cfg(feature = "diagnostic-read-version-caller")]
{
if crate::cluster::rpc::runtime_sources::current_read_version_request_caller().is_some() {
operation.await
} else {
let caller = crate::cluster::rpc::runtime_sources::read_version_request_caller_from_location(
std::panic::Location::caller().file(),
);
crate::cluster::rpc::runtime_sources::with_read_version_request_caller(caller, operation).await
}
}
#[cfg(not(feature = "diagnostic-read-version-caller"))]
operation.await
}
async fn read_xl(&self, volume: &str, path: &str, read_data: bool) -> Result<RawFileInfo> {
@@ -3921,6 +3995,97 @@ mod tests {
assert_eq!(snapshot.total_errors_availability, 1);
}
#[tokio::test]
async fn local_guarded_delete_capability_is_independent_of_faulty_or_stale_mutation_rejection() {
for stale in [false, true] {
let dir = tempfile::tempdir().expect("create local disk directory");
let endpoint = Endpoint::try_from(dir.path().to_str().expect("UTF-8 disk path")).expect("parse local endpoint");
let disk = Arc::new(LocalDisk::new(&endpoint, false).await.expect("create local disk"));
let volume = "guarded-capability";
let object = "object";
disk.make_volume(volume).await.expect("create object volume");
let mut info = FileInfo::new(object, 1, 0);
info.name = object.to_owned();
info.version_id = Some(Uuid::nil());
info.data_dir = Some(Uuid::new_v4());
info.mod_time = Some(::time::OffsetDateTime::now_utc());
info.erasure.index = 1;
info.size = 8;
info.parts = vec![ObjectPartInfo {
number: 1,
size: 8,
actual_size: 8,
..Default::default()
}];
info.data = Some(Bytes::from_static(b"original"));
info.metadata.insert("etag".into(), "etag-a".into());
info.set_inline_data();
disk.write_metadata(volume, volume, object, info.clone())
.await
.expect("publish guarded fixture metadata");
let generation = crate::disk::CurrentDeleteGeneration::from_file_info(&info).expect("capture object generation");
let wrapper = LocalDiskWrapper::new(Arc::clone(&disk), false);
let expected_error = if stale {
let mut format_info = disk.format_info.write().await;
format_info.id = Some(Uuid::new_v4());
format_info.file_info = Some(tokio::fs::metadata(dir.path()).await.expect("local disk directory metadata"));
format_info.last_check = Some(::time::OffsetDateTime::now_utc());
drop(format_info);
wrapper.set_disk_id_state(Some(Uuid::new_v4())).await;
DiskError::DiskNotFound
} else {
wrapper.force_offline_for_test();
DiskError::FaultyDisk
};
let object_dir = dir.path().join(volume).join(object);
let metadata_path = object_dir.join(crate::disk::STORAGE_FORMAT_FILE);
let before = tokio::fs::read(&metadata_path).await.expect("read original metadata");
wrapper
.supports_guarded_delete_versions()
.await
.expect("local binary capability is known even when the drive is unavailable");
assert_eq!(
wrapper.metrics_snapshot().total_errors_availability,
0,
"capability does not probe drive availability"
);
let transaction = Uuid::new_v4();
let result = wrapper
.delete_versions_guarded_recoverable(
volume,
vec![GuardedFileInfoVersions {
group: FileInfoVersions {
name: object.to_owned(),
versions: vec![FileInfo {
name: object.to_owned(),
version_id: info.version_id,
..Default::default()
}],
..Default::default()
},
expected_current: generation,
}],
DeleteOptions {
old_data_dir: Some(transaction),
..Default::default()
},
)
.await;
assert_eq!(result, vec![Some(expected_error)], "mutation availability checks remain enforced");
assert_eq!(
tokio::fs::read(&metadata_path)
.await
.expect("metadata survives rejected mutation"),
before
);
assert!(
!object_dir.join(transaction.to_string()).exists(),
"rejected mutation creates no recovery record"
);
assert_eq!(wrapper.metrics_snapshot().total_errors_availability, 1);
}
}
#[tokio::test]
async fn local_disk_health_wrapper_counts_faulty_precheck_rejections() {
let dir = tempfile::tempdir().expect("temp dir should be created");
+27
View File
@@ -167,6 +167,12 @@ pub enum DiskError {
#[error("method not allowed")]
MethodNotAllowed,
#[error("conditional delete object generation changed")]
ConditionalDeleteConflict,
#[error("guarded batch deletion is not supported by this disk")]
GuardedDeleteUnsupported,
#[error("erasure write quorum")]
ErasureWriteQuorum,
@@ -762,6 +768,8 @@ impl Clone for DiskError {
DiskError::PartMissingOrCorrupt => DiskError::PartMissingOrCorrupt,
DiskError::NoHealRequired => DiskError::NoHealRequired,
DiskError::MethodNotAllowed => DiskError::MethodNotAllowed,
DiskError::ConditionalDeleteConflict => DiskError::ConditionalDeleteConflict,
DiskError::GuardedDeleteUnsupported => DiskError::GuardedDeleteUnsupported,
DiskError::ErasureWriteQuorum => DiskError::ErasureWriteQuorum,
DiskError::ErasureReadQuorum => DiskError::ErasureReadQuorum,
DiskError::ShortWrite => DiskError::ShortWrite,
@@ -819,6 +827,8 @@ impl DiskError {
DiskError::Timeout => 0x29,
DiskError::InvalidPath => 0x2A,
DiskError::RemoteClientUnavailable(_) => 0x2B,
DiskError::ConditionalDeleteConflict => 0x2C,
DiskError::GuardedDeleteUnsupported => 0x2D,
}
}
@@ -867,6 +877,8 @@ impl DiskError {
0x29 => Some(DiskError::Timeout),
0x2A => Some(DiskError::InvalidPath),
0x2B => Some(DiskError::RemoteClientUnavailable(String::new())),
0x2C => Some(DiskError::ConditionalDeleteConflict),
0x2D => Some(DiskError::GuardedDeleteUnsupported),
_ => None,
}
}
@@ -1645,6 +1657,21 @@ mod tests {
assert!(rendered.contains("transport error: connection refused"));
}
#[test]
fn guarded_delete_errors_preserve_wire_and_io_types() {
for (error, code) in [
(DiskError::ConditionalDeleteConflict, 0x2C),
(DiskError::GuardedDeleteUnsupported, 0x2D),
] {
assert_eq!(error.to_u32(), code);
assert_eq!(DiskError::from_u32(code), Some(error.clone()));
let wire: rustfs_protos::proto_gen::node_service::Error = error.clone().into();
assert_eq!(DiskError::from(wire), error);
let io_error: std::io::Error = error.clone().into();
assert_eq!(DiskError::from(io_error), error);
}
}
#[test]
fn remote_client_unavailable_wire_roundtrip_keeps_variant_and_detail() {
let original = DiskError::RemoteClientUnavailable("dial tcp: connection refused".to_string());
@@ -0,0 +1,72 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Bucket-scoped observations distinguish exact metadata reads from walks.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
#[derive(Default, Clone)]
pub(crate) struct Counts {
pub walks: usize,
pub reads: Vec<String>,
}
fn observers() -> &'static Mutex<HashMap<String, Arc<Mutex<Counts>>>> {
static OBSERVERS: OnceLock<Mutex<HashMap<String, Arc<Mutex<Counts>>>>> = OnceLock::new();
OBSERVERS.get_or_init(Mutex::default)
}
pub(crate) struct Observer {
bucket: String,
counts: Arc<Mutex<Counts>>,
}
impl Observer {
pub fn new(bucket: &str) -> Self {
let counts = Arc::new(Mutex::new(Counts::default()));
assert!(
observers()
.lock()
.expect("I/O observers lock")
.insert(bucket.to_owned(), counts.clone())
.is_none(),
"each test must use its own bucket"
);
Self {
bucket: bucket.to_owned(),
counts,
}
}
pub fn snapshot(&self) -> Counts {
self.counts.lock().expect("I/O counts lock").clone()
}
}
impl Drop for Observer {
fn drop(&mut self) {
observers().lock().expect("I/O observers lock").remove(&self.bucket);
}
}
pub(crate) fn observe(bucket: &str, object: Option<&str>) {
let observer = observers().lock().expect("I/O observers lock").get(bucket).cloned();
if let Some(observer) = observer {
let mut counts = observer.lock().expect("I/O counts lock");
match object {
Some(object) => counts.reads.push(object.to_owned()),
None => counts.walks += 1,
}
}
}
File diff suppressed because it is too large Load Diff
+25 -15
View File
@@ -24,7 +24,7 @@ use super::{
LOG_SUBSYSTEM_DISK_LOCAL, LocalDisk, SyncMode, effective_durability, inline_metadata_rollback_dir, observe_old_current_size,
remove_dir_all_if_exists, remove_dst_base_before_commit, remove_file_if_exists, rename_data_versions_signature,
run_inline_preparation_before_backup, should_fail_after_metadata_commit, should_fail_before_old_metadata_backup,
should_fail_commit_rename, should_remove_staged_meta_before_commit, skip_access_checks,
should_fail_commit_rename, should_remove_staged_meta_before_commit, skip_access_checks, tmp_staging_mutation_path,
};
#[cfg(test)]
use super::{run_inline_before_file_sync_admission, run_owned_file_write_before_open, run_rename_data_after_first_publication};
@@ -354,12 +354,20 @@ impl LocalDisk {
None => None,
};
// Quota admission -> metadata RMW -> namespace/volume publication.
let no_inline = fi.data.is_none() && fi.size > 0;
let fi_healing = fi.is_healing();
let staging_prefix = if fi_healing && !no_inline && src_volume == super::super::RUSTFS_META_TMP_BUCKET {
Some(tmp_staging_mutation_path(&self.root, src_path)?)
} else {
None
};
let metadata_lease =
os::acquire_metadata_mutation_lease(&self.get_object_path(dst_volume, dst_path)?, state.namespace_owner.take()).await;
let mutation_lease = os::acquire_rename_data_mutation_lease_with_owner(
&self.root,
dst_volume,
&destination_object_path,
staging_prefix.as_deref(),
Some(metadata_lease),
)
.await;
@@ -457,11 +465,6 @@ impl LocalDisk {
check_path_length(src_file_path.to_string_lossy().to_string().as_str())?;
check_path_length(dst_file_path.to_string_lossy().to_string().as_str())?;
let no_inline = fi.data.is_none() && fi.size > 0;
// Captured before `fi` is consumed by add_version; gates the stale
// destination purge below.
let fi_healing = fi.is_healing();
// Resolved once for the whole commit so a concurrent configuration
// change can never leave a single rename_data half-synced. The tier is
// keyed on the destination volume: user data staged in scratch
@@ -1006,14 +1009,21 @@ impl LocalDisk {
version_id,
);
let old_data_dir = xlmeta.find_unshared_data_dir_for_version(Some(version_id));
let old_version_exists = xlmeta.find_version(Some(version_id)).is_ok();
let rollback_data_dir = old_data_dir.or_else(|| {
if old_version_exists && has_dst_buf.is_some() {
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
} else {
None
}
});
// Healing commits never run set-level post-return rollback; keep
// their intra-commit rollback backup in the staging directory so
// `<object>/<rollback_data_dir>/xl.meta.bkp` is never stranded.
let rollback_data_dir = if fi_healing {
None
} else {
let old_version_exists = xlmeta.find_version(Some(version_id)).is_ok();
old_data_dir.or_else(|| {
if old_version_exists && has_dst_buf.is_some() {
Some(inline_metadata_rollback_dir(version_id, &xlmeta))
} else {
None
}
})
};
let mut staged_rollback_path = None;
if let Some(d) = old_data_dir.as_ref() {
let _ = xlmeta.data.remove_two(version_id, *d);
@@ -1029,7 +1039,7 @@ impl LocalDisk {
prepared_metadata_source.write_all(&new_buf, sync)?;
run_inline_preparation_before_backup(&dst_path_for_failpoint);
if let Some(ref old_metadata) = has_dst_buf
&& (rollback_data_dir.is_some() || sync || cfg!(test))
&& (rollback_data_dir.is_some() || fi_healing || sync || cfg!(test))
{
#[cfg(windows)]
let backup_path = {

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