Compare commits

..

99 Commits

Author SHA1 Message Date
xiaomage 57f7678bc1 ci(upgrade): support manual runs between any two release versions
The workflow_dispatch inputs already accept arbitrary release tags, but
the run failed late and unclearly when a tag had no .deb asset, and the
from_version default pointed at 1.0.0-rc.4-preview.1, whose release
ships no .deb at all - so scheduled runs died on a 404 while installing
the old package.

- Add a fail-fast preflight that resolves each requested tag via the
  GitHub release API and verifies the rustfs_<tag>_amd64.deb asset
  exists before the suite starts, with an actionable error message
  otherwise (e.g. 1.0.0-rc.4 ships only zip/sbom assets).
- Change the from_version default to 1.0.0-rc.3, the newest release
  that actually ships a .deb asset.
- Reword the from_version/to_version descriptions so manual triggers
  state the .deb-asset requirement and the nightly fallback.
- Pass PF_TESTING_GH_TOKEN as GH_TOKEN to the suite step for the gh api
  release lookups, matching the other functional workflows.
2026-09-04 21:43:06 +08:00
cxymds 65ed86f76e test(ecstore): run checkpoint publication test on large stack (#7134) 2026-09-04 12:39:18 +00:00
cxymds 3da3bcd9ee fix(tier): preserve credentials during backend validation (#7127)
* fix(tier): preserve credentials during backend validation

* test(tier): make clone redaction assertion clippy-clean
2026-09-04 12:24:02 +00:00
Zhengchao An 20e4fd7de6 feat(ecstore): add the sealed remote credential seam (#7137)
* feat(ecstore): add the sealed remote credential seam

Replication targets, remote tiers and on-demand migration sources will all
seal their stored secrets through one envelope rather than three
(rustfs/backlog#2168, design in docs/architecture/remote-credential-sealing-adr.md).

Adds the versioned envelope, the seal scope that binds a ciphertext to the
store, owner and field it belongs to, the sealer registration point, and the
fail-closed error type. ECStore still has no rustfs-kms dependency: the binary
installs a sealer the way it installs the event dispatch hook.

Nothing is wired to a consumer yet, so no stored format changes.

* docs(ecstore): name the event dispatch hook by module, not by symbol

The architecture guard keeps EVENT_DISPATCH_HOOK references inside the
event-notification owner module; the module doc cited the symbol only as an
example of the hook shape, so cite its file instead.
2026-09-04 12:11:49 +00:00
houseme da28f8c843 chore(deps): refresh hotpath, mimalloc, and kafka (#7135)
* chore(deps): refresh hotpath and mimalloc

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

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

* chore(deps): bump rustfs kafka crates

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

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

---------

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-04 12:10:16 +00:00
Zhengchao An 507447da12 fix(restore): reject SELECT restore and keep typed S3 errors (#7113)
* fix(restore): reject SELECT restore and keep typed S3 errors

RestoreObject accepted `Type=SELECT` requests, but the restore path can
only write the retrieved bytes back to the source key: `put_restore_opts`
built SELECT output options and `restore_transitioned_object` then PUT
them over the source bucket/object. On an unversioned bucket that dropped
`x-amz-restore`, user metadata and tags from the live object; on a
versioned bucket it published a bogus latest version. Nothing was ever
written to `OutputLocation.S3`, yet the response still carried a
fabricated `x-amz-restore-output-path`.

Reject SELECT at the API boundary with a typed NotImplemented, before any
guard or metadata write, and fail closed in `put_restore_opts` as the
backstop for any other caller.

Every other RestoreObject failure was collapsed into a `Custom` error
code, which serializes as a generic retryable 500: a missing key or
version, a malformed version-id, an object that was never transitioned,
an illegal `Days`, and authorization or storage failures all looked the
same to a client. Map them to their S3 identities instead — NoSuchKey,
NoSuchVersion, InvalidArgument, InvalidObjectState, InvalidRequest,
MalformedXML — by preserving `StorageError` through `post_restore_opts`
and letting `ApiError` do the mapping. The intentional 409
RestoreAlreadyInProgress and 503 SlowDown behaviour is unchanged, and
request validation now runs before any lock is taken.

backlog#1341, backlog#2205

* test(restore): give the typed-error regression the ecstore test stack

`execute_restore_object_maps_failures_to_typed_s3_errors` builds a real
ECStore fixture, and under nextest each test runs in a spawned thread with
libtest's 2 MiB stack. On Linux CI that overflowed: the test aborted with
SIGABRT / "fatal runtime error: stack overflow" while every other test in
the run passed.

Add it to the `ecstore-base-stack` filter in both the default and ci
profiles, alongside the other `package(rustfs)` tests that drive the same
store fixture. 4 MiB matches what the deeper multipart and access
roundtrips already use.
2026-09-04 12:04:28 +00:00
cxymds 81014fd233 docs(ilm): approve legacy tier state reconciliation (#7142) 2026-09-04 19:53:36 +08:00
cxymds ff3c5a4989 fix(ilm): drain tier-delete recovery pages (#7133) 2026-09-04 10:46:19 +00:00
cxymds 4d226998e2 fix(ilm): chunk large tier-delete dispatches (#7123)
* fix(ilm): chunk large tier-delete dispatches

* fix(ilm): bound tier-delete chunk dispatch stack use
2026-09-04 10:30:32 +00:00
Zhengchao An f6bed1a73a fix(ci): track nightly test selection per platform (#7128) 2026-09-04 18:25:34 +08:00
Zhengchao An 7ff578ff20 fix(ilm): notify on batch noncurrent version expiry (#7116)
The batch `NewerNoncurrentVersions` expiry path took a lifecycle event
argument and ignored it: after `delete_objects` committed it only evicted
the cache and scheduled replication deletes, so a successful noncurrent
version expiry was invisible to notification subscribers while the
equivalent current-version path emitted a lifecycle expiration event.

Emit that event from the batch path too, reusing the existing lifecycle
audit sink and event contract. Only entries that actually mutated
something are announced, and cache eviction and replication scheduling
keep their existing order and admission — the event is derived from the
committed result and a send failure never rolls back a delete.

"No error" is not enough to prove a mutation: the disk layer skips an
absent version and reports success, so a batch entry for a version that
was already gone came back indistinguishable from a committed delete.
The delete plan already resolves whether the source exists, so carry that
`source_missing` result on `DeletedObject` and let the lifecycle path
stay silent for versions it did not remove.

backlog#2202
2026-09-04 18:25:10 +08:00
Henry Guo f16a30b231 feat(scanner): expose authenticated dirty bucket snapshots (#7122)
* feat(scanner): add peer bucket dirty snapshots

* fix(scanner): keep dirty snapshot errors stable

* test(protos): satisfy dirty snapshot clippy

* fix(scanner): satisfy dirty snapshot clippy

---------

Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-09-04 18:23:13 +08:00
Zhengchao An 6f717b2752 fix(filemeta): accept MinIO RFC1123 restore expiry-date (#7115) 2026-09-04 09:02:03 +00:00
cxymds 3654c147e2 fix(ilm): defer aborted dispatch cleanup to recovery (#7126) 2026-09-04 08:49:14 +00:00
Zhengchao An a9373288f2 docs(architecture): decide how remote credentials are sealed at rest (#7131)
Replication targets, remote tiers and on-demand migration sources each store a
remote secret in clear text under .rustfs.sys today. Record one design for all
three before any of them invents its own: seal only the secret fields, into an
added field so an old reader finds the credential absent rather than a
ciphertext it would sign with, unseal at remote-client construction, and reach
KMS through an installed hook because ECStore does not depend on rustfs-kms.

Covers the envelope format and its encryption context, the mixed-version
compatibility matrix, the rollout gate, rotation ownership and the fail-closed
rules.
2026-09-04 16:30:17 +08:00
cxymds fdb28e611f fix: bound tier free-version recovery sweeps (#7125) 2026-09-04 08:12:01 +00:00
Zhengchao An 5f739944a3 fix: resolve clippy warnings in bucket_list_through.rs (#7121) 2026-09-04 14:14:26 +08:00
Zhengchao An 2bc88418f5 fix(list): satisfy clippy in the source listing merge (#7117) 2026-09-04 12:06:34 +08:00
Zhengchao An 9d10d69a6d test(odm): add the scheduled provider interop lane (#7114)
* test(odm): drive the migration cases from an env-named source

The ODM e2e suite only ever migrates from the in-process fake source, so
path-style addressing, region handling, ETag shape and list pagination on
real implementations stay untested. OdmInteropEnv resolves the source from
RUSTFS_ODM_INTEROP_*, seeding into a per-run source_prefix so a shared real
bucket can host concurrent runs and every seeded key is removed afterwards.
A named provider with a missing variable is an error, never a silent
fallback to the fake source.

interop_test holds the four cases that run against either source, and the
e2e-odm-interop profile is the lane that selects them; e2e-full excludes
them, so its committed selection is unchanged. wait_until_odm_engaged
replaces the fake source's journal probe for the readiness wait, since a
real source keeps no journal.

* ci(odm): add the scheduled provider interop lane

on-demand-migration-interop.yml runs the interop cases against a pinned
MinIO container with a 5,000-object backfill - past the fake source's 4,096
version and journal caps - and the three-case minimum against AWS, R2 and
GCS when their ODM_INTEROP_* secrets exist, skipping with a summary note
when they do not. Each provider gets one JSON report merging the per-case
entries with the nextest JUnit, which stays authoritative for what ran.

Report-only and never required: it depends on third-party endpoints and on
secrets a fork does not have.
2026-09-04 10:05:40 +08:00
Zhengchao An 9863f4848d feat(tier): report cluster tier stats and count tier requests (#7110)
`GET /v3/tier-stats` answered from whichever process received the
request, returning that node's rolling 24-hour transition counters as
if they were cluster totals, and the `TierRequestsSuccess` and
`TierRequestsFailure` metric names had no producer at all.

The body now separates the two quantities a tier carries. Stored
inventory comes from the persisted scanner usage snapshot, which is
already cluster-wide; rolling activity is summed over every member
through a new read-only `TierDailyStats` peer RPC. Rings are merged
rather than added, so an idle node's expired hours age out, and each
node counts only its own committed transitions, so a retry is counted
once. Coverage travels with the numbers: `activity.status` names the
reporting members and the ones that could not be asked, timed out, or
answered with a ring this build refuses to merge, and per-tier
inventory is absent rather than zero when the snapshot has no
accounting. The version 1 body stays reachable at `?format=legacy`.

Tier request counters are recorded at the two seams every remote
request passes through, so a new provider is counted by construction,
with a closed operation/outcome label set that can never grow a tier
name, endpoint or object key.

Closes rustfs/backlog#2207

Co-authored-by: cxymds <cxymds@gmail.com>
2026-09-04 09:40:03 +08:00
Zhengchao An 7dfc2ee5f0 feat(odm): merge the source listing into ListObjectsV2 (#7112)
* feat(odm): merge the source listing into ListObjectsV2

Adds policy.list_through: ListObjectsV2 merges the local and source
listings into one ordered page so clients see the whole namespace during
an on-demand migration. Local entries win a key both sides hold,
CommonPrefixes are unioned under a delimiter, and the continuation token
is an opaque versioned envelope carrying both cursors.

A source listing failure or an open breaker follows policy.source_error:
propagate answers 424, not_found answers from local state and marks the
response x-rustfs-on-demand-migration-list: local_only. Source listings
are capped at 10 per second per bucket.

* test(odm): refresh the e2e-full darwin selection digest

The list-through e2e module adds seven cases to the merge lane.
2026-09-04 09:08:35 +08:00
cxymds 80c88a9031 fix(ilm): delete historical null versions by exact identity (#7109) 2026-09-04 08:14:47 +08:00
Zhengchao An 3005efe845 fix(odm): declare source retry policy and time out a stalled inline read (#7111)
* fix(odm): declare the remote client retry policy per consumer

The SDK retry policy was an inherited default: one logical call could cost
three wire requests, so the migration breaker counted logical calls on top
of a threefold amplification against a source that was already failing.

Make it an explicit RemoteS3EndpointSpec field. Replication targets declare
today's standard three attempts and keep their behaviour; the on-demand
migration source and its admin probe declare a disabled policy, so one
counted failure is exactly one source request and pull.rs owns the only
retry budget.

* fix(odm): count a stalled inline source as a source timeout

The inline tee wraps its source body in the idle guard, but the tee turns a
stalled source into an ordinary body read error, so the write-back reported
it as a local write failure. Hand commit_inline the guard so the pull is
counted under source_timeout instead.

The background pump now enforces the idle budget through the same guard
rather than a second copy of the timeout loop.

* test(odm): cover a stalled source body end to end

The fake target can now deliver a GetObject body in slices with a pause
between them, so the inline abort can be driven by a stalled source instead
of a truncated one. Two fault cases drop the workarounds they carried for
the SDK's retries: the scripted fault count and the observed source request
count now have to agree.

The operations guide records the retry and idle-timeout guarantees.
2026-09-04 02:24:53 +08:00
Henry Guo 3a914b429d fix(scanner): preserve explicit cycle retry cadence (#7107)
Co-authored-by: houseme <housemecn@gmail.com>
2026-09-03 22:30:46 +08:00
Henry Guo 8a8616b8f7 test(heal): cover background target restart (#7103)
Co-authored-by: houseme <housemecn@gmail.com>
2026-09-03 12:36:30 +00:00
houseme 62e66baf89 feat(scanner): add storage seam (#7105) 2026-09-03 20:31:59 +08:00
唐小鸭 53cabe9274 fix(replication): send an integrity header on Object Lock PUTs (#7097)
* fix(replication): send an integrity header on Object Lock replication PUTs

AWS S3, MinIO and most compatible targets reject a PutObject that carries
x-amz-object-lock-* headers unless it also carries Content-MD5 or an
x-amz-checksum-* header. Since rustfs#6895 the replication client sends
plain signed payloads with no SDK checksum, so every replicated object
with a retention period or legal hold failed against such targets.

TargetClient::put_object now decides per request through the pure
rustfs_replication::object_lock_put_integrity: a plaintext single-part
object whose source ETag is its MD5 gets Content-MD5 derived from the
ETag (no body pass, framing unchanged); a multipart-layout ETag, managed
SSE or SSE-C passthrough falls back to an SDK CRC32; a forwarded source
checksum or an unlocked PUT is left alone.

The outbound target matrix flips its two KnownFailing(rustfs#7082) cells
to Completed and every Completed cell now asserts that a locked
PutObject carried an integrity header.

Fixes rustfs#7082.

* test(e2e): keep the matrix expectation table clippy-clean under -D warnings

The CI lint runs cargo clippy --all-targets -- -D warnings. With every cell
green the single-arm match tripped match_single_binding and the unused
KnownFailing variant tripped dead_code, and the target-client tests tripped
field_reassign_with_default. Drive the expectation table from a
KNOWN_FAILING_CELLS constant (so the variant stays live and adding a red
cell is a one-line entry), build the test options as struct literals, and
refresh the e2e-repl-nightly selection digest for the renamed table test.
2026-09-03 20:26:44 +08:00
cxymds 0f272ddb14 docs(ilm): document persistence recovery contracts (#7106) 2026-09-03 20:05:46 +08:00
cxymds 0181a583a6 fix(ilm): recover orphaned restore generations (#7104) 2026-09-03 20:03:51 +08:00
Zhengchao An 0713a723cd fix(odm): close three on-demand migration follow-ups from the e2e matrix (#7101) 2026-09-03 19:40:54 +08:00
cxymds a6cb34c7a4 fix: fence transition transaction recovery (#7095) 2026-09-03 10:38:30 +00:00
cxymds 703766677b fix(ci): provision a pinned client for tier tests (#7098) 2026-09-03 18:01:36 +08:00
Zhengchao An f7c51271ee test(rustfs): run RestoreObject generation-guard test on a large stack (#7100)
The scenario keeps the shared gating ECStore, a live RestoreObject
authorization hook and the RestoreObject usecase future alive at once, which
left the 2 MiB test-thread stack a few KiB from overflowing on Linux; pinning a
single future was not enough. Run the whole scenario through the existing
run_large_stack_test helper instead.
2026-09-03 09:40:05 +00:00
houseme 0e6ee3bf62 feat(scanner): coordinate usage and workload boundaries (#7093)
* test(scanner): wire usage and heal rebuild gates

* docs(scanner): define usage authority protocol

* docs(heal): clarify scanner and ecstore boundaries

* refactor(scanner): split metrics from contracts

* feat(scanner): use shared workload snapshots

* fix(ecstore): recheck capacity before decommission drain
2026-09-03 17:02:43 +08:00
houseme 3ab7a1921f chore(deps): refresh mimalloc and codec usage (#7096) 2026-09-03 16:11:24 +08:00
cxymds 86ebcb325c fix(replication): fence stale metadata status writeback (#7083)
Co-authored-by: houseme <housemecn@gmail.com>
2026-09-03 08:05:58 +00:00
唐小鸭 8023cf3e26 test(e2e): add the outbound target matrix and #7082 postmortem (#7092)
test(e2e): add the outbound target matrix and the replication checksum postmortem

Defense work for rustfs#7082, the regression rustfs#6895 introduced while
fixing rustfs#6853: a fix for one target class changed a client default for
every target class and nothing in tree modeled the other classes.

- docs/postmortems: timeline, root cause, why four defense layers missed
  it, and the SOP for changing any outbound client default; AGENTS.md and
  the adversarial compatibility lens point at it; the two env knobs from
  rustfs#6895 are documented in docs/operations.
- fake_s3_target: reject_aws_chunked_uploads, require_checksum_for_object_lock
  (Content-MD5 always verified), create_bucket_with_object_lock with a
  GetObjectLockConfiguration handler, and a TransportSnapshot on every
  journal record.
- replication_target_matrix_test: six object shapes against four target
  modes with an explicit expectation table; the two rustfs#7082 cells are
  pinned KnownFailing and fail with an XPASS message once the fix lands.
  Wired into e2e-repl-nightly, excluded from e2e-full.
2026-09-03 14:57:05 +08:00
cxymds 54a7e9f307 fix(ecstore): stabilize decommission config and retry tests (#7091) 2026-09-03 14:45:26 +08:00
cxymds 5e58b1d3a2 test(ecstore): stabilize subquorum free-version fixture (#7090) 2026-09-03 14:06:11 +08:00
cxymds df30dff1a7 feat(storage): complete Snowball and decommission follow-ups (#7039)
* feat(storage): complete Snowball and capacity follow-ups

* fix(ecstore): clarify V3 capacity gate guidance

* fix(ecstore): keep target contention retryable

* fix(ecstore): preserve typed target lock errors

* fix(ecstore): harden decommission recovery

* fix(ecstore): close decommission recovery races

* fix(ecstore): fail closed on multipart cleanup gaps

* fix(ecstore): model capacity mutation parameters

* fix(ecstore): settle checkpoint capacity retries
2026-09-03 03:58:59 +00:00
Zhengchao An d011ec9952 feat(odm): enable on-demand migration by default (#7089)
* feat(odm): enable on-demand migration by default

The module switch RUSTFS_ON_DEMAND_MIGRATION_ENABLED now defaults to true,
so the feature is reachable without an opt-in; setting it to false still
keeps the module out of the read path entirely. A bucket without an
on-demand-migration.json is never resolved by the runtime and makes no
source call, so the flip changes nothing for unconfigured buckets.

The admin plane now reads the switch through the predicate published by
module_switches.rs instead of its own duplicated env constant; the
behaviour (an environment read per call) is unchanged.

* test(e2e): wire three on-demand migration cases into e2e-smoke

The PR smoke lane gains one case per user-visible contract: a GET miss
that pulls and persists, a HEAD miss that answers from the source and
stores nothing, and the admin config/status pair that must redact the
source secret. The HEAD case did not exist outside the nightly
real-source lane, so it is added to get_basic_test.

Measured on darwin: the lane goes from 168 tests in 101.98 s to 171
tests in 101.92 s, since the three cases overlap the lane's existing
work. The darwin selection digests for e2e-smoke and e2e-full are
regenerated; the e2e-full linux digest still needs a Linux runner.

* docs(changelog): record the on-demand migration feature
2026-09-03 11:40:54 +08:00
Sergey Shlukov 1747ed0292 fix(ecstore): stop scan_dir emitting entries past a limit hit inside a subdirectory (#7049)
* fix(ecstore): stop scan_dir emitting entries past a limit hit inside a subdirectory

scan_dir's flush loop recurses into a pending subdirectory when the
current sibling entry's page limit is reached mid-recursion, but kept
writing the current (later-sorting) entry regardless. gather_results
then builds the next page's continuation marker from that later entry,
which permanently skips the still-unscanned tail of the subdirectory
on resume instead of just deferring it to the next page.

Add a limit re-check right after the flush loop, before the current
entry is written, so scan_dir stops cleanly at the true last-written
key. Reproduces and fixes the rc.5 recursive ListObjectsV2 data-loss
report (7826/7881 keys, contiguous 55-key block silently dropped).

Adds scan_dir_does_not_emit_entries_past_a_limit_hit_inside_a_subdirectory.

* fix(ecstore): re-check the page limit on every dir_stack flush iteration

The flush loop that drains dir_stack can pop and recurse into more than
one pending subdirectory per outer iteration (whenever more than one
stack entry sorts below the current sibling entry). The limit re-check
added in the previous commit only ran once, after the whole flush loop
exited - so if the first recursive scan_dir call already exhausted the
page limit, the loop's next pop+recurse still went ahead and scanned
(and emitted entries for) another subdirectory beyond where the page
was supposed to stop.

Confirmed against production data: a bucket with ~1.17M objects under
one prefix still cut a recursive ListObjectsV2 listing short (825 of an
expected much larger next page, IsTruncated=false) even with the first
fix deployed, at a two-level-nested subdirectory. Move the check inside
the while loop so it runs before every pop, not just once after.

---------

Co-authored-by: Claude Agent <agent@local>
2026-09-03 02:43:58 +00:00
Zhengchao An 74be040c62 feat(ecstore): add the on-demand migration backfill job (#7087)
* feat(ecstore): add on-demand migration backfill job core

Add the background backfill job for on-demand migration
(rustfs/backlog#2159): a durable checkpoint under
buckets/<bucket>/on-demand-migration-backfill.json saved by If-Match
compare-and-set every 1000 keys or 10 s, a 60 s owner lease renewed by
every save, a recovery pass that takes over expired leases (or jobs this
node owned before a restart) and cancels jobs whose config changed, and a
main loop over the source ListObjectsV2 pages with the skip_existing
policy, dry runs, bounded outstanding pulls and wait-on-full enqueueing.

The pull queue gains per-job completion reports so the job can count
pulled/failed keys (hashes only), and pull permits become two-tier so an
online miss is never queued behind a backfill pull.

* feat(admin): expose on-demand migration backfill job

Wire the ODM-12 backfill job (rustfs/backlog#2159) to its operators:
POST /v3/on-demand-migration/{bucket}/backfill?op=start|cancel and
GET .../backfill return the checkpoint document, GET .../status gains a
backfill summary, and the recovery loop plus the process-wide runner are
installed at startup. Backfill control reuses
Set/GetBucketOnDemandMigrationAction and is recorded in the route policy,
the registration matrix and the admin route snapshot.

Add the rustfs-madmin wire types and client methods with golden fixtures
shared by the server tests, the backfill_* metric descriptors and their
collector, and three e2e scenarios: a full backfill across list pages,
cancellation, and resuming from the persisted continuation token after a
server restart.
2026-09-03 08:52:58 +08:00
Zhengchao An df4fdef1d8 docs(odm): add the on-demand migration operations guide (#7086)
* docs(odm): add the on-demand migration operations guide

Positioning against replication, site replication, tiering and the
replication read-proxy; the admin API walkthrough; the full config field
table with defaults and bounds; the semantics table with the test that
pins each row; provider presets and least-privilege source permissions;
integrity, ETag and metadata mapping; protections, error codes,
observability with PromQL, troubleshooting, known limitations and
security notes.

Adds docs/architecture/background-services-inventory.md with the
write-back pipeline, backfill job and backfill recovery loop rows, and
registers it in the architecture index.

Refs rustfs/backlog#2160, rustfs/backlog#2147.

* docs(odm): note migration sources on the replication egress guard

Refs rustfs/backlog#2160.
2026-09-03 08:48:31 +08:00
Zhengchao An 759a395fec test(e2e): cover on-demand migration faults, concurrency and interactions (#7085)
* test(odm): add the fault, concurrency, interaction and real-source e2e

Twenty-one scenarios for on-demand migration: source failures and the
circuit breaker, single-flight and the pull-concurrency limits, how a
pulled object composes with encryption, Object Lock, quota, events,
replication, delete markers and the disable switch, and two cases against
a real second RustFS as the source.

Every assertion carries a source-request-count dimension so a case cannot
pass by serving the client while quietly re-reading the source.

* ci(odm): route the slow on-demand-migration e2e to the nightly lane

The fault, concurrency and real-source modules wait out the 30 s breaker
window, drive 100-deep bursts, or start extra RustFS processes, so they
join e2e-nightly and are subtracted from the e2e-full merge budget. The
e2e-smoke selection is unchanged.
2026-09-03 08:28:16 +08:00
Zhengchao An d04611ba38 feat(object): serve GET misses from the on-demand migration source (#7084)
feat(rustfs): serve GET misses from the migration source

Wire the on-demand migration read-through into the GET path, after the
local read and the replication proxy have both missed (rustfs/backlog#2156).

A source HEAD supplies size, validators and metadata. Conditional headers
are evaluated locally against it and never forwarded, so a source 304/412
cannot be mistaken for a source failure. An object within inline_max_bytes
is teed: the primary streams to the client while the secondary commits the
local copy in a background task, so a client disconnect still stores the
whole object and a failed write-back never touches the client stream.
Range reads and larger objects stream straight through and queue a
background pull per policy. Concurrent misses of one key share the
singleflight slot: the leader tees, followers re-read local after it
commits or degrade to passthrough after first_byte_ms.

Version reads, partNumber reads, anti-loop marked requests and a respected
local delete marker keep their original 404. Source answers carry
x-rustfs-on-demand-migration: source; local hits are untouched, and the
local hit path gains no await or lock.
2026-09-03 07:21:51 +08:00
唐小鸭 37344a84da fix(replication): quote the ETag in the single-PUT size guard message (#7075)
The guard added in #7021 fails a >5 GiB single-PutObject replication up
front instead of streaming the body to a target that must reject it. Its
message asserted a conclusion: "was not written as multipart on the
source ... re-upload it with multipart". That text is only as right as
the transport decision feeding it, and until #7047 that decision was
wrong for multipart objects carrying a full-object checksum. On 1.0.0-rc.5
a 768-part object was misrouted to the single-PUT path, and the new
default-level error line told the operator to re-upload as multipart an
object whose own ETag ended in -768.

State the evidence instead of the conclusion. The message now quotes the
ETag the decision was read from and says what was read from it (no
part-count suffix), so an operator can check the line against the
object's listing. A misroute then reads as a visible contradiction --
a suffixed ETag on a single-PUT line -- and the message says that case is
a transport-selection defect to report, not something to fix by
re-uploading. A missing or empty ETag is printed as <none> rather than
hidden.

The routing itself is already fixed by #7047; this changes only what the
guard says when it fires.
2026-09-03 07:03:49 +08:00
Zhengchao An c1070f1447 fix(ci): refresh Linux full E2E selection (#7069)
Co-authored-by: houseme <housemecn@gmail.com>
2026-09-03 07:03:36 +08:00
唐小鸭 8bf569899a fix(s3): report FULL_OBJECT checksum type for multipart objects (#7060)
* fix(ecstore): persist merged checksum type for full-object multipart

complete_multipart_upload built the object-level checksum record from a
ChecksumType copied before the MULTIPART / INCLUDES_MULTIPART flags were
merged in. ChecksumType::merge takes &mut self, so the merge updated the
local variable while the copy already inside the Checksum struct stayed
behind. The composite branch rebuilt the Checksum from the merged type
and was unaffected; the full-object branch never rebuilt it, so those
flags never reached disk.

rustfs_rio::read_checksums only sets its multipart flag and only emits
the "x-amz-checksum-type" = "FULL_OBJECT" entry inside its MULTIPART
branch, so a full-object multipart object read back as non-multipart with
no type entry, and GetObject and HeadObject answered with no
x-amz-checksum-type header at all where AWS returns FULL_OBJECT.

Hand the full-object branch the merged type instead of rebuilding the
Checksum: the value must stay the running merge produced by add_part,
because hashing the concatenated part digests would yield the COMPOSITE
value, a different number than the one the client sent. The serialization
now lives in multipart_object_checksum_record so both shapes are covered
by unit tests.

Records written by earlier builds carry the bare algorithm type with no
MULTIPART flags and no trailing part block; they keep reading back to the
same checksum value, and the FULL_OBJECT reader arm predates this change
so older peers parse the new record shape correctly too.

Found while root-causing rustfs#6825.

* fix(s3): reject contradicting multipart checksum type as client error

A CompleteMultipartUpload declaring an x-amz-checksum-type that
contradicts the type recorded at CreateMultipartUpload answered 500
InternalError, telling the caller to retry a request that can only ever
fail. The storage layer does refuse the combination, but through a
generic error that maps to InternalError.

Validate the header against the recorded type in the usecase, where the
upload metadata returned by get_multipart_info is already in hand, and
answer InvalidRequest naming both types, matching AWS. The storage-layer
check stays as a backstop for non-HTTP callers.

Uploads created without a checksum algorithm record no type, so there is
nothing to contradict and the header is left alone rather than newly
rejected. Replication is unaffected: replication_put_object_options
already excludes x-amz-checksum-type from the metadata it forwards.

* test(e2e): cover full-object multipart checksum type round-trip

Adds an end-to-end test that a CRC32 FULL_OBJECT multipart upload reports
x-amz-checksum-type: FULL_OBJECT and the unsuffixed full-object value on
both GetObject and HeadObject, and one that a CompleteMultipartUpload
contradicting the recorded type is rejected as InvalidRequest while
leaving the upload intact. Extends the existing CRC64NVME multipart test
with the same checksum-type assertion.

* fix(s3): keep checksum-type validation off the s3s error macro

The s3s footprint ratchet (scripts/check_s3s_footprint.sh) counts
s3_error! invocation lines and is lower-only: new code must route
through the gateway abstractions rather than widen the direct s3s
surface the s3gate migration is shrinking.

Raise the contradiction through ApiError::invalid_request instead. The
response is byte-for-byte identical -- From<ApiError> for S3Error carries
the InvalidRequest code and the message through unchanged -- and the
usecase already returns ApiError elsewhere, so this is the idiomatic
path rather than a way around the counter.

The explanatory comment deliberately says "the s3s error macro" instead
of naming the macro: the ratchet counts raw matches, so spelling it out
in a comment tripped the same check.
2026-09-03 07:03:19 +08:00
Henry Guo 98f7e63396 fix(heal): recover replacement after transient disk errors (#7059)
* fix(heal): recover replacement after transient disk errors

* fix(heal): satisfy replacement status clippy lint
2026-09-03 07:03:01 +08:00
Zhengchao An 07dce1cab0 feat(obs): export on-demand migration metrics and status snapshot (#7081)
* feat(obs): export on-demand migration bucket metrics

Add the on_demand_migration metric subsystem: per-bucket request,
pull, failure, inflight, queue depth, source latency distribution and
breaker state series fed from the ODM runtime snapshot through the
storage boundary, collected alongside bucket replication metrics, and
retired once a bucket's config disappears.

* feat(admin): report the full on-demand migration status snapshot

Extend GET /v3/on-demand-migration/{bucket}/status with provider,
endpoint host, breaker state, runtime counters, last source error,
inflight and queue gauges and the config timestamp. served_by_source_ratio
stays null: no per-bucket GET total exists to divide by. Update the
madmin status type and golden fixture together.
2026-09-03 03:46:28 +08:00
Zhengchao An b727e669db fix(object): route write-back test wire types through the s3 facade (#7080) 2026-09-03 03:38:40 +08:00
Zhengchao An 0fe6cc3641 feat(ecstore): add on-demand migration write-back pipeline (#7079)
* feat(ecstore): add on-demand migration pull queue and write-back pipeline

Background pull queue per bucket (bounded by pull_queue_capacity, concurrency via the state's pull slot), OdmWriteBack/PullSource traits, single-part and multipart write-back with a pumped body that enforces idle timeout, cancel and content length, retry policy for retryable source errors, inline commit helper, and stats accounting (rustfs/backlog#2153).

* feat(object): implement on-demand migration write-back over internal put

OdmWriteBack impl mapping source heads onto InternalPutContext (content-header allowlist, x-amz-meta copy, tags, dual-prefix odm-* provenance, ETag policy), injected into OnDemandMigrationSys at startup; removes the dead-code gates left by ODM-06a (rustfs/backlog#2153).
2026-09-03 02:45:24 +08:00
houseme 5be9d255e6 Fix rustfs-cli e2e target warning (#7073)
* fix: split rustfs-cli e2e entrypoint

* fix: allow e2e enrollment feature without fixtures
2026-09-02 18:21:27 +00:00
Zhengchao An 817adb127c fix(admin): gate the s3 facade auth re-export to tests (#7078) 2026-09-03 02:08:37 +08:00
Zhengchao An e3349f5f30 feat(object): serve HEAD misses from the on-demand migration source (#7077)
feat(object): proxy HEAD misses to the on-demand migration source

Add the ODM HEAD passthrough (rustfs/backlog#2155): after the local lookup
and the replication proxy both miss, resolve the bucket through
OnDemandMigrationSys and answer from the source's HEAD without writing back
or queueing a pull. Versioned reads, requests carrying the source-proxy
anti-loop marker, and a respected latest delete marker never consult the
source; policy.head=local_only, the negative cache and an open breaker
answer 404 locally. Source 404 feeds the negative cache; other source
failures map to 424 SourceUnavailable (class only) or 404 per
policy.source_error, and unsupported source objects always map to 424.
Source answers carry x-rustfs-on-demand-migration: source and omit version,
SSE and replication headers.

The delete-marker probe, request gate, 424 constructor and response marker
live in shared.rs for the GET passthrough to reuse.
2026-09-03 02:08:34 +08:00
Zhengchao An a5bde8b0af feat(admin): add on-demand migration bucket admin API (#7076)
* feat(ecstore): add on-demand migration bucket config model

Introduce OnDemandMigrationConfig (deny_unknown_fields, version 1) with typed validation, credential redaction, a secret-free Debug impl, and the OnceLock publish hook the runtime registers into. Exported through the api facade.

* feat(ecstore): persist on-demand migration config in bucket metadata

Store the config as a RustFS extension entry (on-demand-migration.json) with its update time in .metadata.bin, add the typed BucketMetadataSys accessor, and publish the config through the hook on every cache-install path alongside the durability sync.

* refactor(ecstore): extract shared remote S3 client builder

Move the aws_sdk_s3 client construction out of bucket_target_sys into
bucket/remote_s3_client.rs: endpoint assembly, credential provider,
path-style selection, custom CA / skip-TLS transports and the outbound
SSRF gate now build from a neutral RemoteS3EndpointSpec so replication
targets and the upcoming on-demand migration source client share one
policy. Replication builds its client through From<&BucketTarget>; the
gate keeps its relaxed semantics (private allowed, loopback only behind
RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET) verbatim. The builder also
gains optional connect/read timeouts and a User-Agent suffix
interceptor, both unset for replication.

Refs rustfs/backlog#2149

* feat(ecstore): add on-demand migration SourceClient

Add bucket/on_demand_migration/source_client.rs on top of the shared
remote S3 builder: HEAD, ranged streaming GET, ListObjectsV2 with
source-prefix mapping, GetObjectTagging and an admin probe. Every request
carries the x-rustfs-/x-minio-source-proxy-request anti-loop markers and
a RustFS-OnDemandMigration/<version> User-Agent suffix; SSE-C source
objects are rejected as unsupported. SourceError classifies SDK failures
(not found, access denied, throttled, timeout, connect, server error)
with retryability and a stable metrics label. Debug output redacts
credentials.

Refs rustfs/backlog#2149

* docs(operations): point outbound policy at shared remote S3 client builder

* chore: integrate ODM-01 and ODM-02 as B1 base (fix facade merge)

* feat(admin): add on-demand migration bucket admin API

Add the management plane for On-Demand Migration (ODM-07,
rustfs/backlog#2154): PUT/GET/DELETE /v3/on-demand-migration/{bucket},
PUT ?dry-run=true, and a GET .../status skeleton.

- PUT authorizes SetBucketOnDemandMigration, checks the bucket, the
  RUSTFS_ON_DEMAND_MIGRATION_ENABLED switch and the license, validates the
  ODM-01 config against local endpoints and replication targets, probes the
  source with SourceClient::probe(), then persists through the incarnation
  gate and asks peers to reload. Responses carry the redacted config and a
  probe summary; probe failures name only the error class.
- GET answers 404 NoSuchConfiguration when unset; DELETE is idempotent (204).
- New AdminAction variants admin:SetBucketOnDemandMigration and
  admin:GetBucketOnDemandMigration, route policy matrix rows, registration
  and MinIO alias coverage, and a doc row for the extra handler gates.
- rustfs-madmin gains on_demand_migration wire types and client methods;
  golden fixtures under crates/madmin/fixtures/on_demand_migration/ are
  asserted byte-for-byte by both the handler and the client tests.

Anonymous sources still map to a 400 naming source.credentials until the
runtime slice adds the credential-less path.

* refactor(admin): route on-demand migration handler errors through the s3 facade
2026-09-03 01:58:49 +08:00
Zhengchao An a23d4b05a3 feat(ecstore): add on-demand migration runtime OnDemandMigrationSys (#7074)
* feat(ecstore): add on-demand migration bucket config model

Introduce OnDemandMigrationConfig (deny_unknown_fields, version 1) with typed validation, credential redaction, a secret-free Debug impl, and the OnceLock publish hook the runtime registers into. Exported through the api facade.

* feat(ecstore): persist on-demand migration config in bucket metadata

Store the config as a RustFS extension entry (on-demand-migration.json) with its update time in .metadata.bin, add the typed BucketMetadataSys accessor, and publish the config through the hook on every cache-install path alongside the durability sync.

* refactor(ecstore): extract shared remote S3 client builder

Move the aws_sdk_s3 client construction out of bucket_target_sys into
bucket/remote_s3_client.rs: endpoint assembly, credential provider,
path-style selection, custom CA / skip-TLS transports and the outbound
SSRF gate now build from a neutral RemoteS3EndpointSpec so replication
targets and the upcoming on-demand migration source client share one
policy. Replication builds its client through From<&BucketTarget>; the
gate keeps its relaxed semantics (private allowed, loopback only behind
RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET) verbatim. The builder also
gains optional connect/read timeouts and a User-Agent suffix
interceptor, both unset for replication.

Refs rustfs/backlog#2149

* feat(ecstore): add on-demand migration SourceClient

Add bucket/on_demand_migration/source_client.rs on top of the shared
remote S3 builder: HEAD, ranged streaming GET, ListObjectsV2 with
source-prefix mapping, GetObjectTagging and an admin probe. Every request
carries the x-rustfs-/x-minio-source-proxy-request anti-loop markers and
a RustFS-OnDemandMigration/<version> User-Agent suffix; SSE-C source
objects are rejected as unsupported. SourceError classifies SDK failures
(not found, access denied, throttled, timeout, connect, server error)
with retryability and a stable metrics label. Debug output redacts
credentials.

Refs rustfs/backlog#2149

* docs(operations): point outbound policy at shared remote S3 client builder

* chore: integrate ODM-01 and ODM-02 as B1 base (fix facade merge)

* feat(ecstore): add on-demand migration runtime OnDemandMigrationSys

Per-node runtime for On-Demand Migration (rustfs/backlog#2152): turns each
bucket's persisted config into a live SourceClient guarded by a three-state
circuit breaker, a TTL negative cache, per-key singleflight, a pull
concurrency semaphore and lock-free counters with a serializable snapshot.

- sys.rs: OnceLock singleton; `apply` installs/rebuilds/removes bucket state
  (config compared by value, counters preserved across rebuilds, old
  cancellation token fired); `publish` is the metadata publish-hook entry
  (sync removal, spawned install, generation-ordered so a slow older install
  cannot overwrite a newer one); `resolve(bucket, key)` judges module switch,
  bucket state, prefix filter, client availability, negative cache, breaker.
- breaker.rs: Closed/Open/HalfOpen with fixed constants (5 failures / 30 s
  window / 30 s open / 1 probe); NotFound resets, AccessDenied is neutral.
- negative_cache.rs: moka sync cache keyed by local key, ttl=0 disables.
- stats.rs: requests_total{op,outcome}, pulled_bytes/objects, pull_failures,
  inflight/queue gauges, log-bucket latency histogram, last_source_error;
  snake_case snapshot pinned by a golden JSON test.
- Anonymous sources surface as a typed `OdmStateError::AnonymousUnsupported`
  until the shared client builder gains an anonymous mode.
- rustfs: `RUSTFS_ON_DEMAND_MIGRATION_ENABLED` module switch (default false)
  published to module_switches and injected into ecstore before bucket
  metadata loads; hook registered at the same point.
2026-09-03 00:51:03 +08:00
Zhengchao An 183b5c9ede refactor(object): extract internal put entry and ODM provenance keys (#7071)
* feat(utils): add on-demand migration provenance metadata suffixes

* refactor(object): extract internal put entry from the S3 PutObject path
2026-09-02 22:56:23 +08:00
Zhengchao An 1ab6405ac9 feat(ecstore): add on-demand migration bucket config model (#7061)
* feat(ecstore): add on-demand migration bucket config model

Introduce OnDemandMigrationConfig (deny_unknown_fields, version 1) with typed validation, credential redaction, a secret-free Debug impl, and the OnceLock publish hook the runtime registers into. Exported through the api facade.

* feat(ecstore): persist on-demand migration config in bucket metadata

Store the config as a RustFS extension entry (on-demand-migration.json) with its update time in .metadata.bin, add the typed BucketMetadataSys accessor, and publish the config through the hook on every cache-install path alongside the durability sync.

* test(e2e): rename stall timing variable flagged by typos

* test(storage): heap-pin the RestoreObject usecase future in the generation guard test
2026-09-02 22:54:51 +08:00
Zhengchao An 9e0663cbba test(e2e): rename stall timing variable flagged by typos (#7070) 2026-09-02 22:46:28 +08:00
houseme ba20af77bb fix(ecstore): wait for multipart copy readiness (#7065) 2026-09-02 14:31:29 +00:00
houseme 2231633ae1 fix: preserve protocol service account claims (#7062) 2026-09-02 14:16:15 +00:00
houseme 2e2bc814b1 fix(scanner): align usage floor compat marker (#7064)
fix(scanner): accept bootstrap marker on timeout fence
2026-09-02 14:03:04 +00:00
Zhengchao An 1dd81cf276 feat(rio): add bounded tee reader for one-read two-consumer paths (#7066)
Add `tee_reader` / `tee_reader_with_options` in `rustfs-rio`: a
`TeePrimary` that drives the source and a `TeeSecondary` that observes
an identical copy of every chunk through a byte-bounded queue. The
primary returns `Pending` when the queue is full, so both sides advance
at the pace of the slowest consumer; it is meant for small objects only.

Termination: source EOF and errors propagate to the secondary with the
same `io::ErrorKind`; dropping the secondary turns the primary into a
pass-through; dropping the primary early fails the secondary with
`BrokenPipe` by default, or hands the remaining source to a background
drain task bounded by `max_drain_bytes` when
`TeeOptions::drain_on_primary_drop` is set. `TeeSecondary::into_stream`
exposes the queued `Bytes` chunks without an extra copy.

Includes a proptest equivalence test, backpressure, error, drop,
drain-limit and cancel-safety tests, and a criterion bench comparing
tee throughput against a direct read (64 MiB in 1 MiB chunks).
2026-09-02 21:59:46 +08:00
Zhengchao An 01db1f6644 test(e2e): extend fake S3 target as an on-demand migration source (#7068)
* test(e2e): extend fake S3 target as an on-demand migration source

Add ListObjectsV2 paging, Range GET/HEAD, unversioned buckets, standard
and user metadata replay, ResponseStatus/TruncateBodyAt/Stall fault
actions, Range/User-Agent/prefix/continuation-token journal fields,
count_requests, direct seeding, and a configurable object cap to the
programmable fake S3 target, and add the on_demand_migration e2e
harness (OdmTestEnv, admin wrappers, source seeding, local-state
assertions, second RustFS source) with its self-test.

* test(ci): refresh darwin e2e-full selection for ODM harness
2026-09-02 21:59:28 +08:00
Zhengchao An 7e1f261e38 refactor(ecstore): shared remote S3 client builder and ODM source client (#7067)
* refactor(ecstore): extract shared remote S3 client builder

Move the aws_sdk_s3 client construction out of bucket_target_sys into
bucket/remote_s3_client.rs: endpoint assembly, credential provider,
path-style selection, custom CA / skip-TLS transports and the outbound
SSRF gate now build from a neutral RemoteS3EndpointSpec so replication
targets and the upcoming on-demand migration source client share one
policy. Replication builds its client through From<&BucketTarget>; the
gate keeps its relaxed semantics (private allowed, loopback only behind
RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET) verbatim. The builder also
gains optional connect/read timeouts and a User-Agent suffix
interceptor, both unset for replication.

Refs rustfs/backlog#2149

* feat(ecstore): add on-demand migration SourceClient

Add bucket/on_demand_migration/source_client.rs on top of the shared
remote S3 builder: HEAD, ranged streaming GET, ListObjectsV2 with
source-prefix mapping, GetObjectTagging and an admin probe. Every request
carries the x-rustfs-/x-minio-source-proxy-request anti-loop markers and
a RustFS-OnDemandMigration/<version> User-Agent suffix; SSE-C source
objects are rejected as unsupported. SourceError classifies SDK failures
(not found, access denied, throttled, timeout, connect, server error)
with retryability and a stable metrics label. Debug output redacts
credentials.

Refs rustfs/backlog#2149

* docs(operations): point outbound policy at shared remote S3 client builder
2026-09-02 21:59:11 +08:00
houseme 99f85ca2b1 fix(scanner): recover fenced incomplete usage floors (#7055)
* fix(scanner): recover fenced incomplete usage floors

* fix(scanner): validate legacy usage floor shape

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

---------

Co-authored-by: heihutu <heihutu@gmail.com>
2026-09-02 12:32:31 +00:00
cxymds afc66b7182 fix(ilm): enqueue committed tier free versions (#7041)
* fix(ilm): enqueue committed tier free versions

* fix(ilm): stabilize causal cleanup CI coverage

* test(ilm): make expire GET race deterministic

* test(ilm): synchronize expiry with active GET
2026-09-02 11:08:28 +00:00
唐小鸭 922552083f fix(replication): keep multipart objects on the multipart transport (#7047)
A 6 GiB object uploaded to the source as a 768-part multipart upload was
replicated to a generic S3 target with a single PutObject, and the target
rejected the body with EntityTooLarge. No CreateMultipartUpload was ever
issued, so the multipart replication transport never ran for the object
it exists for.

`replication_put_object_options` seeded the transport from
`object_info.is_multipart()` and then overwrote it with the second
return value of `decrypt_checksums`. Those two booleans do not mean the
same thing: the first is the object's storage shape, read from the ETag,
while the second reports whether the stored *checksum record* carries
per-part data. A full-object checksum -- what `aws s3 cp` writes by
default for a CRC algorithm -- is serialized with no MULTIPART flag even
on a multipart upload, so the record reports false and the object was
routed as a single PUT. `decrypt_checksums` documents this in
object_api/types.rs: callers that need routing must consult
`is_multipart()`. Replication did the opposite.

Route on the object's own shape, and let the checksum record only add
multipart-ness, never take it away. Objects already stored with such a
record are fixed too: the ETag was always right.

This also repairs the diagnosis of rustfs#6825, where the single-PUT
5 GiB guard fired against an object that was multipart all along and
told the operator to re-upload it as multipart.

Tests cover the three shapes the router has to separate: a multipart
object with a full-object checksum record (the regression, which fails
without this change), a multipart object with a composite record, and a
single-part object that must not be promoted onto multipart.
2026-09-02 18:22:32 +08:00
唐小鸭 32eb116cbc fix(ecstore): report unreachable bucket-delete residue at error level (#7048)
DeleteBucket answers from a raw per-disk residue scan rather than from a
listing, so it can refuse for a reason no S3 request can observe: the
client drains every version the API will show, DeleteBucket still returns
BucketNotEmpty, and the client-visible message is the generic "The bucket
you tried to delete is not empty" for every blocker kind.

The server does know which residue blocked it, and where — that is what
`bucket_delete_blocked` carries. But it was emitted at `debug`, below
both the `error` DEFAULT_LOG_LEVEL and the `info` the CI s3-tests lane
runs at, so it was never actually written down. An intermittent
BucketNotEmpty in that lane leaves a server log with no trace of the
refusal at all, which is not a diagnosable state: confirmed against the
artifact log of a failing run, where the rejected bucket appears only in
span-close lines and the blocker event is absent entirely.

Split the blocker kinds by whether the client can still reach the
residue. A visible version or a tier free-version is an ordinary 409 —
the bucket really is not empty and the caller can list and delete what is
left — so that stays at `warn`. UnknownXlMeta, OrphanDirectory, and
DiagnosticBudgetExceeded are on-disk state no S3 request can remove; that
is a server-side integrity problem and is now reported at `error`, with
the blocker kind, the residue counts, and the sample path.

This does not change what DeleteBucket accepts or rejects, and does not
retry or suppress anything — it makes the existing diagnosis reachable.

Refs #7005, #7010
2026-09-02 18:22:20 +08:00
houseme 68f47b9219 fix(s3): preserve s3s v0.16 compatibility (#7052) 2026-09-02 16:45:47 +08:00
houseme de7422b508 fix(server): align vhost domains with s3s port matching (#7051) 2026-09-02 07:55:30 +00:00
cxymds 1bbfa71b11 fix(ecstore): preserve buckets after pool expansion (#7040)
* fix(ecstore): preserve buckets after pool expansion

* fix(ecstore): scope bucket operations by erasure set

* fix(ecstore): preserve bucket metadata load errors
2026-09-02 15:33:55 +08:00
Zhengchao An c066faf07a fix: update stale docs/README.md references to docs/architecture/README.md (#7053)
fix(docs): update stale docs/README.md references to docs/architecture/README.md

The file docs/README.md was removed in a previous commit but references
in AGENTS.md, ARCHITECTURE.md, and CLAUDE.md were not updated. The
expanded check_doc_paths.sh now catches these stale references.
2026-09-02 15:30:42 +08:00
Henry Guo ca8bbbf8f3 test(heal): cover target endpoint recovery (#7046) 2026-09-02 05:51:23 +00:00
houseme bc789332b6 chore(deps): refresh s3s and smallvec (#7043)
Update the workspace s3s pin and refresh the lockfile with cargo update/upgrade.

Remove the unused lifecycle url dependency reported by cargo shear.

Tighten the s3s footprint ratchet to the current observed baseline.
2026-09-02 05:04:35 +00:00
cxymds b422d1fea9 fix(ecstore): make publication part matching bijective (#7037)
* fix(ecstore): make publication part matching bijective

* test(ecstore): persist opaque retry etag
2026-09-02 03:00:11 +00:00
cxymds 87bc9d14ea fix(ecstore): defer zero-evidence delete diagnostics (#7036) 2026-09-02 01:50:39 +00:00
Zhengchao An fddaeba247 fix(ci): pass repository to preview release cleanup (#7038) 2026-09-02 09:10:09 +08:00
cxymds b46a8164f8 fix(http): drain request bodies after early responses (#7019)
* fix(http): drain request bodies after early responses

* fix(http): log early response body drain failures

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-09-02 01:09:55 +00:00
hector b1faaafb1f fix(ci): repair chain handoff scripts broken by ${{VAR}} expressions (#7034)
PR #7023 rewrote the handoff retry scripts with shell parameter
expansions collapsed into Actions expression syntax: ${GH_TOKEN:-},
${{attempt}}, ${{DISPATCHED:-0}}, ${{TITLE}} etc. GitHub parses
${{...}} as workflow expressions, and bare identifiers are invalid
there, so all seven shared-VM suite workflows (upgrade, s3-compat, kms,
tier, storage, heal, pool-expand) were rejected as invalid workflow
files on main.

Symptoms since 2026-09-01 23:11 +0800 (bba9347):
- every push to any branch produced 'failure' runs with no jobs
  ('This run likely failed because of a workflow file issue')
- the nightly functional chain dispatched rustfs-chain-upgrade at
  17:08Z but the event was silently dropped: zero repository_dispatch
  runs for all eight shared-VM suites overnight (only performance,
  whose file was untouched, ran)
- the workflows API listed them by path instead of name

Fix: restore the shell expansions (${VAR}, ${VAR:-default}); quote the
expected-event name without legacy backticks; render the markdown fence
via printf so shellcheck can parse the block. actionlint and YAML
validation now pass clean on all eleven rustfs-*.yml workflows.
2026-09-02 08:36:13 +08:00
Zhengchao An 0a975f2fe2 docs(knowledge-base): prune stale content and add agent-facing index (#7035) 2026-09-02 08:26:59 +08:00
Zhengchao An ceeff52229 docs(swift): align README feature lists with router and handler wiring (#7033) 2026-09-02 08:08:37 +08:00
Zhengchao An e04e15aed1 ci: bump repo-visuals-action to v1.3.1 (#7032) 2026-09-02 08:08:07 +08:00
Zhengchao An afa84fa988 docs(readme): refresh feature and status matrix (#7031) 2026-09-02 08:07:51 +08:00
Zhengchao An 7aaed4d67b chore(agents): move mimocode skills into .agents/skills (#7030)
Relocate issue-triage and pr-review from the tool-specific .mimocode
directory into the shared .agents/skills tree that every agent already
reads (AGENTS.md, .claude/skills symlink), and ignore .mimocode/ so a
local copy never gets recommitted.
2026-09-01 23:17:06 +00:00
hector 36e07e104a ci(functional): add replication suite (bucket + site) as chain finale (#7026)
- New RustFS Replication Test workflow (rustfs-replication-test.yml):
  standalone workflow_dispatch (suite selector bucket/site/all) and
  repository_dispatch rustfs-chain-replication; runs on the shared
  smoke-testing runner under the shared functional concurrency group.
- Suite never fails the workflow (continue-on-error): failures are filed
  as redacted issues in rustfs/backlog (deduped per run) and the report is
  uploaded to rustfs/dashboard functional-reports/replication/<date>.md.
- Security now hands off to Replication, making it the tenth and final
  link: upgrade -> s3 -> kms -> tier -> storage -> heal -> pool ->
  security -> replication (performance stays parallel on pf-testing).
- Depends on rustfs/auto-testing#27 (rustfs-replication-test.sh).

Co-authored-by: houseme <housemecn@gmail.com>
2026-09-02 07:08:33 +08:00
Henry Guo b03804566c test(heal): cover coordinator restart during rebuild (#7027)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-09-02 07:06:16 +08:00
Zhengchao An 40a2470feb fix(s3): align encrypted checksums and multipart completion (#7025)
* fix(s3): align encrypted checksum handling

* test(s3): align multipart SSE-C completion

* fix(ecstore): scope startup helper to tests
2026-09-01 17:35:49 +00:00
Henry Guo 7dcfdb3320 fix(heal): preserve automatic replacement recovery status (#7018)
* fix(heal): preserve automatic replacement recovery status

* fix(heal): admit unformatted replacement targets

* fix(heal): preserve replacement heal set scope

* fix(heal): attach scoped replacement targets

* fix(heal): preserve replacement heal set scope

* fix(ecstore): keep startup helper test-only

---------

Co-authored-by: houseme <housemecn@gmail.com>
2026-09-01 17:21:43 +00:00
cxymds 397dbcf102 fix(ecstore): reconcile pending capacity before exact delete (#7016)
fix(ecstore): reconcile capacity before exact delete
2026-09-02 00:25:55 +08:00
cxymds 99073938ae perf(s3): bound Snowball archive decoders (#7022) 2026-09-02 00:25:29 +08:00
唐小鸭 d22991f33b fix(replication): surface failed objects at the default log level (#7021)
Replication could fail an object with nothing in the server log an
operator could act on. Every failure branch in the resyncer is quieter
than `error` on purpose — most sit on the hot path and fire once per
object per ARN — but `DEFAULT_LOG_LEVEL` is `error`, so on a stock
deployment a failed object produced no line at all. Raising those
branches to `warn` (#6840) did not close this: the default filter still
dropped them.

Report the terminal outcome instead of the branches. `replicate_object_
with_outcome` and `replicate_delete_with_outcome` now emit one `error`
per failed (object, target) once the per-target results are merged,
carrying the object key, version id, target ARN and endpoint, and the
target's own error, redacted through `sanitize_resync_error_detail` so
an echoed credential cannot reach the log. Volume is bounded by objects
that actually fail rather than by attempts inside a transfer.

Also state the single-PutObject size limit instead of discovering it at
the target. Replication picks its transport from the source object's
storage shape, not its size, so an object written with one PutObject
replicates with one PutObject however large it is — and S3 caps that at
5 GiB. Such an object could never reach a generic S3 target, and only
found out after streaming the whole body. `replication_single_put_size_
error` fails it up front with a message naming the size, the limit, and
the remedy.

Version-identity drift moves to `error` on a 10-minute per-ARN throttle.
It was `warn` deduped once per ARN per process, so the one line
explaining why a purged version is still on the target was both filtered
out by default and gone for good after it first fired.

Fixes #6825
Refs #6822
2026-09-02 00:25:09 +08:00
唐小鸭 194c8643c0 fix(admin): report real peer health in site replication status (#7024)
`build_metrics_summary` emitted a single metric entry for the local
deployment with `online` hardcoded to `true` and `last_online` stamped
with the current time, so `mc admin replicate status` reported "I am
online" rather than whether the remote site was reachable. A peer could
be down for minutes with replication failing while the status page
stayed green, leaving operators with no signal that the link had
dropped.

Emit an entry for every peer instead, deriving `online` from the
`reachable_peers` set the handler already computes by probing each peer,
and take `total_downtime`/`last_online` from the replication heartbeat's
existing `EpHealth` tracking. Node-local replication counters stay on
the local entry so a two-site cluster does not double-count its own
traffic.

The new `BucketTargetSys::endpoint_health` accessor deliberately does not
call `init_hc`: unlike `is_offline` it must not create health entries as
a side effect, or merely rendering the status page would mark an unknown
peer online.

Failure counters (`Errors`) are unchanged and still read zero; that is a
separate defect in the bucket-level statistics path and is not addressed
here.
2026-09-01 16:23:01 +00:00
houseme 5720c5c748 fix(ecstore): bootstrap verified MinIO adoption metadata (#7020) 2026-09-01 23:15:41 +08:00
cxymds 1941189499 ci(tier): isolate per-run evidence (#7017) 2026-09-01 23:11:28 +08:00
hector bba934723a ci(functional): retry chain handoffs and alert on stall (#7023)
The repository_dispatch handoff step was continue-on-error with a single
attempt: if the call failed (token lacking contents:write, transient API
error), the chain stalled silently while every job stayed green.

Each handoff now retries 3x and, if all attempts fail, files an alert
issue in rustfs/backlog with the exact recovery command before exiting 1
(still continue-on-error, so suite workflows themselves never fail).
2026-09-01 23:11:25 +08:00
唐小鸭 cebe57a2f0 fix(admin): keep site region out of empty IDP comparison (#7015)
`local_idp_settings` stamped the site region into the reported OpenID
settings whenever the federated identity service was published, which it
is even with OpenID disabled and no provider configured. The add
preflight compares those settings verbatim, so two sites in different
regions could never be paired: `replicate add` failed with `IDP settings
mismatch` while both sites reported an identical, empty `identity_openid`
config.

Report the empty OpenID settings when no provider is configured, so the
region only qualifies real provider identities, and name the diverging
field in the rejection instead of emitting a bare mismatch. Scalar values
are echoed; nested objects and credential-derived leaves are reported by
presence only.

Fixes #7003
2026-09-01 22:41:24 +08:00
401 changed files with 70412 additions and 17436 deletions
@@ -22,3 +22,17 @@
fixtures and encrypted migration data.
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
condition, and default toward reading old data safely.
## Outbound targets
- A change to what the replication or migration client sends by default
(checksum policy, payload framing, headers, version-id addressing) is judged
against every target class, not the one it fixes. Name each target-side rule
the current default satisfies — checksum required with Object Lock
parameters, `aws-chunked` decoding, version-id adoption, ETag equals content
MD5 — and show which cell of
`crates/e2e_test/src/replication_target_matrix_test.rs` covers each.
- A test that asserts the fix ("no trailer header") is not evidence; the
matrix cell that asserts the target accepted and stored the object is.
- Every new environment escape hatch appears in
`docs/operations/replication-outbound-transport.md` in the same diff.
+5 -4
View File
@@ -50,10 +50,11 @@ consider adding it to the script's `checked_files` list.
## `check_doc_paths.sh`
Instruction/architecture docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`,
`docs/architecture/*.md`) must not reference repo file paths that no longer
exist. If your refactor moved code, update the docs that point at it — the
error message lists `doc -> stale-path` pairs.
Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
Markdown file under `docs/` (architecture, operations, testing, index) must not
reference repo file paths that no longer exist. If your refactor moved code,
update the docs that point at it — the error message lists `doc -> stale-path`
pairs. Cite paths plus symbol names, never line numbers (see `docs/README.md`).
## `check_no_planning_docs.sh`
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=ef914ec0b8daa9c2c5e52f501d339914662f42d6f6ed9d33877d56b97adf16f9
sha256-linux=a8a816d7bb0e7cb5632b1863b33794bcb9fc7e765f150aa5e1bf16518e28dfb4
sha256-darwin=a881fd7d3f5cb94654221ca85b8b30cce1b95e608824a55a15339cbc294e6d34
sha256-linux=e9a8d64e73f627c4d26c236dbbba690c9ee03a9e26d42a4244515b4439365535
+2 -1
View File
@@ -1 +1,2 @@
sha256=51da41c54167602f2bd6c45921b39a44562bf3cfcdf468d992bb992c62cad7fd
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
+1
View File
@@ -0,0 +1 @@
sha256=87c05c46d611ea7ed3feb5f7276bda8e5a0f70d72165d305d73a457907e7ba79
+1 -1
View File
@@ -1 +1 @@
sha256=8d5517f5f2fc32d561782dfccd51b7f746f5e25b2835e37e100c883f7f18777d
sha256=95c8adc016bbc0df9fb2afa24a108bcdf6567ec4d0518725a6cae301593ab556
+1 -1
View File
@@ -1 +1 @@
sha256=dbebfbab9b9efd4eff31211e69dd32235dc00e207f2ab0dd919a1b2ac9e724c2
sha256=a2542dc86bbff56b2177efc621785c56fa7e8d813b209b7d935e1e41a9f0ad15
+88 -13
View File
@@ -69,7 +69,7 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
setup = 'ecstore-large-stack'
[[profile.default.scripts]]
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
setup = 'ecstore-base-stack'
[[profile.default.scripts]]
@@ -210,7 +210,7 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
setup = 'ecstore-large-stack'
[[profile.ci.scripts]]
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
setup = 'ecstore-base-stack'
[[profile.ci.scripts]]
@@ -355,7 +355,8 @@ test-group = 'ecstore-serial-flaky'
# allowlist", so any new replication test lands in nightly by default (never
# silently unrun) until it is explicitly blessed as fast here. Keep the two
# regexes byte-identical. The committed profile selection digests make changes
# visible in CI; current counts live in docs/testing/e2e-suite-inventory.md.
# visible in CI; list current membership with `cargo nextest list -p e2e_test
# --profile <profile>` (platform-dependent; see docs/testing/README.md).
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
# (#4724) because they set a loopback (127.0.0.1) replication target that the
# SSRF egress guard rejected on every PR after repl-1 (#4712). That is fixed —
@@ -393,13 +394,32 @@ test-group = 'ecstore-serial-flaky'
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
# Single-node servers on random ports with isolated temp dirs — meets the
# admission criteria unchanged.
#
# On-demand migration GA (backlog#2163 ODM-16): three named cases join the
# lane, one per user-visible contract of the feature — a GET miss that pulls
# the object and persists it locally, a HEAD miss that answers from the source
# and stores nothing, and the admin config/status pair that must redact the
# source secret. Each spawns one single-node rustfs server plus the in-process
# fake S3 source (`fake_s3_target`, already in the first clause), so they meet
# the admission criteria unchanged; measured at 15.8 s / 15.8 s / 15.9 s, which
# is entirely the shared server startup and overlaps the lane's other tests.
# The rest of `on_demand_migration::{get_basic,interaction,backfill,
# harness_self}_test` stays in e2e-full and the fault / concurrency /
# real-source modules stay in e2e-nightly; this is an allowlist, not a module
# clause, so a new ODM test never lands here silently.
#
# Scanner authoritative usage publication (backlog#2213): data_usage_test is
# the PR-lane e2e coverage for scanner usage snapshots consumed by quota and
# admin surfaces. It uses the same single-node, random-port, isolated-temp-dir
# fixture as the existing smoke modules.
[profile.e2e-smoke]
default-filter = """
package(e2e_test) & (
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)_test::|^fake_s3_target::/)
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(/^reliant::tiering::/)
| 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)$/)
)
"""
fail-fast = false
@@ -439,6 +459,10 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
# until it is explicitly promoted to the fast PR subset — no replication test
# is ever silently left out of CI.
#
# replication_target_matrix_test (the outbound target matrix: every object
# shape against every remote-target failure mode the fake target models) runs
# here in full; its expectation table pins known-red cells to open issues.
#
# #[serial] does NOT serialize under nextest (process-per-test; see the file
# header). These tests need no cross-test serialization: each spawns its own
# server(s) on random ports with isolated temp dirs, so they are parallel-safe
@@ -456,7 +480,7 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
[profile.e2e-repl-nightly]
default-filter = """
package(e2e_test)
& test(/^replication_extension_test::/)
& (test(/^replication_extension_test::/) | test(/^replication_target_matrix_test::/))
& !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)$/)
"""
fail-fast = false
@@ -469,15 +493,29 @@ path = "junit.xml"
# ---------------------------------------------------------------------------
# e2e-nightly profile — destructive multi-process cluster fault domains
# ---------------------------------------------------------------------------
# These seven modules are deliberately outside e2e-full's merge budget. Each
# These eight modules are deliberately outside e2e-full's merge budget. Each
# starts a real multi-process or multi-disk topology and exercises node/disk
# loss, quorum, cleanup, notification fan-in, or admin-timeout behavior. The
# consolidated nightly workflow runs them serially to avoid resource
# starvation; failures are never retried.
#
# heal_erasure_disk_rebuild_test also runs in e2e-full so core heal rebuild
# regressions are caught no later than the merge/main lane. It remains here for
# nightly serial coverage with the other cluster fault domains.
#
# On-demand migration (backlog#2158 ODM-11) joins by the second clause: the
# fault matrix waits out the 30 s circuit-breaker window, the concurrency
# matrix drives 100-deep bursts, and the real-source cases start a second
# (loop guard: a third) RustFS process. They are too slow or too heavy for
# the merge budget; `on_demand_migration::{get_basic,interaction}_test` stay
# in e2e-full, which excludes exactly these three modules.
[profile.e2e-nightly]
default-filter = """
package(e2e_test)
& test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& (
test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
| test(/^on_demand_migration::(concurrency_test|fault_test|real_source_test)::/)
)
"""
fail-fast = false
@@ -488,6 +526,34 @@ path = "junit.xml"
filter = 'package(e2e_test)'
test-group = 'e2e-cluster-nightly'
# ---------------------------------------------------------------------------
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
# ---------------------------------------------------------------------------
# backlog#2167. Report-only, scheduled, never a required check; wired by
# .github/workflows/on-demand-migration-interop.yml.
#
# The four cases in `on_demand_migration::interop_test` take their source from
# the environment (`RUSTFS_ODM_INTEROP_*`, documented on the constants in
# `crates/e2e_test/src/on_demand_migration/common.rs`), so the same bodies run
# against the in-process fake source locally and against a MinIO container or a
# real cloud provider in the lane. The cloud jobs narrow this profile with their
# own `-E` filter to the three-case minimum (GET miss, HEAD miss, merged list
# pagination) and pass `--no-tests=fail` so a rename cannot silently select
# nothing; the MinIO job runs the whole profile, backfill included.
#
# These cases are deliberately absent from every other lane: without an
# interop source they only re-prove what `get_basic_test` and
# `list_through_test` already cover in e2e-smoke and e2e-full. The committed
# selection digest is the guard against a rename dropping one of them.
[profile.e2e-odm-interop]
default-filter = 'package(e2e_test) & test(/^on_demand_migration::interop_test::/)'
fail-fast = false
[profile.e2e-odm-interop.junit]
# Emitted to target/nextest/e2e-odm-interop/junit.xml; the lane uploads it and
# reconciles it against the per-case JSON report entries.
path = "junit.xml"
# ---------------------------------------------------------------------------
# e2e-protocols profile — serial protocol lane
# ---------------------------------------------------------------------------
@@ -508,16 +574,23 @@ path = "junit.xml"
# quota, checksum, encryption,
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
# skips. Budget <= 45 min; authority for the suite count is `cargo nextest list
# --profile e2e-full` (see docs/testing/e2e-suite-inventory.md).
# --profile e2e-full -p e2e_test` (platform-dependent; see docs/testing/README.md).
#
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
# * protocols:: — FTPS/SFTP/WebDAV, run from the dedicated protocol profile
# with one worker because the suite owns fixed ports.
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
# * cluster suites that spin up a RustFSTestClusterEnvironment
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
# object_lambda) — too heavy for the merge budget; they run in the
# e2e-nightly serial cluster-fault lane.
# namespace_lock_quorum, admin_timeout_regression, object_lambda) — too
# heavy for the merge budget; they run in the e2e-nightly serial
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
# excluded here because backlog#2213 promotes core heal rebuild coverage to
# this merge/main lane while retaining nightly coverage.
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
# cases, which are meaningless without a source: they run in the dedicated
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
# MinIO container or a real cloud provider. Excluding them here also keeps
# this profile's committed selection digest stable.
# * replication_extension_test — repl-1 already splits it into the PR
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
# it for those, so e2e-full does not double-run it.
@@ -533,8 +606,10 @@ path = "junit.xml"
default-filter = """
package(e2e_test)
& !test(/^protocols::/)
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& !test(/^replication_extension_test::/)
& !test(/^replication_target_matrix_test::/)
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
"""
fail-fast = false
@@ -0,0 +1,100 @@
name: On-demand migration interop report
description: >-
Merge the per-case JSON entries an on-demand-migration interop run wrote with
the nextest JUnit result into one provider report, and summarise it.
inputs:
provider:
description: Provider the run addressed (minio, aws, r2, gcs).
required: true
cases-dir:
description: Directory the cases wrote their JSON entries into.
required: true
junit:
description: nextest JUnit XML of the run.
required: true
output:
description: Path of the merged JSON report to write.
required: true
runs:
using: composite
steps:
# The JUnit file is authoritative for which cases ran and how they ended:
# a case that fails or panics never reaches its own report entry, so
# trusting the entries alone would silently shorten the report exactly when
# something went wrong. The entries only add what JUnit cannot know — the
# source request accounting and the bucket's migration counters.
- name: Merge interop case reports
shell: bash
env:
ODM_REPORT_PROVIDER: ${{ inputs.provider }}
ODM_REPORT_CASES_DIR: ${{ inputs.cases-dir }}
ODM_REPORT_JUNIT: ${{ inputs.junit }}
ODM_REPORT_OUTPUT: ${{ inputs.output }}
run: |
python3 - <<'PY'
import json
import os
import pathlib
import xml.etree.ElementTree as ElementTree
provider = os.environ["ODM_REPORT_PROVIDER"]
cases_dir = pathlib.Path(os.environ["ODM_REPORT_CASES_DIR"])
junit = pathlib.Path(os.environ["ODM_REPORT_JUNIT"])
output = pathlib.Path(os.environ["ODM_REPORT_OUTPUT"])
entries = {}
if cases_dir.is_dir():
for path in sorted(cases_dir.glob("*.json")):
entry = json.loads(path.read_text())
entries[entry["case"]] = entry
cases = []
for case in ElementTree.parse(junit).getroot().iter("testcase"):
name = case.get("name", "")
failed = [child for child in case if child.tag in ("failure", "error")]
skipped = [child for child in case if child.tag == "skipped"]
outcome = "failed" if failed else "skipped" if skipped else "passed"
entry = entries.get(name.rsplit("::", 1)[-1], {})
cases.append(
{
"name": name,
"outcome": outcome,
"junit_duration_ms": round(float(case.get("time", "0")) * 1000),
"case_duration_ms": entry.get("duration_ms"),
"source_requests": entry.get("source_requests"),
"odm_counters": entry.get("odm_counters"),
}
)
report = {
"provider": provider,
"repository": os.environ.get("GITHUB_REPOSITORY", ""),
"sha": os.environ.get("GITHUB_SHA", ""),
"run_id": os.environ.get("GITHUB_RUN_ID", ""),
"cases": cases,
"totals": {
"cases": len(cases),
"passed": sum(1 for case in cases if case["outcome"] == "passed"),
"failed": sum(1 for case in cases if case["outcome"] == "failed"),
},
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
summary = [f"### On-demand migration interop: `{provider}`", "", "| Case | Outcome | Duration | Source requests |", "|---|---|---|---|"]
for case in cases:
requests = case["source_requests"]
counted = f"{requests['total']} ({requests['counted_by']})" if requests else "not reported"
summary.append(f"| `{case['name']}` | {case['outcome']} | {case['junit_duration_ms']} ms | {counted} |")
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle:
handle.write("\n".join(summary) + "\n\n")
# A passed case with no entry of its own means the harness stopped
# writing one: the report would keep looking complete while silently
# losing its request accounting.
unreported = [case["name"] for case in cases if case["outcome"] == "passed" and case["source_requests"] is None]
if unreported:
raise SystemExit(f"passed cases wrote no interop report entry: {', '.join(unreported)}")
PY
+1 -1
View File
@@ -1065,7 +1065,7 @@ jobs:
while IFS= read -r preview_tag; do
[[ -n "$preview_tag" ]] || continue
echo "🧹 Deleting preview release $preview_tag (tag kept)"
gh release delete "$preview_tag" --yes
gh release delete "$preview_tag" --repo "${GITHUB_REPOSITORY}" --yes
DELETED=$((DELETED + 1))
done < <(
jq -r --arg tag "$TAG" '
@@ -0,0 +1,315 @@
# 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.
# On-demand migration provider interop (rustfs/backlog#2167, ODM-20).
#
# The in-process fake source that the merge-gate ODM suite runs against covers
# the protocol semantics, but real implementations differ in path-style vs
# virtual-host addressing, region handling, ETag shape, list pagination and
# rate limiting. This lane runs the same case bodies
# (crates/e2e_test/src/on_demand_migration/interop_test.rs) against real
# sources; the source is injected through RUSTFS_ODM_INTEROP_* environment
# variables, so nothing about the cases is duplicated per provider.
#
# Report-only and scheduled. It is never a required check and must not be
# promoted to one: it depends on third-party endpoints and on repository
# secrets that a fork does not have.
#
# Jobs:
# * minio-source runs the whole e2e-odm-interop profile — read-through,
# HEAD passthrough, merged list pagination and a backfill — against a
# pinned MinIO container. The backfill is sized at 5,000 objects here: the
# fake source retains at most 4,096 object versions and 4,096 journal
# entries, so the merge-gate backfill coverage cannot go past that, and a
# real source is where a production-shaped batch belongs.
# * cloud-source runs the three-case minimum (GET miss, HEAD miss, merged
# list pagination) against AWS S3, Cloudflare R2 and the GCS XML
# interoperability API. Each provider is skipped with a summary note when
# its ODM_INTEROP_* repository secrets are absent, which is the normal
# state on a fork and in any clone of this repository.
#
# Every job uploads one JSON report per provider naming the cases, their
# timings and the source request accounting.
name: on-demand-migration-interop
on:
workflow_dispatch:
schedule:
# Nightly at 05:23 UTC, offset from the other nightly lanes.
- cron: "23 5 * * *"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# The three cases a cloud provider is asked for. Named individually rather
# than by module so adding a fourth case does not silently start billing a
# cloud account for it.
CLOUD_CASE_FILTER: >-
package(e2e_test) & test(/^on_demand_migration::interop_test::(interop_get_miss_pulls_from_the_source_and_serves_locally|interop_head_miss_answers_from_the_source_without_persisting|interop_list_through_pages_the_source_namespace)$/)
CLOUD_CASE_COUNT: "3"
jobs:
minio-source:
name: MinIO source (read-through, list-through, backfill)
# Skip on forks: needs this repository's runners and is not a contributor
# gate.
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
timeout-minutes: 90
env:
NO_PROXY: 127.0.0.1,localhost
# Fixed credentials of the container this job starts and throws away;
# not a secret and deliberately not read from one, so the lane runs
# unattended in any clone that enables it.
MINIO_ROOT_USER: rustfsodminterop
MINIO_ROOT_PASSWORD: rustfsodminteropsecret
RUSTFS_ODM_INTEROP_PROVIDER: minio
RUSTFS_ODM_INTEROP_ENDPOINT: http://127.0.0.1:9100
RUSTFS_ODM_INTEROP_REGION: auto
RUSTFS_ODM_INTEROP_BUCKET: odm-interop-source
RUSTFS_ODM_INTEROP_PATH_STYLE: path
RUSTFS_ODM_INTEROP_ACCESS_KEY: rustfsodminterop
RUSTFS_ODM_INTEROP_SECRET_KEY: rustfsodminteropsecret
RUSTFS_ODM_INTEROP_BACKFILL_OBJECTS: "5000"
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/minio/cases
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/minio/selection.json
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: ci-odm-interop
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: 'false'
- name: Start MinIO source
run: |
set -euo pipefail
mkdir -p artifacts/odm-interop/minio
docker run -d --name rustfs-odm-interop-minio \
-e "MINIO_ROOT_USER=${MINIO_ROOT_USER}" \
-e "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" \
-p 9100:9000 \
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
for _ in $(seq 1 120); do
curl -fsS http://127.0.0.1:9100/minio/health/live >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://127.0.0.1:9100/minio/health/live
# The harness never creates a bucket, so that pointing it at a cloud
# account cannot create one there either. The source bucket for the
# container is created here instead.
- name: Create the MinIO source bucket
env:
AWS_ACCESS_KEY_ID: ${{ env.MINIO_ROOT_USER }}
AWS_SECRET_ACCESS_KEY: ${{ env.MINIO_ROOT_PASSWORD }}
AWS_DEFAULT_REGION: us-east-1
run: |
aws --endpoint-url "${RUSTFS_ODM_INTEROP_ENDPOINT}" \
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
- name: Build the RustFS binary under test
run: cargo build --locked -p rustfs --bins
# The lane selects tests by module, so a rename would quietly shrink it.
# The committed digest in .config/e2e-odm-interop-selection.txt fails
# closed on that.
- name: Verify interop lane membership
run: |
cargo nextest list --profile e2e-odm-interop -p e2e_test --message-format json > "${NEXTEST_LISTING}"
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
- name: Run the interop cases against MinIO
run: cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
- name: Build the MinIO interop report
if: always()
uses: ./.github/actions/odm-interop-report
with:
provider: minio
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
junit: target/nextest/e2e-odm-interop/junit.xml
output: artifacts/odm-interop/minio/report.json
- name: Collect MinIO logs
if: always()
run: |
docker logs --tail 500 rustfs-odm-interop-minio \
> artifacts/odm-interop/minio/minio.log 2>&1 || true
- name: Stop MinIO source
if: always()
run: docker rm -f rustfs-odm-interop-minio >/dev/null 2>&1 || true
- name: Upload the MinIO interop report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: odm-interop-minio-${{ github.run_number }}-${{ github.run_attempt }}
path: |
artifacts/odm-interop/minio
target/nextest/e2e-odm-interop/junit.xml
retention-days: 14
# Unlike a production migration source, which needs read access only, the
# credentials here also seed the objects each case reads back, so they need
# write and delete on the interop bucket. Every run seeds under
# `odm-interop/<case>/<uuid>/` and deletes what it seeded when the case
# passes; give the bucket an expiration lifecycle rule so the prefixes a
# failing case leaves behind cannot accumulate.
cloud-source:
name: ${{ matrix.provider }} source (three-case minimum)
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- provider: aws
secret_prefix: AWS
path_style: virtual
- provider: r2
secret_prefix: R2
path_style: virtual
- provider: gcs
secret_prefix: GCS_HMAC
path_style: virtual
env:
RUSTFS_ODM_INTEROP_PROVIDER: ${{ matrix.provider }}
RUSTFS_ODM_INTEROP_PATH_STYLE: ${{ matrix.path_style }}
RUSTFS_ODM_INTEROP_ENDPOINT: ${{ secrets[format('ODM_INTEROP_{0}_ENDPOINT', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_REGION: ${{ secrets[format('ODM_INTEROP_{0}_REGION', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_BUCKET: ${{ secrets[format('ODM_INTEROP_{0}_BUCKET', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_ACCESS_KEY: ${{ secrets[format('ODM_INTEROP_{0}_ACCESS_KEY_ID', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_SECRET_KEY: ${{ secrets[format('ODM_INTEROP_{0}_SECRET_ACCESS_KEY', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/cases
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/selection.json
steps:
# Absent secrets are the normal state, not a failure: the lane reports
# which providers it could reach and skips the rest. An empty value is
# what an unset repository secret expands to, so it is checked, not the
# secret's existence.
- name: Check for provider credentials
id: credentials
run: |
set -euo pipefail
if [ -z "${RUSTFS_ODM_INTEROP_ENDPOINT}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_REGION}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_BUCKET}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_ACCESS_KEY}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_SECRET_KEY}" ]; then
echo "present=false" >> "$GITHUB_OUTPUT"
{
echo "### On-demand migration interop: \`${{ matrix.provider }}\`"
echo
echo "Skipped: the \`ODM_INTEROP_${{ matrix.secret_prefix }}_*\` repository secrets"
echo "(\`_ENDPOINT\`, \`_REGION\`, \`_BUCKET\`, \`_ACCESS_KEY_ID\`, \`_SECRET_ACCESS_KEY\`)"
echo "are not configured, so no real \`${{ matrix.provider }}\` source was reached."
echo
} >> "$GITHUB_STEP_SUMMARY"
else
echo "present=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout repository
if: steps.credentials.outputs.present == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Rust environment
if: steps.credentials.outputs.present == 'true'
uses: ./.github/actions/setup
with:
cache-shared-key: ci-odm-interop
cache-save-if: 'false'
install-build-packaging-tools: 'false'
- name: Build the RustFS binary under test
if: steps.credentials.outputs.present == 'true'
run: cargo build --locked -p rustfs --bins
# A filterset that matches nothing is valid, so the count is asserted
# rather than inferred from a green run.
- name: Verify the three-case minimum still selects three cases
if: steps.credentials.outputs.present == 'true'
run: |
set -euo pipefail
mkdir -p "$(dirname "${NEXTEST_LISTING}")"
cargo nextest list --profile e2e-odm-interop -p e2e_test \
-E "${CLOUD_CASE_FILTER}" --message-format json > "${NEXTEST_LISTING}"
selected="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(sum(1 for suite in d.get("rust-suites", {}).values() for test in suite.get("testcases", {}).values() if test.get("filter-match", {}).get("status") == "matches"))' "${NEXTEST_LISTING}")"
echo "cloud interop cases selected: ${selected}"
if [ "${selected}" != "${CLOUD_CASE_COUNT}" ]; then
echo "::error::CLOUD_CASE_FILTER selected ${selected} cases, expected ${CLOUD_CASE_COUNT}; the interop cases were renamed or moved. Context: rustfs/backlog#2167."
exit 1
fi
- name: Run the three-case minimum
if: steps.credentials.outputs.present == 'true'
run: |
cargo nextest run --profile e2e-odm-interop -p e2e_test \
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
- name: Build the ${{ matrix.provider }} interop report
if: always() && steps.credentials.outputs.present == 'true'
uses: ./.github/actions/odm-interop-report
with:
provider: ${{ matrix.provider }}
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
junit: target/nextest/e2e-odm-interop/junit.xml
output: artifacts/odm-interop/${{ matrix.provider }}/report.json
- name: Upload the ${{ matrix.provider }} interop report
if: always() && steps.credentials.outputs.present == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: odm-interop-${{ matrix.provider }}-${{ github.run_number }}-${{ github.run_attempt }}
path: |
artifacts/odm-interop/${{ matrix.provider }}
target/nextest/e2e-odm-interop/junit.xml
retention-days: 14
alert-on-failure:
name: Alert on scheduled failure
needs: [minio-source, cloud-source]
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -12,10 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Functional chain driver: runs the nine functional suites in a fixed order
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security, with
# performance on its own runner in parallel) and guarantees the chain keeps
# moving even when individual suites fail.
# Functional chain driver: runs the ten functional suites in a fixed order
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
# replication, with performance on its own runner in parallel) and guarantees
# the chain keeps moving even when individual suites fail.
#
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
# only chain-triggered runs forward to the next suite via repository_dispatch,
+37 -6
View File
@@ -284,21 +284,52 @@ jobs:
- name: "Continue functional chain (next: Pool expansion)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
set -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: Pool expansion"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-pool' \
-F 'client_payload[from_suite]=heal'
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-pool' \
-F 'client_payload[from_suite]=heal'; then
echo "dispatched next suite Pool expansion (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch Pool expansion after 3 attempts" >&2
TITLE="[functional][chain] stalled after heal (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **heal** to **Pool expansion** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-pool'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-pool'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure
if: failure()
+37 -6
View File
@@ -340,21 +340,52 @@ jobs:
- name: "Continue functional chain (next: Tier)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
set -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: Tier"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-tier' \
-F 'client_payload[from_suite]=kms'
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-tier' \
-F 'client_payload[from_suite]=kms'; then
echo "dispatched next suite Tier (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch Tier after 3 attempts" >&2
TITLE="[functional][chain] stalled after kms (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **kms** to **Tier** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-tier'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-tier'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure
if: failure()
+37 -6
View File
@@ -596,21 +596,52 @@ jobs:
- name: "Continue functional chain (next: Security)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
set -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: Security"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-security' \
-F 'client_payload[from_suite]=pool'
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-security' \
-F 'client_payload[from_suite]=pool'; then
echo "dispatched next suite Security (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch Security after 3 attempts" >&2
TITLE="[functional][chain] stalled after pool (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **pool** to **Security** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-security'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-security'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure
if: failure()
@@ -0,0 +1,365 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: RustFS Replication Test
on:
workflow_dispatch:
inputs:
rustfs_version:
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
required: false
default: '1.0.0-rc.4-preview.1'
package_url:
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
required: false
type: string
suite:
description: 'Suite to run (all = bucket REP-* then site SITE-*)'
type: choice
options:
- all
- bucket
- site
default: all
repository_dispatch:
# Chain handoff: dispatched when the security suite finishes. This is the
# last link of the functional chain.
types: [rustfs-chain-replication]
permissions:
contents: read
# The replication suite uses the same shared VMs as the other functional
# tests, so it must serialize with them instead of running in parallel.
concurrency:
group: rustfs-shared-functional-tests
cancel-in-progress: false
defaults:
run:
shell: bash
env:
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
jobs:
replication-test:
runs-on: smoke-testing
# A failed replication run must not break the chain or the workflow: the
# failure is reported to rustfs/backlog instead (see the issue step).
continue-on-error: true
timeout-minutes: 360
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
# auto-testing is private: clone it with the dedicated PF token (not
# GITHUB_TOKEN) and retry transient GitHub/network failures.
- name: Checkout auto-testing scripts (with retry)
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
rm -rf auto-testing
for attempt in 1 2 3 4 5; do
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
echo "auto-testing cloned (attempt ${attempt})"
exit 0
fi
rm -rf auto-testing
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
sleep $((attempt * 15))
done
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
exit 1
- name: Show environment
run: |
uname -a
jq --version
openssl version
aws --version
df -h /data | tail -1 || true
- name: Cleanup environment (before)
run: |
set -euo pipefail
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
for node in "${NODES[@]}"; do
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
${SUDO} dpkg -P rustfs
fi
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2 /var/lib/rustfs/kms
'
done
- name: Run replication suite
id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-replication.log
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-replication-test.sh
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
SUITE='${{ inputs.suite }}'
ARGS=(-y --log-file "${LOG_FILE}")
if [ "${SUITE}" = "all" ] || [ -z "${SUITE}" ] || [ "${SUITE}" = "null" ]; then
ARGS+=(--suite all)
else
ARGS+=(--suite "${SUITE}")
fi
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
ARGS+=(--version "${RUSTFS_VERSION}")
else
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-replication.log
REPORT_FILE: /tmp/rustfs-replication-report.md
run: |
set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
if [ -n "${PACKAGE_URL}" ]; then
PACKAGE_SOURCE="${PACKAGE_URL}"
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
RUSTFS_VERSION_INFO="N/A"
if [ "${#NODES[@]}" -gt 0 ]; then
DETECTED_VERSION="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
"${SSH_USER}@${NODES[0]}" 'rustfs --version' 2>/dev/null | tr -d '\r' | head -n 1 || true)"
if [ -n "${DETECTED_VERSION}" ]; then
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
fi
fi
CASE_TABLE="/tmp/rustfs-replication-cases.md"
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
import re
import sys
log_file, out_file = sys.argv[1], sys.argv[2]
ansi = re.compile(r'\x1b\[[0-9;]*m')
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
rows = []
index = {}
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{
echo "# RustFS replication test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
echo ""
cat "${CASE_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}" || true
echo '```'
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload functional report to dashboard
if: always()
continue-on-error: true
env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-replication-report.md
SUITE: replication
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
exit 0
fi
DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
- name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
SUITE: 'replication'
SUITE_LABEL: 'Replication (bucket + site)'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-replication-report.md'
LOG_FILE: '/tmp/rustfs-replication.log'
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
exit 0
fi
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
EXISTING="$(gh issue list -R rustfs/backlog --state all \
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
--json number --jq '.[].number' || true)"
if [ -n "${EXISTING}" ]; then
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
exit 0
fi
redact() {
sed -E \
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
}
BODY_FILE="$(mktemp)"
{
echo "The **${SUITE_LABEL}** functional suite failed."
echo ""
echo "- Suite: \`${SUITE}\`"
echo "- Run: ${RUN_URL}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}"
elif [ -s "${LOG_FILE:-}" ]; then
echo "(report file missing; log tail below)"
echo ""
tail -n 200 "${LOG_FILE}" | redact
else
echo "(no report or log file was produced)"
fi
} | head -c 55000 > "${BODY_FILE}"
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test; then
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
fi
echo "filed backlog issue for suite ${SUITE}"
- name: Upload report and logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-replication-${{ github.run_id }}
path: |
/tmp/rustfs-replication.log
/tmp/rustfs-replication-report.md
if-no-files-found: warn
- name: Cleanup environment (after)
if: always()
run: |
set -euo pipefail
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
for node in "${NODES[@]}"; do
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
set -euo pipefail
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
${SUDO} dpkg -P rustfs
fi
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2
'
done
- name: Chain complete
# Replication is the last link of the functional chain: nothing to
# dispatch after it. This step just records that the chain finished.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
run: |
echo "Functional chain complete: replication (final suite) finished."
echo "from_suite=security trigger=${{ github.event_name }} outcome=${{ steps.test.outcome }}"
- name: Notify on failure
if: failure()
run: |
echo "RustFS replication suite failed"
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
echo "See the uploaded report and log artifacts for details."
+37 -6
View File
@@ -320,21 +320,52 @@ jobs:
- name: "Continue functional chain (next: KMS)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
set -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: KMS"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-kms' \
-F 'client_payload[from_suite]=s3'
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-kms' \
-F 'client_payload[from_suite]=s3'; then
echo "dispatched next suite KMS (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch KMS after 3 attempts" >&2
TITLE="[functional][chain] stalled after s3 (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **s3** to **KMS** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-kms'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-kms'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure
if: failure()
+19 -1
View File
@@ -47,7 +47,7 @@ on:
type: boolean
default: true
repository_dispatch:
# Chain handoff: dispatched when the pool expansion suite finishes (last link).
# Chain handoff: dispatched when the pool expansion suite finishes.
types: [rustfs-chain-security]
permissions:
@@ -292,6 +292,24 @@ jobs:
'
done
- name: "Continue functional chain (next: Replication)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: Replication"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-replication' \
-F 'client_payload[from_suite]=security'
- name: Notify on failure
if: failure()
run: |
+37 -6
View File
@@ -335,21 +335,52 @@ jobs:
- name: "Continue functional chain (next: Heal)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
set -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: Heal"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-heal' \
-F 'client_payload[from_suite]=storage'
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-heal' \
-F 'client_payload[from_suite]=storage'; then
echo "dispatched next suite Heal (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch Heal after 3 attempts" >&2
TITLE="[functional][chain] stalled after storage (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **storage** to **Heal** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-heal'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-heal'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure
if: failure()
+173 -56
View File
@@ -11,13 +11,20 @@ on:
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
required: false
type: string
package_sha256:
description: 'Optional SHA-256 for package_url; mismatch is an infrastructure failure.'
rc_archive_url:
description: 'Exact RustFS CLI Linux archive URL.'
required: false
default: 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz'
type: string
rc_archive_sha256:
description: 'Expected SHA-256 of the RustFS CLI archive.'
required: false
default: 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df'
type: string
rc_sha256:
description: 'Optional SHA-256 for the preinstalled rc binary; mismatch is an infrastructure failure.'
description: 'Expected SHA-256 of the extracted RustFS CLI binary.'
required: false
default: '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df'
type: string
force_case_failure:
description: 'Diagnostic only: rewrite single-single/TIER-101 to FAIL after execution to verify artifact and final-gate behavior.'
@@ -45,7 +52,9 @@ env:
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || vars.RUSTFS_TIER_RC_SHA256 }}
RUSTFS_RC_ARCHIVE_URL: ${{ inputs.rc_archive_url || 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz' }}
RUSTFS_RC_ARCHIVE_SHA256: ${{ inputs.rc_archive_sha256 || 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df' }}
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df' }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
TIER_ARTIFACTS_DIR: /tmp/rustfs-tier-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
@@ -75,20 +84,13 @@ jobs:
- name: Checkout auto-testing scripts (with retry)
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
AUTO_TESTING_REF: cxymds/fix-2132-tier-log-isolation
AUTO_TESTING_COMMIT: 02da54dd62110649dc2860fc5fcd9e08d2e9a1ca
run: |
set -euo pipefail
rm -rf auto-testing
for attempt in 1 2 3 4 5; do
if gh repo clone rustfs/auto-testing auto-testing -- \
--branch "${AUTO_TESTING_REF}" --single-branch --depth 1 --quiet; then
actual_commit="$(git -C auto-testing rev-parse HEAD)"
if [[ "${actual_commit}" == "${AUTO_TESTING_COMMIT}" ]]; then
echo "auto-testing ${actual_commit} cloned (attempt ${attempt})"
exit 0
fi
echo "auto-testing commit mismatch: expected ${AUTO_TESTING_COMMIT}, got ${actual_commit}" >&2
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
echo "auto-testing cloned (attempt ${attempt})"
exit 0
fi
rm -rf auto-testing
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
@@ -97,38 +99,110 @@ jobs:
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
exit 1
- name: Download exact rc candidate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
repository: rustfs/rustfs-release-validation
run-id: '33465191972'
name: rc-under-test-33465191972-1
path: ${{ runner.temp }}/issue-2128-rc
github-token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Verify exact rc candidate
env:
RC_BIN: ${{ runner.temp }}/issue-2128-rc/rc
RC_PROVENANCE: ${{ runner.temp }}/issue-2128-rc/rc-build.json
RC_EXPECTED_COMMIT: f6b9b509a60ef172a2b037d638c2cac46e762129
RC_EXPECTED_SHA256: 3d128d99f05403f4028c7c9ae24b03d66a3e98f2090e66cb7f9f45a9e11fdce1
- name: Prepare pinned RustFS CLI
id: rc
run: |
set -euo pipefail
test -s "${RC_BIN}"
test -s "${RC_PROVENANCE}"
jq -e \
--arg commit "${RC_EXPECTED_COMMIT}" \
--arg digest "${RC_EXPECTED_SHA256}" \
'.repository == "rustfs/cli"
and .requestedCommit == $commit
and .resolvedCommit == $commit
and .binarySha256 == $digest
and .target == "x86_64-unknown-linux-gnu"' \
"${RC_PROVENANCE}" >/dev/null
actual_sha256="$(sha256sum -- "${RC_BIN}" | awk '{print $1}')"
test "${actual_sha256}" = "${RC_EXPECTED_SHA256}"
chmod 0555 "${RC_BIN}"
"${RC_BIN}" --version
umask 077
case "${RUSTFS_RC_ARCHIVE_URL}" in
https://*) ;;
*)
echo "RustFS CLI archive URL must use HTTPS" >&2
exit 1
;;
esac
EXPECTED_ARCHIVE_SHA256="$(printf '%s' "${RUSTFS_RC_ARCHIVE_SHA256}" | tr '[:upper:]' '[:lower:]')"
EXPECTED_RC_SHA256="$(printf '%s' "${RUSTFS_EXPECTED_RC_SHA256}" | tr '[:upper:]' '[:lower:]')"
if ! [[ "${EXPECTED_ARCHIVE_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
echo "invalid RustFS CLI archive SHA-256" >&2
exit 1
fi
if ! [[ "${EXPECTED_RC_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
echo "invalid RustFS CLI binary SHA-256" >&2
exit 1
fi
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
RC_ARCHIVE="${RC_ROOT}/rustfs-cli.tar.gz"
RC_BIN="${RC_ROOT}/rc"
if ! mkdir -- "${RC_ROOT}"; then
echo "refusing to reuse RustFS CLI directory: ${RC_ROOT}" >&2
exit 1
fi
curl --fail --location --retry 3 --retry-all-errors \
--connect-timeout 15 --max-time 180 \
--proto '=https' --proto-redir '=https' \
--output "${RC_ARCHIVE}" "${RUSTFS_RC_ARCHIVE_URL}"
RC_ARCHIVE_SIZE="$(wc -c < "${RC_ARCHIVE}" | tr -d '[:space:]')"
if [ "${RC_ARCHIVE_SIZE}" -eq 0 ] || [ "${RC_ARCHIVE_SIZE}" -gt 33554432 ]; then
echo "RustFS CLI archive size is outside the accepted range: ${RC_ARCHIVE_SIZE}" >&2
exit 1
fi
if ! ACTUAL_ARCHIVE_SHA256="$(openssl dgst -sha256 -r "${RC_ARCHIVE}" | awk '{print $1}')"; then
echo "failed to calculate RustFS CLI archive SHA-256" >&2
exit 1
fi
if [ "${ACTUAL_ARCHIVE_SHA256}" != "${EXPECTED_ARCHIVE_SHA256}" ]; then
echo "RustFS CLI archive SHA-256 mismatch: expected ${EXPECTED_ARCHIVE_SHA256}, got ${ACTUAL_ARCHIVE_SHA256}" >&2
exit 1
fi
ARCHIVE_MEMBERS="$(tar -tzf "${RC_ARCHIVE}")"
if ! grep -Fxq 'rc' <<< "${ARCHIVE_MEMBERS}"; then
echo "RustFS CLI archive does not contain the rc entry" >&2
exit 1
fi
if ! tar -xOzf "${RC_ARCHIVE}" rc > "${RC_BIN}"; then
echo "failed to extract the RustFS CLI binary" >&2
exit 1
fi
chmod 0700 "${RC_BIN}"
if ! ACTUAL_RC_SHA256="$(openssl dgst -sha256 -r "${RC_BIN}" | awk '{print $1}')"; then
echo "failed to calculate RustFS CLI binary SHA-256" >&2
exit 1
fi
if [ "${ACTUAL_RC_SHA256}" != "${EXPECTED_RC_SHA256}" ]; then
echo "RustFS CLI binary SHA-256 mismatch: expected ${EXPECTED_RC_SHA256}, got ${ACTUAL_RC_SHA256}" >&2
exit 1
fi
if ! RC_VERSION_OUTPUT="$(timeout 30 "${RC_BIN}" --version 2>&1)"; then
echo "failed to execute the pinned RustFS CLI" >&2
exit 1
fi
RC_VERSION="${RC_VERSION_OUTPUT%%$'\n'*}"
if [ -z "${RC_VERSION}" ]; then
echo "pinned RustFS CLI returned an empty version" >&2
exit 1
fi
jq -n \
--arg schema_version '1' \
--arg generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg archive_url "${RUSTFS_RC_ARCHIVE_URL}" \
--arg archive_sha256 "${ACTUAL_ARCHIVE_SHA256}" \
--arg archive_size "${RC_ARCHIVE_SIZE}" \
--arg path "${RC_BIN}" \
--arg version "${RC_VERSION}" \
--arg sha256 "${ACTUAL_RC_SHA256}" \
'{
schema_version: ($schema_version | tonumber),
generated_at: $generated_at,
archive: {
url: $archive_url,
sha256: $archive_sha256,
size: ($archive_size | tonumber)
},
binary: {
path: $path,
version: $version,
sha256: $sha256
}
}' > "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json"
printf 'path=%s\n' "${RC_BIN}" >> "${GITHUB_OUTPUT}"
echo "RustFS CLI ready: ${RC_VERSION} (${ACTUAL_RC_SHA256})"
- name: Show environment
run: |
@@ -190,15 +264,13 @@ jobs:
continue-on-error: true
env:
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
PACKAGE_SHA256_INPUT: ${{ inputs.package_sha256 }}
RC_BIN: ${{ steps.rc.outputs.path }}
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
run: |
set -euo pipefail
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
chmod +x auto-testing/rustfs-tier-test.sh
RC_BIN="${RUNNER_TEMP}/issue-2128-rc/rc"
PACKAGE_URL="${PACKAGE_URL_INPUT}"
PACKAGE_SHA256="${PACKAGE_SHA256_INPUT}"
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
ARGS=(
--all-topologies
@@ -210,9 +282,6 @@ jobs:
if [ -n "${RUSTFS_EXPECTED_RC_SHA256}" ]; then
ARGS+=(--expected-rc-sha256 "${RUSTFS_EXPECTED_RC_SHA256}")
fi
if [ -n "${PACKAGE_SHA256}" ]; then
ARGS+=(--sha256 "${PACKAGE_SHA256}")
fi
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ]; then
@@ -257,6 +326,11 @@ jobs:
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
if RC_BOOTSTRAP_SUMMARY="$(jq -r '.binary | "\(.version) / \(.sha256)"' "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json" 2>/dev/null)"; then
:
else
RC_BOOTSTRAP_SUMMARY="missing or invalid"
fi
set +e
python3 auto-testing/rustfs_tier_report.py \
--results-dir "${TIER_ARTIFACTS_DIR}/cases" \
@@ -278,6 +352,7 @@ jobs:
echo "- Run: ${RUN_URL}"
echo "- Trigger: ${TRIGGER_NAME}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- Client bootstrap: ${RC_BOOTSTRAP_SUMMARY}"
echo "- Test Step Outcome: ${TEST_OUTCOME}"
echo "- Structured Gate Exit: ${CASE_GATE_RC}"
echo ""
@@ -328,6 +403,7 @@ jobs:
rustfs-tier-report.md \
rustfs-tier-cases.md \
rustfs-tier-gate.rc \
rc-bootstrap.json \
provenance.json; do
if [ ! -s "${TIER_ARTIFACTS_DIR}/${name}" ]; then
echo "required tier evidence is missing or empty: ${name}" >&2
@@ -375,6 +451,16 @@ jobs:
'
done
- name: Cleanup pinned RustFS CLI
if: always()
run: |
set -euo pipefail
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
rm -f -- "${RC_ROOT}/rustfs-cli.tar.gz" "${RC_ROOT}/rc"
if [ -d "${RC_ROOT}" ]; then
rmdir -- "${RC_ROOT}"
fi
- name: Enforce tier suite result
id: gate
if: always()
@@ -479,21 +565,52 @@ jobs:
- name: "Continue functional chain (next: Storage engine)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
set -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: Storage engine"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-storage' \
-F 'client_payload[from_suite]=tier'
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-storage' \
-F 'client_payload[from_suite]=tier'; then
echo "dispatched next suite Storage engine (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch Storage engine after 3 attempts" >&2
TITLE="[functional][chain] stalled after tier (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **tier** to **Storage engine** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-storage'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-storage'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure
if: failure()
+64 -9
View File
@@ -18,15 +18,15 @@ on:
workflow_dispatch:
inputs:
from_version:
description: 'OLD RustFS release tag (e.g. 1.0.0-rc.4-preview.1)'
description: 'OLD RustFS release tag, e.g. 1.0.0-rc.3 (its release must ship a .deb asset). Leave empty for the default.'
required: false
default: '1.0.0-rc.4-preview.1'
default: '1.0.0-rc.3'
from_url:
description: 'OLD .deb URL. Overrides from_version.'
required: false
type: string
to_version:
description: 'NEW RustFS release tag (leave empty for latest nightly)'
description: 'NEW RustFS release tag, e.g. 1.0.0-rc.5 (any version with a .deb asset). Leave empty for latest nightly.'
required: false
to_url:
description: 'NEW .deb URL. Overrides to_version / nightly default.'
@@ -145,6 +145,7 @@ jobs:
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-upgrade.log
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-upgrade-test.sh
@@ -175,6 +176,29 @@ jobs:
else
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
# Fail fast with a clear message when a requested release tag has
# no .deb asset (e.g. 1.0.0-rc.4 ships only zips), instead of
# letting the suite die mid-run on a 404.
check_release_asset() {
local version="$1" tag asset url
[ -n "${version}" ] && [ "${version}" != "null" ] || return 0
tag="${version#v}"
asset="rustfs_${tag//-/.}_amd64.deb"
url="https://github.com/rustfs/rustfs/releases/download/${tag}/${asset}"
if ! gh api "repos/rustfs/rustfs/releases/tags/${tag}" --jq '.assets[].name' 2>/dev/null | grep -qxF "${asset}"; then
echo "ERROR: release ${tag} has no downloadable asset ${asset}:" >&2
echo " ${url}" >&2
echo "Pick a tag whose release ships a .deb (check its release assets)." >&2
exit 1
fi
echo "resolved ${tag} -> ${url}"
}
if [ -z "${FROM_URL}" ]; then
check_release_asset "${FROM_VERSION}"
fi
if [ -z "${TO_URL}" ]; then
check_release_asset "${TO_VERSION}"
fi
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
- name: Generate report
@@ -388,21 +412,52 @@ jobs:
- name: "Continue functional chain (next: S3 compatibility)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
# workflow_dispatch runs stop after their own cleanup. A failed
# handoff must never pass silently: it retries, then files an alert
# issue in rustfs/backlog so a stalled chain is visible.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
set -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: S3 compatibility"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-s3' \
-F 'client_payload[from_suite]=upgrade'
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-s3' \
-F 'client_payload[from_suite]=upgrade'; then
echo "dispatched next suite S3 compatibility (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch S3 compatibility after 3 attempts" >&2
TITLE="[functional][chain] stalled after upgrade (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
{
echo "The functional chain could not hand off from **upgrade** to **S3 compatibility** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-s3'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-s3'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure
if: failure()
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
- uses: overtrue/repo-visuals-action@fd79cba437ecfac933d00a69add17eb95d3939c3 # v1.3.1
with:
github-token: ${{ github.token }}
output-branch: star-history
+4 -3
View File
@@ -55,11 +55,10 @@ docs/*
!docs/architecture/**
!docs/operations/
!docs/operations/**
!docs/postmortems/
!docs/postmortems/**
!docs/testing/
!docs/testing/**
docs/heal-scanner-logging-governance.md
docs/benchmark/rustfs-target-bench/
docs/benchmark/*.md
.codegraph/*
.docker/test/compat/data/*
.docker/test/compat/kms/*
@@ -83,6 +82,8 @@ worktrees/*
# Local AI-agent review artifacts (omo evidence dumps)
.omo/
# Legacy per-tool skill dir; skills live in .agents/skills (shared by all agents)
.mimocode/
# insta scratch files; the accepted .snap files ARE the assertions and are committed
*.snap.new
+7
View File
@@ -86,6 +86,7 @@ This file contains repository-wide rules. Use the nearest subdirectory
- CI gates: `.github/workflows/ci.yml`.
- PR format: `.github/pull_request_template.md`.
- Architecture routing: `ARCHITECTURE.md` and `docs/architecture/README.md`.
- Knowledge-base index and documentation rules: `docs/architecture/README.md`.
- Agent skills: `.agents/skills/*/SKILL.md`.
Do not commit one-shot plans, trackers, migration ledgers, benchmark snapshots,
@@ -161,6 +162,12 @@ Risk and review shape:
S3-visible semantics. Cover all applicable lenses using exactly two
independent reviewers when delegation is explicitly authorized. Split the
lenses between them. Otherwise perform two fresh sequential passes.
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
the remote SDK configuration sends to every replication or migration target
is high risk for every target class even when the change fixes one. Follow
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
run the outbound target matrix, document each new env knob in the same PR,
and list verified and unverified target classes in the PR Impact section.
Available domain lenses are security, concurrency/durability, compatibility,
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
+3 -3
View File
@@ -62,7 +62,7 @@ rustfs/ # Workspace root (virtual manifest)
│ ├── utils/ # Pure utility functions
│ ├── ... # (see "Crate Reference" below)
│ └── e2e_test/ # End-to-end integration tests
└── docs/ # Design documents and analysis
└── docs/ # Agent knowledge base: contracts, runbooks, testing rules (index: docs/architecture/README.md)
```
### Main Crate Layers (`rustfs/src/`)
@@ -92,7 +92,7 @@ refactors.
| Domain | Current workspace crates | Responsibility |
|--------|--------------------------|----------------|
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. |
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-metrics`, `utils` | Shared configuration, data-usage models, heal domain contracts, scanner telemetry types, utilities, and checksums. |
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
@@ -135,7 +135,7 @@ default build (lifecycle:
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
collision, not copies; renaming is tracked in rustfs/backlog#1847.
- `LastMinuteLatency` has two deliberately different implementations: the
per-second bucketed accumulator in `crates/scanner-contracts/src/last_minute.rs` and
per-second bucketed accumulator in `crates/scanner-metrics/src/last_minute.rs` and
the in-memory endpoint-health sample tracker in
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
why it stays local).
+7
View File
@@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
### Added
- **On-Demand Migration**: Lazy, pull-style migration of an existing S3-compatible bucket into RustFS. A local bucket is attached to an external source bucket; a GET for a key that does not exist locally fetches it from the source, streams it to the client, and stores it locally in the same pass, so every later read is served locally. The module is on by default; set `RUSTFS_ON_DEMAND_MIGRATION_ENABLED=false` on every node to turn it off. A bucket with no source configured behaves exactly as before — the runtime never intervenes on its reads and makes no outbound call. Operator guide at `docs/operations/on-demand-migration.md`.
- Per-bucket configuration persisted as `on-demand-migration.json` in the bucket metadata: source provider (`s3`, `aws`, `minio`, `rustfs`, `r2`, `gcs`), endpoint, region, addressing style, credentials and TLS material, an optional key-prefix filter and source-prefix rewrite, and a policy block covering the inline size threshold, multipart part size, concurrency, queue capacity, timeouts, bandwidth limit and negative-cache TTL
- Admin routes under `/rustfs/admin/v3/on-demand-migration/{bucket}`: `PUT` (with `?dry-run=true` to validate and probe the source without saving), `GET`, `DELETE`, `GET .../status`, plus `POST .../backfill?op=start|cancel` and `GET .../backfill` for the background full-backfill job with its resumable checkpoint. Authorized by the new `admin:GetBucketOnDemandMigration` and `admin:SetBucketOnDemandMigration` actions; every response redacts `secret_key` and `session_token`
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
- Limitations: listings show only local objects (the source is not merged into `ListObjectsV2`); PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
+1
View File
@@ -27,6 +27,7 @@ make build-docker BUILD_OS=ubuntu22.04
## 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)
Generated
+156 -146
View File
@@ -125,13 +125,28 @@ version = "2.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
[[package]]
name = "alloc-no-stdlib"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a"
[[package]]
name = "alloc-stdlib"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
dependencies = [
"alloc-no-stdlib",
"alloc-no-stdlib 2.0.4",
]
[[package]]
name = "alloc-stdlib"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1"
dependencies = [
"alloc-no-stdlib 3.0.0",
]
[[package]]
@@ -347,9 +362,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "arrow"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61d285d16bce7d0be61912f7928342b673067b6b7d7ef6cc179258ba7de1fecf"
checksum = "7c14b3d39f306bc28fd639d59f06e17a0f377d0021e1b7e9054e4d6fedc98774"
dependencies = [
"arrow-arith",
"arrow-array",
@@ -368,9 +383,9 @@ dependencies = [
[[package]]
name = "arrow-arith"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "757ef1836251e88222542a7da2623bc1c9cb9e20afefa6db2c41e79991cd91d4"
checksum = "ce2961626677665b2195eb59242af4c7befe7b8737ca2050295389362380104e"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -382,9 +397,9 @@ dependencies = [
[[package]]
name = "arrow-array"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc9a4a4b2b5ecd0e04df03471661cb61f28bed3c7fd50994715129b01b2edb97"
checksum = "1e5f6adeffdf587d7a31db5d2266189624b526730cd3627f9ff9fedae97ad584"
dependencies = [
"ahash",
"arrow-buffer",
@@ -401,9 +416,9 @@ dependencies = [
[[package]]
name = "arrow-buffer"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c12b576ef18c1deb80925a248b25ad84f419198d791b8e293fc6aaa60441fe90"
checksum = "097d193003ce7995d5d087089069ec2a6e0187faf5a6f8c9f38af2645d987182"
dependencies = [
"bytes",
"half",
@@ -413,9 +428,9 @@ dependencies = [
[[package]]
name = "arrow-cast"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68338a9096a5dc9bc11927c58c43a8526d96bf6abd2012ef6c0c9f505991cc79"
checksum = "635c9c635668ad26adf76cce8fb276c4be7cf06e63bd516de7da514f9680ee53"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -435,9 +450,9 @@ dependencies = [
[[package]]
name = "arrow-csv"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25011b52b346407d497ef0030e12b45e4f2d0cc279efc09c4f3d09106db30e36"
checksum = "4c2ebf8d631e79b02c16cf5ae860561272c26024ec88fce389a56aaddd558e86"
dependencies = [
"arrow-array",
"arrow-cast",
@@ -450,9 +465,9 @@ dependencies = [
[[package]]
name = "arrow-data"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "723fe4aeed7604e00b9883a465af4ff0a0e6c44c03e41a68c3d1cbc403e0e44d"
checksum = "9ba2f832eaeca24b8f26143dba750e42ee4ab51cf7d65e701ca9607cfda9f358"
dependencies = [
"arrow-buffer",
"arrow-schema",
@@ -463,9 +478,9 @@ dependencies = [
[[package]]
name = "arrow-ipc"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "149437b14371f5b9ec60f5ddc751483ae99d7a7072653c0075e5e469156eea7b"
checksum = "dcc41681ea80f521df14c36725b74d4c60702c47f0793af2be469c04527e2599"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -479,9 +494,9 @@ dependencies = [
[[package]]
name = "arrow-json"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f18b9123ccfec418a663f821c9a034af339711678c11ffe00d3ec07da5ff9f7e"
checksum = "a2f57d7a81969f24ccf80809587b76c09897e6f829d2d65a5976bfb3218851f1"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -504,9 +519,9 @@ dependencies = [
[[package]]
name = "arrow-ord"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c08dff0686cf23ca4f562803f191ccbeb726dbae6309cd4b4aaf65e0f2c979"
checksum = "2c900759f3bd8354fd4196bc4403eee846894dc2adf66b4225472006a0bf18c5"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -517,9 +532,9 @@ dependencies = [
[[package]]
name = "arrow-row"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbec439386df71ad570e6758a946111322b9e9dc8db83b5527321f0b4c9119c2"
checksum = "f4c6425032e28266e3fc4ff680805e57e670d6ea92473043f3e65b7ed6ac79f2"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -530,9 +545,9 @@ dependencies = [
[[package]]
name = "arrow-schema"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6fed2ca0d1eade57e811cbe73b98ad50cc08a1183e13b2d2aa43a7df593f40e"
checksum = "10fab8d4563491417ba801fab29d205104d20d4bdf37bda6cd1cf425cff598cd"
dependencies = [
"serde_core",
"serde_json",
@@ -540,9 +555,9 @@ dependencies = [
[[package]]
name = "arrow-select"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "466b19cf75130b891dc1b23a84b343c714c62c64c9c62e365c76aa0ff90a53fb"
checksum = "fc58569193c2525915f3cc6310edba3792f1200f65d6e9ed330aa33e691493b8"
dependencies = [
"ahash",
"arrow-array",
@@ -554,9 +569,9 @@ dependencies = [
[[package]]
name = "arrow-string"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c838a25bb3691e919e0f617616ac51a4ff8517a952e29ca133cf0c22b2ce65b1"
checksum = "2e0813f3c35c1cfea65e14c20a953440f7783c088b7ad2d0db162ccdeefcec14"
dependencies = [
"arrow-array",
"arrow-buffer",
@@ -667,9 +682,9 @@ dependencies = [
[[package]]
name = "async-compression"
version = "0.4.43"
version = "0.4.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8"
checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844"
dependencies = [
"compression-codecs",
"compression-core",
@@ -919,9 +934,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.18.0"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -930,9 +945,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.44.0"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
dependencies = [
"cc",
"cmake",
@@ -1024,7 +1039,7 @@ dependencies = [
"http 0.2.12",
"http 1.5.0",
"http-body 1.1.0",
"lru 0.18.3",
"lru 0.18.4",
"percent-encoding",
"regex-lite",
"sha2 0.11.0",
@@ -1313,9 +1328,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime-api"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9"
checksum = "9c054752dd9e4dc73d0b75748c99ac2d0feafbf2f25c7b0516f03a3534161223"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api-macros",
@@ -1353,9 +1368,9 @@ dependencies = [
[[package]]
name = "aws-smithy-types"
version = "1.6.2"
version = "1.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
checksum = "8f94d16e797ec62cd999fc9d5942b48fa7050c3093ddadff48e4d7528d16fcb9"
dependencies = [
"base64-simd",
"bytes",
@@ -1728,9 +1743,20 @@ version = "8.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"brotli-decompressor",
"alloc-no-stdlib 2.0.4",
"alloc-stdlib 0.2.4",
"brotli-decompressor 5.0.3",
]
[[package]]
name = "brotli"
version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b"
dependencies = [
"alloc-no-stdlib 3.0.0",
"alloc-stdlib 0.3.0",
"brotli-decompressor 6.0.0",
]
[[package]]
@@ -1739,8 +1765,18 @@ version = "5.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
"alloc-no-stdlib 2.0.4",
"alloc-stdlib 0.2.4",
]
[[package]]
name = "brotli-decompressor"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46087801e40b785410c352bee60e9b6226312709cf52d5fff92776cdf5cf6490"
dependencies = [
"alloc-no-stdlib 3.0.0",
"alloc-stdlib 0.3.0",
]
[[package]]
@@ -2153,11 +2189,11 @@ dependencies = [
[[package]]
name = "compression-codecs"
version = "0.4.38"
version = "0.4.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf"
checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df"
dependencies = [
"brotli",
"brotli 8.0.4",
"bzip2",
"compression-core",
"flate2",
@@ -2170,9 +2206,9 @@ dependencies = [
[[package]]
name = "compression-core"
version = "0.4.32"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
[[package]]
name = "concurrent-queue"
@@ -5067,9 +5103,9 @@ dependencies = [
[[package]]
name = "hickory-net"
version = "0.26.1"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183"
checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1"
dependencies = [
"async-trait",
"cfg-if",
@@ -5091,9 +5127,9 @@ dependencies = [
[[package]]
name = "hickory-proto"
version = "0.26.1"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643"
checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d"
dependencies = [
"data-encoding",
"idna",
@@ -5111,9 +5147,9 @@ dependencies = [
[[package]]
name = "hickory-resolver"
version = "0.26.1"
version = "0.26.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c"
checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f"
dependencies = [
"cfg-if",
"futures-util",
@@ -5190,9 +5226,9 @@ dependencies = [
[[package]]
name = "hotpath"
version = "0.24.0"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2645642a23d4061ec15a4a6e74f851a3145c3125356846cfc7772ff9c6f2737"
checksum = "2ec7782e005cabd5eaf350febde384cd799faa3a0e624587aa8759c240e0b592"
dependencies = [
"arc-swap",
"async-channel",
@@ -5204,7 +5240,6 @@ dependencies = [
"futures-util",
"hdrhistogram",
"hotpath-macros",
"hotpath-meta",
"http 1.5.0",
"libc",
"object 0.36.7",
@@ -5224,30 +5259,15 @@ dependencies = [
[[package]]
name = "hotpath-macros"
version = "0.24.0"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a3d3cdf9b0d4d3d4f6d4a29798f3b9170401ba500eaa58dd8f890f926af0f1"
checksum = "929b2285d2cd21b2733a7fb6ebc843bb4f83dbd1db0122f5f9ebb9567b1e2613"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "hotpath-macros-meta"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e84cd2417fa60938241cf1cd6c03e09953f5c821122dc5da9b8f27975d136c5b"
[[package]]
name = "hotpath-meta"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d2c145b67b1a4e7bcefa918995e212c30a49a85f05cc5962fe1f717878d560b"
dependencies = [
"hotpath-macros-meta",
]
[[package]]
name = "htmlescape"
version = "0.3.1"
@@ -5606,18 +5626,6 @@ dependencies = [
"tempfile",
]
[[package]]
name = "internal-russh-num-bigint"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae8e22120c32fb4d19ec55fba35015f57095cd95a2e3b732e44457f5915b2ee8"
dependencies = [
"num-integer",
"num-traits",
"rand 0.10.2",
"rand_core 0.10.1",
]
[[package]]
name = "io-uring"
version = "0.7.14"
@@ -5868,17 +5876,20 @@ dependencies = [
[[package]]
name = "kafka-protocol"
version = "0.17.0"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66292444a1cd4d430d450d472c30cba839d0724229aba2d79affffcf901516e2"
checksum = "099d5c2f1b40cd830cbf18ca4d2a0805f2875b811ca18f0372b2433e68fe2dda"
dependencies = [
"anyhow",
"bytes",
"crc",
"crc32c",
"flate2",
"indexmap 2.14.1",
"paste",
"lz4",
"snap",
"uuid",
"zstd",
]
[[package]]
@@ -6106,9 +6117,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libredox"
version = "0.1.21"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96"
checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed"
dependencies = [
"libc",
]
@@ -6212,9 +6223,9 @@ dependencies = [
[[package]]
name = "lru"
version = "0.18.3"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c"
checksum = "ff9840bcc50b71349309900da0ce7279aa336ae71d73250b07998932c7d97c25"
dependencies = [
"hashbrown 0.17.1",
]
@@ -6554,9 +6565,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.2.2"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
@@ -6663,7 +6674,7 @@ dependencies = [
"futures-sink",
"futures-util",
"keyed_priority_queue",
"lru 0.18.3",
"lru 0.18.4",
"mysql_common",
"percent-encoding",
"rand 0.10.2",
@@ -6887,6 +6898,8 @@ checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
"rand 0.10.2",
"rand_core 0.10.1",
]
[[package]]
@@ -7014,7 +7027,7 @@ version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
dependencies = [
"base64 0.21.7",
"base64 0.22.1",
"chrono",
"getrandom 0.2.17",
"http 1.5.0",
@@ -7479,9 +7492,9 @@ dependencies = [
[[package]]
name = "pageant"
version = "0.2.2"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3adadc44070da6f464b0918655a12f5792c156e088d8c4082d13e27d94c3e791"
checksum = "6d8eab09a361a4afe0b1668be978cd97e4f052e927a92b0b608cf902965d49ce"
dependencies = [
"base16ct 1.0.0",
"byteorder",
@@ -7528,9 +7541,9 @@ dependencies = [
[[package]]
name = "parquet"
version = "59.2.0"
version = "59.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7065842956a20c2a536924ce8e4d9955f7422451511b9eb7500d7bfe5077e59c"
checksum = "ff322f54b1a0f9288e614ed1f2d329b380af5476420db19f46ffb865e1163d73"
dependencies = [
"ahash",
"arrow-array",
@@ -7540,7 +7553,7 @@ dependencies = [
"arrow-schema",
"arrow-select",
"base64 0.23.1",
"brotli",
"brotli 8.0.4",
"bytes",
"chrono",
"flate2",
@@ -7570,12 +7583,6 @@ dependencies = [
"phc",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "path-absolutize"
version = "4.0.1"
@@ -8241,7 +8248,7 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
dependencies = [
"heck 0.4.1",
"heck 0.5.0",
"itertools 0.14.0",
"log",
"multimap",
@@ -8261,7 +8268,7 @@ version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
dependencies = [
"heck 0.4.1",
"heck 0.5.0",
"itertools 0.14.0",
"log",
"multimap",
@@ -9214,9 +9221,9 @@ dependencies = [
[[package]]
name = "russh"
version = "0.63.1"
version = "0.63.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bab1b87d915817d5d9cc352637cd40d5f0b298a48c6309af9156a4addc3031"
checksum = "8e134e2480f4e86f83e4aa45b4c0a9723f84beaffa694c54bdf056e74efdd7dd"
dependencies = [
"aes 0.9.3",
"aws-lc-rs",
@@ -9245,13 +9252,12 @@ dependencies = [
"hex-literal",
"hmac 0.13.0",
"inout 0.2.2",
"internal-russh-num-bigint",
"keccak",
"log",
"md5",
"ml-kem",
"module-lattice",
"num-bigint 0.4.8",
"num-bigint 0.5.1",
"p256 0.14.0",
"p384 0.14.0",
"p521",
@@ -9481,7 +9487,7 @@ dependencies = [
"rustfs-s3select-api",
"rustfs-s3select-query",
"rustfs-scanner",
"rustfs-scanner-contracts",
"rustfs-scanner-metrics",
"rustfs-security-governance",
"rustfs-signer",
"rustfs-storage-api",
@@ -9727,7 +9733,7 @@ dependencies = [
"rustfs-rio-v2",
"rustfs-s3-client",
"rustfs-s3-types",
"rustfs-scanner-contracts",
"rustfs-scanner-metrics",
"rustfs-storage-api",
"rustfs-uring",
"rustfs-utils",
@@ -9942,11 +9948,11 @@ dependencies = [
[[package]]
name = "rustfs-kafka"
version = "1.2.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4eee0644a99743fb2f51db7fbae1a6ca2d85f064daf7595784eaa52834a68c96"
checksum = "759f8ddf709b497006e0f422a89890f11ac86c84c2c39df282891477c22f1a13"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"bytes",
"fnv",
"hmac 0.13.0",
@@ -9956,22 +9962,22 @@ dependencies = [
"pbkdf2 0.13.0",
"rand 0.10.2",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"socket2",
"thiserror 2.0.20",
"tracing",
"twox-hash",
"uuid",
"webpki-roots 1.0.9",
]
[[package]]
name = "rustfs-kafka-async"
version = "1.2.0"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd1997c3116cb94ede80d9a0b828f46dd27386cc93825fce141a92eb3aa9630"
checksum = "5aa574a13d91ef529e3a08f059a5c3ee8d4c3b16cf62f62c72595a6dc4f79f8f"
dependencies = [
"base64 0.22.1",
"base64 0.23.1",
"bytes",
"hmac 0.13.0",
"kafka-protocol",
@@ -9980,11 +9986,11 @@ dependencies = [
"rand 0.10.2",
"rustfs-kafka",
"rustls",
"rustls-native-certs",
"sha2 0.11.0",
"tokio",
"tokio-rustls",
"tracing",
"uuid",
"webpki-roots 1.0.9",
]
@@ -10083,7 +10089,7 @@ dependencies = [
"proptest",
"rustfs-config",
"rustfs-replication",
"rustfs-scanner-contracts",
"rustfs-scanner-metrics",
"rustfs-storage-api",
"s3s",
"serial_test",
@@ -10091,7 +10097,6 @@ dependencies = [
"time",
"tokio",
"tracing",
"url",
"uuid",
]
@@ -10159,18 +10164,18 @@ dependencies = [
[[package]]
name = "rustfs-mimalloc"
version = "0.5.1"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed388b8a3d55818c32973ded41df5215d33ee0f574d9ca03f2731b3007c3284b"
checksum = "46a7b69356718defa4060de3059e609c9da6619c35924fefef55d902ccd94958"
dependencies = [
"rustfs-mimalloc-sys",
]
[[package]]
name = "rustfs-mimalloc-sys"
version = "0.5.1"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c507265df958f6b63f8ccdd21819374186bab5dbdcc3f9c5cf8bff4b2e3f5d0"
checksum = "1dd3ec9b7e7b9fed453acd8b4e32713ddc0e01e1aee31a3deec5b8025c0880c3"
dependencies = [
"cc",
]
@@ -10282,7 +10287,7 @@ dependencies = [
"rustfs-iam",
"rustfs-io-metrics",
"rustfs-notify",
"rustfs-scanner-contracts",
"rustfs-scanner-metrics",
"rustfs-security-governance",
"rustfs-storage-api",
"rustfs-utils",
@@ -10450,6 +10455,7 @@ dependencies = [
"base64-simd",
"bytes",
"crc-fast",
"criterion",
"faster-hex",
"futures",
"hex-simd",
@@ -10461,6 +10467,7 @@ dependencies = [
"md-5 0.11.0",
"minlz",
"pin-project-lite",
"proptest",
"rand 0.10.2",
"reqwest",
"rustfs-config",
@@ -10637,15 +10644,17 @@ dependencies = [
"rand 0.10.2",
"rmp-serde",
"rustfs-common",
"rustfs-concurrency",
"rustfs-config",
"rustfs-credentials",
"rustfs-data-usage",
"rustfs-ecstore",
"rustfs-filemeta",
"rustfs-heal-contracts",
"rustfs-lifecycle",
"rustfs-lock",
"rustfs-s3-types",
"rustfs-scanner-contracts",
"rustfs-scanner-metrics",
"rustfs-storage-api",
"rustfs-utils",
"s3s",
@@ -10665,7 +10674,7 @@ dependencies = [
]
[[package]]
name = "rustfs-scanner-contracts"
name = "rustfs-scanner-metrics"
version = "1.0.0-rc.5"
dependencies = [
"chrono",
@@ -10854,7 +10863,7 @@ version = "1.0.0-rc.5"
dependencies = [
"base64-simd",
"blake2",
"brotli",
"brotli 9.0.0",
"bytes",
"convert_case 0.12.0",
"crc-fast",
@@ -11084,7 +11093,7 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "s3s"
version = "0.15.0"
source = "git+https://github.com/rustfs/s3s.git?rev=28e9ebb23dd2fb7d667084f34121b4aa4807a5c6#28e9ebb23dd2fb7d667084f34121b4aa4807a5c6"
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
dependencies = [
"arc-swap",
"arrayvec",
@@ -11142,7 +11151,7 @@ dependencies = [
[[package]]
name = "s3s-rfc2047"
version = "0.16.0-alpha.1"
source = "git+https://github.com/rustfs/s3s.git?rev=28e9ebb23dd2fb7d667084f34121b4aa4807a5c6#28e9ebb23dd2fb7d667084f34121b4aa4807a5c6"
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
dependencies = [
"base64-simd",
"thiserror 2.0.20",
@@ -11151,7 +11160,7 @@ dependencies = [
[[package]]
name = "s3s-sigv2"
version = "0.16.0-alpha.1"
source = "git+https://github.com/rustfs/s3s.git?rev=28e9ebb23dd2fb7d667084f34121b4aa4807a5c6#28e9ebb23dd2fb7d667084f34121b4aa4807a5c6"
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
dependencies = [
"base64-simd",
"hmac 0.13.0",
@@ -11164,7 +11173,7 @@ dependencies = [
[[package]]
name = "s3s-sigv4"
version = "0.16.0-alpha.1"
source = "git+https://github.com/rustfs/s3s.git?rev=28e9ebb23dd2fb7d667084f34121b4aa4807a5c6#28e9ebb23dd2fb7d667084f34121b4aa4807a5c6"
source = "git+https://github.com/rustfs/s3s.git?rev=bdcb6259339c41369f9f1c60e3a42b5ab8da607b#bdcb6259339c41369f9f1c60e3a42b5ab8da607b"
dependencies = [
"arrayvec",
"base64-simd",
@@ -11787,9 +11796,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.15.2"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f"
dependencies = [
"serde",
]
@@ -12446,9 +12455,9 @@ dependencies = [
[[package]]
name = "tinyvec"
version = "1.12.0"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
dependencies = [
"tinyvec_macros",
]
@@ -13613,13 +13622,14 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "wnaf"
version = "0.14.0"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1"
checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa"
dependencies = [
"ff 0.14.0",
"group 0.14.0",
"hybrid-array",
"primefield",
]
[[package]]
+13 -13
View File
@@ -52,7 +52,7 @@ members = [
"crates/s3select-api", # S3 Select API interface
"crates/s3select-query", # S3 Select query engine
"crates/scanner", # Scanner for data integrity checks and health monitoring
"crates/scanner-contracts", # Scanner metrics and cycle contracts
"crates/scanner-metrics", # Scanner metrics and cycle telemetry
"crates/security-governance", # Security governance contracts
"crates/extension-schema", # Extension schema contracts
"crates/signer", # client signer
@@ -93,7 +93,7 @@ redundant_clone = "warn"
rustfs = { path = "./rustfs", version = "1.0.0-rc.5" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.5" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.5" }
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.5" }
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.0-rc.5" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.5" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.5" }
rustfs-common = { path = "crates/common", version = "1.0.0-rc.5" }
@@ -145,7 +145,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" }
mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.43" }
async-compression = { version = "0.4.44" }
async-recursion = "1.1.1"
async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false }
@@ -165,7 +165,7 @@ http-body = "1.1.0"
http-body-util = "0.1.5"
minlz = "1.2.3"
reqwest = "0.13.4"
rustfs-kafka-async = { version = "1.2.0" }
rustfs-kafka-async = { version = "1.3.1" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.53.1" }
tokio-rustls = { default-features = false, version = "0.26.4" }
@@ -244,10 +244,10 @@ aws-sdk-kms = { default-features = false, version = "1.117.0" }
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
aws-sdk-sts = { default-features = false, version = "1.113.0" }
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
aws-smithy-runtime-api = { version = "1.15.0" }
aws-smithy-types = { version = "1.6.2" }
aws-smithy-runtime-api = { version = "1.16.0" }
aws-smithy-types = { version = "1.6.3" }
base64-simd = "0.8.0"
brotli = "8.0.4"
brotli = "9.0.0"
clap = { version = "4.6.6" }
const-str = { version = "1.1.0" }
convert_case = "0.12.0"
@@ -298,7 +298,7 @@ pretty_assertions = "1.4.1"
rand = { version = "0.10.2" }
ratelimit = "2.0.0"
rayon = "1.12.0"
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
rustfs-erasure-codec = { version = "8.0.2" }
reed-solomon-simd = "3.1.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
@@ -307,11 +307,11 @@ rustify = { version = "0.7", default-features = false }
rustix = { version = "1.1.4" }
rust-embed = { version = "8.12.0" }
rustc-hash = { version = "2.1.3" }
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "28e9ebb23dd2fb7d667084f34121b4aa4807a5c6", version = "0.15.0", features = ["minio"] }
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "bdcb6259339c41369f9f1c60e3a42b5ab8da607b", version = "0.15.0", features = ["minio"] }
serial_test = "4.0.1"
shadow-rs = { default-features = false, version = "2.0.0" }
siphasher = "1.0.3"
smallvec = { version = "1.15.2" }
smallvec = { version = "1.16.0" }
compact_str = "0.10.0"
snap = "1.1.2"
starshard = { version = "2.3.0" }
@@ -359,15 +359,15 @@ libunftp = { version = "0.23.0" }
unftp-core = "0.1.0"
suppaftp = { version = "11.0.0" }
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.63.1" }
russh = { version = "0.63.2" }
russh-sftp = "2.4.0"
# WebDAV
dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.1" }
hotpath = { version = "0.24.0", default-features = false }
rustfs-mimalloc = { version = "0.5.3" }
hotpath = { version = "0.25.0", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.48" }
+27 -10
View File
@@ -48,16 +48,33 @@ Unlike other storage systems, RustFS is released under the permissible Apache 2.
- **Open Source**: Licensed under Apache 2.0, encouraging unrestricted community contributions and commercial usage.
- **User-Friendly**: Designed with simplicity in mind for easy deployment and management.
| Feature | Status | Feature | Status |
| :---------------------- | :----------- | :----------------------- | :--------------- |
| **S3 Core Features** | ✅ Available | **Bitrot Protection** | ✅ Available |
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
| **Versioning** | ✅ Available | **Bucket Replication** | ✅ Available |
| **Logging** | ✅ Available | **Lifecycle Management** | 🚧 Under Testing |
| **Event Notifications** | ✅ Available | **Distributed Mode** | 🚧 Under Testing |
| **K8s Helm Charts** | ✅ Available | **RustFS KMS** | 🚧 Under Testing |
| **Keystone Auth** | ✅ Available | **Multi-Tenancy** | ✅ Available |
| **Swift API** | ✅ Available | **Swift Metadata Ops** | 🚧 Partial |
Status legend: ✅ Available — shipped and covered by CI gates; 🧪 Preview — shipped behind an opt-in flag or with a bounded compatibility claim.
| Feature | Status | Feature | Status |
| :------------------------------- | :----------- | :--------------------------------- | :----------- |
| **S3 Core Features** | ✅ Available | **Distributed Mode** | ✅ Available |
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
| **Versioning** | ✅ Available | **Bitrot Protection** | ✅ Available |
| **Object Lock (WORM)** | ✅ Available | **Healing & Scanner** | ✅ Available |
| **Server-Side Encryption** | ✅ Available | **Pool Expansion / Decommission** | ✅ Available |
| **RustFS KMS** | ✅ Available | **Bucket Replication** | ✅ Available |
| **Lifecycle Management (ILM)** | ✅ Available | **Site Replication** | ✅ Available |
| **ILM Tiering (Remote S3)** | ✅ Available | **Bucket Quota** | ✅ Available |
| **S3 Select** | ✅ Available | **Event Notifications** | ✅ Available |
| **S3 Tables (Iceberg REST)** | 🧪 Preview | **Audit Logging** | ✅ Available |
| **IAM / Policies** | ✅ Available | **Logging & Observability** | ✅ Available |
| **OIDC / SSO** | ✅ Available | **Web Console** | ✅ Available |
| **Keystone Auth** | ✅ Available | **K8s Helm Charts** | ✅ Available |
| **Swift API** | ✅ Available | **FTPS / WebDAV** | ✅ Available |
| **Multi-Tenancy** | ✅ Available | **SFTP** | ✅ Available |
| **MinIO On-Disk Compatibility** | 🧪 Preview | | |
Notes:
- **RustFS KMS**: Vault (KV2 / Transit) and AWS KMS backends are supported for production. The `Local` and `Static` backends are for development and testing only. See [KMS backend security properties](docs/operations/kms-backend-security.md).
- **Swift API / SFTP**: opt-in cargo features (`--features swift`, `--features sftp`, or `full`). FTPS and WebDAV are enabled in the default build.
- **S3 Tables**: ships as an Iceberg REST Catalog with automated PyIceberg and DuckDB coverage; other engines and vendor profiles carry bounded claims listed in the [S3 Tables support matrix](docs/architecture/s3-tables-support-matrix.md).
- **MinIO On-Disk Compatibility**: gated behind the `rio-v2` feature and not part of the default build. Objects MinIO encrypted are not readable by RustFS. See [MinIO file-format interoperability](docs/architecture/minio-file-format-compat.md).
## RustFS vs MinIO Performance
+27
View File
@@ -197,6 +197,27 @@ pub const DEFAULT_POOL_META_V3_FLEET_CONFIRMED: bool = false;
const _: () = assert!(!DEFAULT_POOL_META_V3_WRITE);
const _: () = assert!(!DEFAULT_POOL_META_V3_FLEET_CONFIRMED);
/// Maximum unpacked size accepted for one Snowball archive member.
///
/// The value is expressed in bytes. Invalid values use the default, while
/// valid values are clamped to [`MAX_SNOWBALL_ENTRY_BYTES`].
pub const ENV_SNOWBALL_MAX_ENTRY_BYTES: &str = "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES";
pub const DEFAULT_SNOWBALL_MAX_ENTRY_BYTES: u64 = 1024 * 1024 * 1024;
pub const MAX_SNOWBALL_ENTRY_BYTES: u64 = 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
/// Maximum cumulative unpacked object bytes accepted from one Snowball
/// archive request.
///
/// This does not include tar headers or bounded PAX metadata. The value is
/// expressed in bytes and is clamped to
/// [`MAX_SNOWBALL_UNPACKED_BYTES`].
pub const ENV_SNOWBALL_MAX_UNPACKED_BYTES: &str = "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES";
pub const DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES: u64 = 10 * 1024 * 1024 * 1024;
pub const MAX_SNOWBALL_UNPACKED_BYTES: u64 = 10 * 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
const _: () = assert!(DEFAULT_SNOWBALL_MAX_ENTRY_BYTES <= MAX_SNOWBALL_ENTRY_BYTES);
const _: () = assert!(DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES <= MAX_SNOWBALL_UNPACKED_BYTES);
// =============================================================================
// Concurrent Request Fix - Timeout and Backpressure Configuration
// =============================================================================
@@ -820,4 +841,10 @@ mod remote_version_state_tests {
assert_eq!(super::ENV_POOL_META_V3_WRITE, "RUSTFS_POOL_META_V3_WRITE");
assert_eq!(super::ENV_POOL_META_V3_FLEET_CONFIRMED, "RUSTFS_POOL_META_V3_FLEET_CONFIRMED");
}
#[test]
fn snowball_limit_environment_names_are_stable() {
assert_eq!(super::ENV_SNOWBALL_MAX_ENTRY_BYTES, "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES");
assert_eq!(super::ENV_SNOWBALL_MAX_UNPACKED_BYTES, "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES");
}
}
+8 -8
View File
@@ -25,7 +25,7 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
@@ -233,8 +233,8 @@ spawn error. Install the pinned CI version before running their profiles.
[`src/policy/README.md`](src/policy/README.md),
[`src/protocols/README.md`](src/protocols/README.md),
[`src/reliant/README.md`](src/reliant/README.md)
- Authoritative per-module counts:
[`docs/testing/e2e-suite-inventory.md`](../../docs/testing/e2e-suite-inventory.md)
- Per-module counts: `cargo nextest list -p e2e_test --profile <profile>`
(one-liner in [`docs/testing/README.md`](../../docs/testing/README.md))
- Test pyramid & flake policy: [`docs/testing/README.md`](../../docs/testing/README.md)
## CI smoke subset (`--profile e2e-smoke`)
@@ -271,12 +271,12 @@ Note on `#[serial]`: nextest runs each test in its own process, so
parallel-safe by construction (random port + isolated temp dir), which the
current subset is.
### Authoritative test inventory
### Test inventory
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
moving e2e tests so acceptance numbers in the test-strategy issues
(backlog#1147#1155) stay auditable. When a profile membership change is
Per-module counts are not committed; list them with
`cargo nextest list -p e2e_test --profile <profile>` (the result is
platform-dependent because some modules are linux-only; the `jq` one-liner is
in `docs/testing/README.md`). When a profile membership change is
intentional, review its JSON listing before updating the matching
`.config/e2e-*-selection.txt` test-ID digest. Update only the platform that
produced the listing:
+331 -1
View File
@@ -22,7 +22,10 @@ mod tests {
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
use aws_sdk_s3::types::{
ChecksumAlgorithm, ChecksumMode, ChecksumType as SdkChecksumType, CompletedMultipartUpload, CompletedPart,
ServerSideEncryption,
};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use md5::{Digest as Md5Digest, Md5};
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
@@ -260,6 +263,117 @@ mod tests {
info!("PASSED: HeadObject returns stored SHA256 digest");
}
#[tokio::test]
async fn test_head_object_returns_sse_s3_checksum() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_SSE_S3_MASTER_KEY", "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="),
("RUSTFS_CONSOLE_ENABLE", "false"),
],
)
.await
.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-sse-s3-checksum-head";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
let put = client
.put_object()
.bucket(bucket)
.key("encrypted.txt")
.body(ByteStream::from_static(b"encrypted checksum"))
.server_side_encryption(ServerSideEncryption::Aes256)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("SSE-S3 PutObject with CRC32 failed");
let expected = put.checksum_crc32().expect("PutObject must return CRC32");
let head = client
.head_object()
.bucket(bucket)
.key("encrypted.txt")
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("SSE-S3 HeadObject failed");
assert_eq!(head.checksum_crc32(), Some(expected));
client
.copy_object()
.bucket(bucket)
.key("encrypted-copy.txt")
.copy_source(format!("{bucket}/encrypted.txt"))
.server_side_encryption(ServerSideEncryption::Aes256)
.send()
.await
.expect("SSE-S3 CopyObject failed");
let copy_head = client
.head_object()
.bucket(bucket)
.key("encrypted-copy.txt")
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("SSE-S3 copied HeadObject failed");
assert_eq!(copy_head.checksum_crc32(), Some(expected));
let multipart_key = "encrypted-multipart.txt";
let create = client
.create_multipart_upload()
.bucket(bucket)
.key(multipart_key)
.server_side_encryption(ServerSideEncryption::Aes256)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("SSE-S3 CreateMultipartUpload with CRC32 failed");
let upload_id = create.upload_id().expect("CreateMultipartUpload must return an upload ID");
let part = client
.upload_part()
.bucket(bucket)
.key(multipart_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(b"encrypted multipart checksum"))
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("SSE-S3 UploadPart with CRC32 failed");
let completed_part = CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().expect("UploadPart must return an ETag"))
.checksum_crc32(part.checksum_crc32().expect("UploadPart must return CRC32"))
.build();
let complete = client
.complete_multipart_upload()
.bucket(bucket)
.key(multipart_key)
.upload_id(upload_id)
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
.send()
.await
.expect("SSE-S3 CompleteMultipartUpload with CRC32 failed");
let expected_multipart = complete.checksum_crc32().expect("CompleteMultipartUpload must return CRC32");
let multipart_head = client
.head_object()
.bucket(bucket)
.key(multipart_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("SSE-S3 multipart HeadObject failed");
assert_eq!(multipart_head.checksum_crc32(), Some(expected_multipart));
}
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
/// Uses part size >= 5MB (server minimum) for two parts.
#[tokio::test]
@@ -485,6 +599,222 @@ mod tests {
Some(full_checksum.as_str()),
"Multipart object should report the same full-object CRC64NVME as direct upload"
);
assert_eq!(
multipart_head.checksum_type(),
Some(&SdkChecksumType::FullObject),
"Multipart object with a full-object checksum must report FULL_OBJECT"
);
}
/// Create a CRC32 FULL_OBJECT multipart upload and upload every part, returning
/// the upload id and the `CompletedPart` list ready for CompleteMultipartUpload.
async fn start_full_object_crc32_upload(
client: &Client,
bucket: &str,
key: &str,
parts: &[&Vec<u8>],
) -> (String, Vec<CompletedPart>) {
let create_result = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.checksum_type(SdkChecksumType::FullObject)
.send()
.await
.expect("Failed to create multipart upload");
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
let mut completed_parts = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part_number = index as i32 + 1;
let uploaded = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from((*part).clone()))
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.unwrap_or_else(|e| panic!("Failed to upload part {part_number}: {e:?}"));
completed_parts.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(uploaded.e_tag().expect("No etag for part"))
.checksum_crc32(uploaded.checksum_crc32().expect("No CRC32 for part"))
.build(),
);
}
(upload_id, completed_parts)
}
/// A multipart upload completed with a **full-object** checksum must report
/// `x-amz-checksum-type: FULL_OBJECT` on both GET and HEAD, the way AWS does.
///
/// `complete_multipart_upload` used to persist the object-level checksum
/// record with the pre-merge checksum type, so the MULTIPART /
/// INCLUDES_MULTIPART flags never reached disk. `rustfs_rio::read_checksums`
/// only emits the FULL_OBJECT entry inside its MULTIPART branch, so these
/// objects came back from GET and HEAD with no checksum-type header at all.
/// Found while root-causing rustfs#6825.
#[tokio::test]
async fn test_full_object_multipart_reports_full_object_checksum_type() {
init_logging();
info!("TEST: full-object multipart upload round-trips x-amz-checksum-type: FULL_OBJECT");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-full-object-checksum-type";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
const PART_SIZE: usize = 5 * 1024 * 1024;
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 241) as u8).collect();
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 29) % 241) as u8).collect();
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
// CRC32 with an explicit FULL_OBJECT type: the object checksum is the
// CRC32 of the whole object, not the composite hash of the part digests.
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
.expect("crc32 checksum")
.encoded;
let key = "full-object-multipart.bin";
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
// Restate the full-object intent and value on CompleteMultipartUpload,
// exactly as an AWS SDK client does: `x-amz-checksum-type: FULL_OBJECT`
// plus `x-amz-checksum-crc32`, with no `x-amz-checksum-algorithm`
// header (CompleteMultipartUpload has no such member).
.checksum_type(SdkChecksumType::FullObject)
.checksum_crc32(full_object_crc32.clone())
.send()
.await
.expect("Failed to complete multipart upload");
let head = client
.head_object()
.bucket(bucket)
.key(key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("Failed to head object");
assert_eq!(
head.checksum_type(),
Some(&SdkChecksumType::FullObject),
"HeadObject must report x-amz-checksum-type: FULL_OBJECT"
);
assert_eq!(
head.checksum_crc32(),
Some(full_object_crc32.as_str()),
"HeadObject must report the full-object CRC32, with no -<parts> suffix"
);
let get = client
.get_object()
.bucket(bucket)
.key(key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("Failed to get object");
assert_eq!(
get.checksum_type(),
Some(&SdkChecksumType::FullObject),
"GetObject must report x-amz-checksum-type: FULL_OBJECT"
);
assert_eq!(
get.checksum_crc32(),
Some(full_object_crc32.as_str()),
"GetObject must report the full-object CRC32, with no -<parts> suffix"
);
let body = get.body.collect().await.expect("Failed to read body").into_bytes();
assert_eq!(body.as_ref(), content.as_slice(), "GetObject body must match the uploaded content");
info!("PASSED: full-object multipart reports FULL_OBJECT on GET and HEAD");
}
/// Declaring a checksum type on CompleteMultipartUpload that contradicts the
/// one recorded at CreateMultipartUpload must be rejected, and rejected as a
/// client error (4xx), not a server error.
#[tokio::test]
async fn test_complete_multipart_rejects_contradicting_checksum_type() {
init_logging();
info!("TEST: CompleteMultipartUpload rejects a checksum type that contradicts the upload");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-checksum-type-mismatch";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
const PART_SIZE: usize = 5 * 1024 * 1024;
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 239) as u8).collect();
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 31) % 239) as u8).collect();
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
.expect("crc32 checksum")
.encoded;
let key = "checksum-type-mismatch.bin";
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
let err = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
// The upload was created as FULL_OBJECT; claiming COMPOSITE here
// contradicts it.
.checksum_type(SdkChecksumType::Composite)
.checksum_crc32(full_object_crc32.clone())
.send()
.await
.expect_err("COMPOSITE on a FULL_OBJECT upload must be rejected");
let service_err = err.into_service_error();
let code = service_err.meta().code().unwrap_or("<no code>").to_string();
let message = service_err.meta().message().unwrap_or_default().to_string();
// Before the fix the storage layer refused the combination with a generic
// error and the caller got `500 InternalError` -- "please try again" for a
// request that can only ever fail.
assert_eq!(
code, "InvalidRequest",
"a contradicting checksum type is a client error, got {code}: {message}"
);
assert!(
message.contains("FULL_OBJECT") && message.contains("COMPOSITE"),
"the message must name the recorded and requested types, got {message}"
);
// The upload is untouched by the rejected completion, so a well-formed
// retry on the same upload id still succeeds.
let listed = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.send()
.await
.expect("upload must survive the rejected completion");
assert_eq!(listed.parts().len(), 2, "both parts must still be listed after the rejection");
info!("PASSED: contradicting checksum type rejected as InvalidRequest");
}
/// Integration test for the AWS 2026-04 additional checksum algorithms
+45
View File
@@ -1699,6 +1699,51 @@ impl RustFSTestClusterEnvironment {
process.wait()?;
Ok(())
}
/// Gracefully stop one cluster node and wait for its process to exit.
///
/// This is intentionally separate from [`Self::stop_node`]: the latter is
/// a hard kill used by crash-recovery tests, while this path lets RustFS
/// complete its normal shutdown hooks before a test restarts the node.
pub async fn stop_node_gracefully(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.ensure_node_index(node_idx)?;
#[cfg(unix)]
{
let Some(process) = self.nodes[node_idx].process.as_ref() else {
return Ok(());
};
let pid = process.id().to_string();
let signal_status = Command::new("kill").args(["-TERM", &pid]).status()?;
if !signal_status.success() {
return Err(format!("failed to send SIGTERM to cluster node {node_idx} (pid {pid})").into());
}
let mut process = self.nodes[node_idx]
.process
.take()
.ok_or_else(|| format!("cluster node {node_idx} process disappeared while stopping"))?;
let deadline = std::time::Instant::now() + Duration::from_secs(45);
loop {
if let Some(status) = process.try_wait()? {
info!("Cluster node {} stopped gracefully with {}", node_idx, status);
return Ok(());
}
if std::time::Instant::now() >= deadline {
let _ = process.kill();
let _ = process.wait();
return Err(format!("cluster node {node_idx} did not stop gracefully within 45 seconds").into());
}
sleep(Duration::from_millis(100)).await;
}
}
#[cfg(not(unix))]
{
let _ = node_idx;
Err("graceful cluster-node stop is only supported on Unix E2E hosts".into())
}
}
}
impl Drop for RustFSTestClusterEnvironment {
+10 -4
View File
@@ -1,11 +1,17 @@
# Programmable fake S3 target
This module is the shared failure-injection boundary for replication end-to-end tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
This module is the shared failure-injection boundary for replication end-to-end tests and the programmable external source for on-demand-migration (ODM) tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
`FakeS3Target::start()` creates the listener. Add target buckets with `create_bucket`, point a RustFS remote target at `address()`, use `FAKE_ACCESS_KEY` / `FAKE_SECRET_KEY`, then enqueue per-operation faults with `inject`. Faults for one operation are consumed in FIFO order and do not consume faults queued for another operation. A fault is consumed only after `s3s` verifies the full request signature, so anonymous, other-access-key, and bad-signature traffic cannot disturb a script.
Supported data operations are HeadBucket, GetBucketVersioning, 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 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.
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.
Fault actions cover HTTP 401/403/503 responses, pre-dispatch delay, connection abort when a logical request-body threshold is reached, streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions. Each record also journals a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
`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`.
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type at 1 KiB. A PUT or uploaded part is capped at 64 MiB; a completed multipart object and all stored object/part data are capped at 128 MiB. Body drain, body-permit waits, delay, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
`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.
ListObjectsV2 lists current versions only (a key whose newest version is a delete marker is hidden) in byte order and supports `prefix`, `delimiter`, `max-keys` (clamped to 1000), `start-after`, and `continuation-token`; common prefixes count toward `max-keys`, `IsTruncated` / `NextContinuationToken` / `KeyCount` follow S3, and continuation tokens are opaque. `encoding-type` and `fetch-owner` are accepted but ignored, and ListObjects (v1) is not implemented. GET and HEAD honor `Range` in the `bytes=first-last`, `bytes=first-`, and `bytes=-suffix` forms with a 206 status, exact `Content-Range`, and `Accept-Ranges: bytes`; unsatisfiable ranges answer 416 `InvalidRange` with `Content-Range: bytes */<length>`. PUT and CreateMultipartUpload accept `Content-Type`, `Content-Encoding`, `Content-Disposition`, `Content-Language`, `Cache-Control`, `Expires`, and `x-amz-meta-*` (names stored lowercased), and HEAD/GET replay them verbatim together with `Last-Modified` and the ETag (hex MD5 for single PUTs, `<md5-of-part-md5s>-<parts>` for multipart objects). `put_seed_object` stores an object directly, bypassing the wire, the fault script, and the journal, so a source can be seeded without polluting the assertions a scenario later makes.
Fault actions cover HTTP 401/403/503 responses (`Status`), any 4xx/5xx status paired with the matching S3 error code (`ResponseStatus`), pre-dispatch delay, holding a fully computed successful response before its first byte (`Stall`), connection abort when a logical request-body threshold is reached, GetObject bodies cut off after N bytes while `Content-Length` announces the full size (`TruncateBodyAt`), GetObject bodies delivered in fixed slices with a pause between them (`SlowSendBody`, a mid-body stall rather than a first-byte one), streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions and `count_requests(operation, key)` counts entries for one exact key. Each record journals the `Range` and `User-Agent` request headers, the ListObjectsV2 `prefix` and `continuation-token` query values, a `TransportSnapshot` — whether the body was announced as `aws-chunked`, the verbatim `Content-MD5`, the sorted `x-amz-checksum-*` / `x-amz-sdk-checksum-algorithm` header names, and whether any `x-amz-object-lock-*` header was present — and a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type and each standard object header at 1 KiB. By default a PUT or uploaded part is capped at 64 MiB and a completed multipart object and all stored object/part data are capped at 128 MiB; `FakeS3Target::start_with_options(FakeS3TargetOptions { max_object_bytes })` raises the object cap up to 256 MiB, and the total budget then becomes twice the object cap (never below 128 MiB). Body drain, body-permit waits, delay, stall, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
File diff suppressed because it is too large Load Diff
@@ -17,15 +17,115 @@
#[cfg(test)]
mod tests {
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging};
use crate::common::{
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
};
use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use std::collections::HashSet;
use std::error::Error;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::net::TcpStream;
use tokio::time::{Duration, Instant, sleep, timeout};
use tracing::info;
const POOL_METADATA_OBJECT: &str = "pool.bin";
struct TcpPortBlackhole {
port: u16,
comment: String,
use_sudo: bool,
active: bool,
}
impl TcpPortBlackhole {
fn install(address: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
let address = address.parse::<SocketAddr>()?;
if !address.ip().is_loopback() {
return Err(format!("refusing to install a test firewall rule for non-loopback address {address}").into());
}
let id = Command::new("id").arg("-u").output()?;
if !id.status.success() {
return Err(format!("failed to determine the test process uid: {}", String::from_utf8_lossy(&id.stderr)).into());
}
let use_sudo = String::from_utf8_lossy(&id.stdout).trim() != "0";
let mut blackhole = Self {
port: address.port(),
comment: format!("rustfs-e2e-{}", uuid::Uuid::new_v4()),
use_sudo,
active: false,
};
blackhole.run_iptables(true)?;
blackhole.active = true;
Ok(blackhole)
}
fn restore(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
if !self.active {
return Ok(());
}
self.run_iptables(false)?;
self.active = false;
Ok(())
}
fn run_iptables(&self, insert: bool) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut command = if self.use_sudo {
let mut command = Command::new("sudo");
command.args(["-n", "iptables"]);
command
} else {
Command::new("iptables")
};
command.args(["-w", "5"]);
if insert {
command.args(["-I", "OUTPUT", "1"]);
} else {
command.args(["-D", "OUTPUT"]);
}
let port = self.port.to_string();
let output = command
.args([
"-p",
"tcp",
"-d",
"127.0.0.1/32",
"--dport",
&port,
"-m",
"comment",
"--comment",
&self.comment,
"-j",
"DROP",
])
.output()?;
if !output.status.success() {
let action = if insert { "install" } else { "remove" };
return Err(format!(
"failed to {action} endpoint blackhole rule for port {}: stdout={}, stderr={}",
self.port,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into());
}
Ok(())
}
}
impl Drop for TcpPortBlackhole {
fn drop(&mut self) {
if let Err(error) = self.restore() {
eprintln!("failed to remove {} firewall rule during test cleanup: {error}", self.comment);
}
}
}
fn has_file_under(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(path) else {
return false;
@@ -91,6 +191,62 @@ mod tests {
.count()
}
async fn assert_all_nodes_list_exact_keys(
clients: &[aws_sdk_s3::Client],
bucket: &str,
expected_keys: &HashSet<String>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
const PAGE_SIZE: i32 = 10;
for (node_index, client) in clients.iter().enumerate() {
let mut listed_keys = Vec::new();
let mut continuation_token = None;
let max_pages = expected_keys.len().div_ceil(PAGE_SIZE as usize) + 1;
let mut page_count = 0;
loop {
page_count += 1;
if page_count > max_pages {
return Err(format!("node {node_index} listing exceeded the bounded {max_pages}-page budget").into());
}
let response = timeout(
Duration::from_secs(15),
client
.list_objects_v2()
.bucket(bucket)
.max_keys(PAGE_SIZE)
.set_continuation_token(continuation_token.clone())
.send(),
)
.await??;
listed_keys.extend(
response
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_owned)),
);
if !response.is_truncated().unwrap_or(false) {
break;
}
let next_token = response
.next_continuation_token()
.filter(|token| Some(*token) != continuation_token.as_deref())
.ok_or_else(|| format!("node {node_index} returned a truncated listing without a new continuation token"))?;
continuation_token = Some(next_token.to_owned());
}
let listed_key_set = listed_keys.iter().cloned().collect::<HashSet<_>>();
assert_eq!(
listed_keys.len(),
listed_key_set.len(),
"node {node_index} returned duplicate keys after recovery: {listed_keys:?}"
);
assert_eq!(
&listed_key_set, expected_keys,
"node {node_index} did not expose the complete recovered namespace"
);
}
Ok(())
}
fn heal_task_status_diagnostic(body: &str) -> String {
let Ok(status) = serde_json::from_str::<serde_json::Value>(body) else {
return body.to_string();
@@ -125,11 +281,12 @@ mod tests {
&& operations["retryingTasks"].as_u64() == Some(0)
}
// Queued low-priority repairs cannot execute while the single admin slot is
// occupied; ownership is determined by active and retrying tasks only.
fn only_admin_heal_is_active(status: &serde_json::Value) -> bool {
let operations = &status["healOperations"];
status["clusterStatusComplete"] == serde_json::Value::Bool(true)
&& status["state"].as_str() == Some("active")
&& operations["queueLength"].as_u64() == Some(0)
&& operations["activeTasks"].as_u64() == Some(1)
&& operations["retryingTasks"].as_u64() == Some(0)
&& operations["activeBySource"]["admin"].as_u64() == Some(1)
@@ -547,41 +704,156 @@ mod tests {
.into())
}
async fn wait_for_scanner_cycle_after(
cluster: &RustFSTestClusterEnvironment,
previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let mut latest_cycle_end = 0;
let mut versions_observed = false;
let mut observations = Vec::with_capacity(cluster.nodes.len());
for (node_index, node) in cluster.nodes.iter().enumerate() {
let (status, body) = timeout(
Duration::from_secs(5),
admin_request(
&node.url,
Method::GET,
"/rustfs/admin/v3/scanner/status",
None,
&cluster.access_key,
&cluster.secret_key,
),
)
.await??;
assert_eq!(status, 200, "scanner status must be available: {body}");
let status: serde_json::Value = serde_json::from_str(&body)?;
assert_eq!(status["enabled"].as_bool(), Some(true), "scanner must stay enabled: {status}");
let metrics = &status["metrics"];
let cycle_end = metrics["last_cycle_end_unix_secs"]
.as_u64()
.ok_or("scanner status is missing its completed-cycle timestamp")?;
let versions_scanned = metrics["versions_scanned"]
.as_u64()
.ok_or("scanner status is missing its version-coverage counter")?;
latest_cycle_end = latest_cycle_end.max(cycle_end);
versions_observed |= versions_scanned > 0;
observations.push(format!(
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
metrics["current_cycle"],
metrics["current_cycle_active"],
metrics["leader_lock_state"],
metrics["last_cycle_result"],
));
}
// The coordinator records cycle completion, but remote workers
// record scanned versions. Both witnesses need not share a node.
if latest_cycle_end > previous_cycle_end && versions_observed {
return Ok(latest_cycle_end);
}
if Instant::now() >= deadline {
return Err(format!(
"enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
)
.into());
}
sleep(Duration::from_millis(250)).await;
}
}
// Keep the original unformatted-disk scenario above. This case retains the
// format identity so only the explicit admin task can rebuild missing data.
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_resumes_missing_remote_shards_after_node_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{
run_cluster_root_heal_interruption(InterruptionScenario::IsolatedTargetRestart).await
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
)
.await?
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{
timeout(
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
)
.await?
}
#[cfg(target_os = "linux")]
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_after_target_endpoint_blackhole() -> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::TargetEndpointBlackhole),
)
.await?
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum InterruptionScenario {
IsolatedTargetRestart,
BackgroundTargetRestart,
BackgroundCoordinatorRestart,
TargetEndpointBlackhole,
}
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
let (background_enabled, interruption_node, interruption_kind) = match scenario {
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
};
init_logging();
info!(
event = "heal_restart_started",
event = "heal_interruption_started",
component = "e2e_test",
subsystem = "heal",
"Starting root-heal restart test"
background_enabled,
interruption_node,
interruption_kind,
"Starting root-heal interruption test"
);
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
cluster.set_env("RUSTFS_HEAL_AUTO_HEAL_ENABLE", "false");
cluster.set_env("RUSTFS_HEAL_MRF_ENABLE", "false");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_HEALS", "1");
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_PER_SET", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
// Keep all storage nodes' Heal runtimes enabled so their disk services
// complete normal registration after restart. Scanner, auto-heal and
// MRF are disabled; the pre-root idle barrier below drains the direct
// outage-object repair before the explicit admin task starts.
// Heal control uses the first lexicographically sorted grid host.
// Keep that coordinator distinct from the remote target at index 1.
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
cluster.set_env("RUSTFS_HEAL_AUTO_HEAL_ENABLE", background_enabled.to_string());
cluster.set_env("RUSTFS_HEAL_MRF_ENABLE", background_enabled.to_string());
cluster.set_env("RUSTFS_SCANNER_ENABLED", background_enabled.to_string());
if background_enabled {
// Only the scanner cadence is accelerated. Keep normal Heal
// concurrency and every automatic recovery owner enabled.
for &(key, value) in FAST_DATA_USAGE_SCANNER_ENV {
cluster.set_env(key, value);
}
} else {
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_HEALS", "1");
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_PER_SET", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
}
// Keep every node's Heal runtime enabled for normal disk registration.
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
cluster.set_env("RUST_LOG", server_rust_log);
if let Ok(log_dir) = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR") {
std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
}
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
}
cluster.start().await?;
let clients = cluster.create_all_clients()?;
@@ -634,6 +906,18 @@ mod tests {
});
}
let expected_pool_metadata = if background_enabled {
let census = census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
assert!(
census.is_complete(),
"target must hold complete pool metadata before the fault: {census:?}"
);
wait_for_scanner_cycle_after(&cluster, 0).await?;
Some(census)
} else {
None
};
cluster.stop_node(1)?;
std::fs::remove_dir_all(&replaced_disk)?;
std::fs::create_dir_all(
@@ -691,25 +975,25 @@ mod tests {
.find(|index| !outage_peer_erasure_indices.contains(index))
.ok_or("online outage-object shards leave no erasure index for the replacement target")?;
// The PUT path may have admitted a direct Internal object repair while
// node 1 was offline. Cancel the isolated bucket path before the target
// returns; otherwise it could rebuild the outage object and invalidate
// the explicit-root ownership assertion below.
let cancel_outage_heal_path = format!("/rustfs/admin/v3/heal/{bucket}?forceStop=true");
let (cancel_status, cancel_body) = admin_request(
&cluster.nodes[0].url,
Method::POST,
&cancel_outage_heal_path,
Some(
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#
.to_string(),
),
&cluster.access_key,
&cluster.secret_key,
)
.await?;
if !cancel_status.is_success() {
return Err(format!("cancel outage heal failed: {cancel_status} {cancel_body}").into());
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
if !background_enabled {
// The PUT path may have admitted a direct Internal object repair while
// node 1 was offline. Cancel the isolated bucket path before the target
// returns; otherwise it could rebuild the outage object and invalidate
// the explicit-root ownership assertion below.
let cancel_outage_heal_path = format!("/rustfs/admin/v3/heal/{bucket}?forceStop=true");
let (cancel_status, cancel_body) = admin_request(
&cluster.nodes[0].url,
Method::POST,
&cancel_outage_heal_path,
Some(heal_body.to_string()),
&cluster.access_key,
&cluster.secret_key,
)
.await?;
if !cancel_status.is_success() {
return Err(format!("cancel outage heal failed: {cancel_status} {cancel_body}").into());
}
}
cluster.start_node(1).await?;
@@ -724,7 +1008,12 @@ mod tests {
);
let recovered: serde_json::Value = serde_json::from_str(&status_body)
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
if cluster_heal_is_idle(&recovered) {
let ready = if background_enabled {
recovered["clusterStatusComplete"] == serde_json::Value::Bool(true)
} else {
cluster_heal_is_idle(&recovered)
};
if ready {
break;
}
if Instant::now() >= recovery_deadline {
@@ -732,23 +1021,24 @@ mod tests {
}
sleep(Duration::from_millis(250)).await;
}
assert_eq!(
matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?,
0,
"non-admin Heal is disabled, so the replacement target must remain empty before the explicit root heal"
);
assert!(
!census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?.has_xl_meta,
"the object written during the outage must be absent before the explicit root heal"
);
let pre_heal_replacement = replacement_recovery_status(&cluster).await?;
assert_eq!(
pre_heal_replacement["cluster"]["records"].as_array().map(Vec::len),
Some(0),
"isolated target must not retain an automatic replacement generation: {pre_heal_replacement}"
);
if !background_enabled {
assert_eq!(
matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?,
0,
"non-admin Heal is disabled, so the replacement target must remain empty before the explicit root heal"
);
assert!(
!census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?.has_xl_meta,
"the object written during the outage must be absent before the explicit root heal"
);
assert_eq!(
pre_heal_replacement["cluster"]["records"].as_array().map(Vec::len),
Some(0),
"isolated target must not retain an automatic replacement generation: {pre_heal_replacement}"
);
}
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", cluster.nodes[0].url);
let heal_start_body = signed_admin_post(&heal_url, Some(heal_body), &cluster.access_key, &cluster.secret_key).await?;
let heal_start: serde_json::Value = serde_json::from_str(&heal_start_body)
@@ -764,24 +1054,39 @@ mod tests {
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(60);
let partial_deadline = Instant::now() + Duration::from_secs(partial_timeout_secs);
let pre_interrupt_status = loop {
loop {
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let active_status: serde_json::Value = serde_json::from_str(&status_body)
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
if only_admin_heal_is_active(&active_status) {
break active_status;
let active = if background_enabled {
active_status["state"].as_str() == Some("active")
&& active_status["healOperations"]["activeBySource"]["admin"].as_u64() == Some(1)
} else {
only_admin_heal_is_active(&active_status)
};
if active {
break;
}
if Instant::now() >= partial_deadline {
return Err(format!("root heal never became active within {partial_timeout_secs}s: {active_status}").into());
}
sleep(Duration::from_millis(50)).await;
};
let partial_count = loop {
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
if matching > 0 && matching < expected_manifests.len() {
break matching;
}
let (partial_count, partial_manifest) = loop {
// Hash one committed shard to prove progress without letting a
// full-corpus hash pass consume the interruption window.
let materialized = metadata_count(&replaced_disk, bucket, &expected_manifests);
if materialized > 0
&& materialized < expected_manifests.len()
&& let Some(expected) = expected_manifests
.iter()
.find(|expected| object_metadata_exists_on_disk(&replaced_disk, bucket, &expected.key))
&& census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?
.matches_manifest(&expected.shard_census)
{
break (materialized, expected);
}
if matching == expected_manifests.len() {
if materialized == expected_manifests.len() {
return Err(format!(
"root heal rebuilt all {} baseline objects before the target could be interrupted",
expected_manifests.len()
@@ -796,30 +1101,195 @@ mod tests {
}
sleep(Duration::from_millis(10)).await;
};
let pre_interrupt_status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
assert!(
coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
}),
"node 0 must have started the exact admin task before interruption"
);
let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
assert_eq!(
pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
Some(1),
"interruption must occur while the single admin task is active: {pre_interrupt_status}"
);
if !background_enabled {
assert!(
only_admin_heal_is_active(&pre_interrupt_status),
"isolated interruption must retain only the admin task: {pre_interrupt_status}"
);
assert_eq!(
pre_interrupt_replacement["cluster"]["records"].as_array().map(Vec::len),
Some(0),
"root-heal interruption point must not retain an automatic replacement generation: {pre_interrupt_replacement}"
);
}
info!(
event = "heal_restart_checkpoint",
event = "heal_interruption_checkpoint",
component = "e2e_test",
subsystem = "heal",
partial_count,
"Verified unique admin owner before target interruption"
background_enabled,
interruption_node,
interruption_kind,
partial_metadata_count = partial_count,
verified_key = partial_manifest.key,
"Observed partial rebuild before interruption"
);
cluster.stop_node(1)?;
let stopped_count = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
assert!(
stopped_count > 0 && stopped_count < expected_manifests.len(),
"the target must stop after a partial rebuild, observed before stop={partial_count}, after stop={stopped_count}, total={}",
expected_manifests.len()
);
let unclean_shutdown_marker = replaced_disk.join(".rustfs.sys").join("unclean-shutdown");
match std::fs::remove_file(&unclean_shutdown_marker) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("failed to isolate unclean recovery marker {unclean_shutdown_marker:?}: {error}").into());
let target_pid = cluster.nodes[1].process.as_ref().ok_or("target process is not running")?.id();
if scenario == InterruptionScenario::TargetEndpointBlackhole {
let node_pids = cluster
.nodes
.iter()
.map(|node| {
node.process
.as_ref()
.ok_or("cluster process is not running")
.map(std::process::Child::id)
})
.collect::<Result<Vec<_>, _>>()?;
timeout(Duration::from_secs(2), TcpStream::connect(&cluster.nodes[1].address)).await??;
let mut blackhole = TcpPortBlackhole::install(&cluster.nodes[1].address)?;
let blocked_connect = timeout(Duration::from_millis(500), TcpStream::connect(&cluster.nodes[1].address)).await;
assert!(
blocked_connect.is_err(),
"target endpoint connection must time out while the OUTPUT DROP rule is active: {blocked_connect:?}"
);
let stable_window_secs = std::env::var("RUSTFS_HEAL_CHAOS_BLACKHOLE_STABLE_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(2)
.clamp(1, 5);
let blackhole_timeout_secs = std::env::var("RUSTFS_HEAL_CHAOS_BLACKHOLE_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(20)
.clamp(stable_window_secs + 1, 60);
let blackhole_deadline = Instant::now() + Duration::from_secs(blackhole_timeout_secs);
let mut stable_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
let mut stable_since = Instant::now();
loop {
for (node_index, (node, expected_pid)) in cluster.nodes.iter_mut().zip(&node_pids).enumerate() {
let process = node
.process
.as_mut()
.ok_or_else(|| format!("node {node_index} process disappeared"))?;
assert_eq!(process.id(), *expected_pid, "node {node_index} PID changed during endpoint blackhole");
assert!(process.try_wait()?.is_none(), "node {node_index} exited during endpoint blackhole");
}
let current_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
if current_count == expected_manifests.len() {
return Err("root heal completed before the endpoint blackhole became observable".into());
}
if current_count != stable_count {
stable_count = current_count;
stable_since = Instant::now();
}
if stable_since.elapsed() >= Duration::from_secs(stable_window_secs) {
break;
}
if Instant::now() >= blackhole_deadline {
return Err(format!(
"target rebuild never remained stable for {stable_window_secs}s during the endpoint blackhole: last_count={stable_count}, total={}",
expected_manifests.len()
)
.into());
}
sleep(Duration::from_millis(100)).await;
}
let blocked_task_body = timeout(
Duration::from_secs(5),
signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key),
)
.await??;
let blocked_task: serde_json::Value = serde_json::from_str(&blocked_task_body)
.map_err(|err| format!("blackholed task status is not JSON ({err}): {blocked_task_body}"))?;
assert_eq!(
blocked_task["summary"].as_str(),
Some("running"),
"the original admin task must remain resumable during the endpoint blackhole: {blocked_task}"
);
assert!(
census_object_version_on_disk(&replaced_disk, bucket, &partial_manifest.key, None)?
.matches_manifest(&partial_manifest.shard_census),
"the witnessed complete shard must survive the endpoint blackhole"
);
blackhole.restore()?;
timeout(Duration::from_secs(2), TcpStream::connect(&cluster.nodes[1].address)).await??;
info!(
event = "heal_endpoint_blackhole_restored",
component = "e2e_test",
subsystem = "heal",
interruption_kind,
stable_metadata_count = stable_count,
stable_window_secs,
"Restored target endpoint forwarding"
);
} else {
cluster.stop_node(interruption_node)?;
let stopped_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
assert!(
stopped_count > 0 && stopped_count < expected_manifests.len(),
"node {interruption_node} must stop during a partial rebuild, observed before stop={partial_count}, after stop={stopped_count}, total={}",
expected_manifests.len()
);
assert!(
census_object_version_on_disk(&replaced_disk, bucket, &partial_manifest.key, None)?
.matches_manifest(&partial_manifest.shard_census),
"the witnessed complete shard must survive interruption"
);
let unclean_shutdown_marker = Path::new(&cluster.nodes[interruption_node].data_dir)
.join(".rustfs.sys")
.join("unclean-shutdown");
if background_enabled {
assert!(
unclean_shutdown_marker.is_file(),
"background restart must retain the real unclean-shutdown marker"
);
} else {
match std::fs::remove_file(&unclean_shutdown_marker) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(
format!("failed to isolate unclean recovery marker {unclean_shutdown_marker:?}: {error}").into()
);
}
}
}
cluster.start_node(interruption_node).await?;
if interruption_node == 0 {
let target = cluster.nodes[1]
.process
.as_mut()
.ok_or("target process disappeared during coordinator restart")?;
assert_eq!(target.id(), target_pid, "coordinator restart must not replace the target process");
assert!(target.try_wait()?.is_none(), "the target must remain alive during coordinator restart");
}
}
cluster.start_node(1).await?;
let scanner_cycle_floor = if background_enabled {
Some(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs())
} else {
None
};
let heal_timeout_secs = std::env::var("RUSTFS_HEAL_REPLACED_DISK_TIMEOUT_SECS")
.ok()
@@ -832,13 +1302,22 @@ mod tests {
{
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
if matching == expected_manifests.len() && outage_census.is_complete() {
let pool_metadata_matches = match &expected_pool_metadata {
Some(expected) => {
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
.matches_manifest(expected)
}
None => true,
};
if matching == expected_manifests.len() && outage_census.is_complete() && pool_metadata_matches {
break;
}
}
if Instant::now() >= heal_deadline {
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
let pool_metadata =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
.await
.unwrap_or_else(|err| format!("status request failed: {err}"));
@@ -858,7 +1337,7 @@ mod tests {
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
};
return Err(format!(
"root heal did not resume after target restart within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, replacement_status={replacement_status}",
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
expected_manifests.len()
)
.into());
@@ -885,6 +1364,17 @@ mod tests {
"the outage object must be rebuilt into its own missing erasure slot"
);
if let Some(cycle_end) = scanner_cycle_floor {
wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
}
let mut expected_keys = expected_manifests
.iter()
.map(|manifest| manifest.key.clone())
.collect::<HashSet<_>>();
assert!(expected_keys.insert(outage_key.to_string()));
assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
let target_client = cluster.create_s3_client(1)?;
for expected in &expected_manifests {
let response = target_client.get_object().bucket(bucket).key(&expected.key).send().await?;
@@ -914,6 +1404,30 @@ mod tests {
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
if interruption_node == 0 {
// Admin tasks are process-local. Physical and queue convergence
// above establish recovery; a lost task must not report success.
assert_eq!(
task_status["summary"].as_str(),
Some("notFound"),
"interrupted task status: {task_status}"
);
assert_eq!(
task_status["detail"].as_str(),
Some("heal task not found or expired"),
"interrupted admin task must be explicitly unavailable: {task_status}"
);
info!(
event = "heal_interruption_recovered",
component = "e2e_test",
subsystem = "heal",
interruption_node,
interruption_kind,
task_state = "not_found",
"Physical recovery completed after coordinator restart"
);
return Ok(());
}
if task_status["summary"].as_str() != Some("finished") {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
}
@@ -560,18 +560,12 @@ async fn test_multipart_encryption_type(
.set_parts(Some(completed_parts))
.build();
let mut complete_request = s3_client
let complete_request = s3_client
.complete_multipart_upload()
.bucket(bucket)
.key(object_key)
.upload_id(upload_id)
.multipart_upload(completed_multipart_upload);
if matches!(encryption_type, EncryptionType::SSEC) {
complete_request = complete_request
.sse_customer_algorithm("AES256")
.sse_customer_key(sse_c_key.as_ref().unwrap())
.sse_customer_key_md5(sse_c_md5.as_ref().unwrap());
}
let _complete_output = complete_request.send().await?;
// Download and verify
+18 -1
View File
@@ -23,10 +23,17 @@ pub mod common;
#[cfg(test)]
pub mod chaos;
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8).
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
// and on-demand-migration source scenarios (backlog#2151).
#[cfg(test)]
pub mod fake_s3_target;
// On-demand migration (backlog#2147): shared two-server environment, admin
// wrappers, and the harness self-test (backlog#2151). Behavior scenarios are
// added by later ODM tasks.
#[cfg(test)]
pub mod on_demand_migration;
// Socket-level network fault-injection proxy for black-box cluster tests
// (backlog#1325 network fault-injection block): latency / blackhole / one-way
// partition on the wire between nodes. Serves #1312/#1319 (lock-plane one-way
@@ -73,6 +80,12 @@ mod upgrade_compatibility_test;
#[cfg(test)]
mod replication_lww_receiver_test;
// Outbound target matrix: every object shape against every remote-target
// failure mode the fake target models (SOP:
// docs/postmortems/2026-09-03-replication-checksum-default-regression.md).
#[cfg(test)]
mod replication_target_matrix_test;
// Data usage regression tests
#[cfg(test)]
mod data_usage_test;
@@ -205,6 +218,10 @@ mod cluster_multidrive_pool_test;
#[cfg(test)]
mod inline_fast_path_cluster_test;
// backlog#2207: two-node gate for the cluster-authoritative tier stats contract.
#[cfg(test)]
mod tier_stats_cluster_test;
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
#[cfg(test)]
mod checksum_upload_test;
+64 -2
View File
@@ -225,8 +225,8 @@ fn encode_unsigned_aws_chunked_with_sha256_trailer(decoded: &[u8]) -> Vec<u8> {
let checksum = sha256_base64(decoded);
let mut encoded = format!("{:x}\r\n", decoded.len()).into_bytes();
encoded.extend_from_slice(decoded);
encoded.extend_from_slice(b"\r\n0\r\n\r\n");
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}").as_bytes());
encoded.extend_from_slice(b"\r\n0\r\n");
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}\r\n\r\n").as_bytes());
encoded
}
@@ -549,6 +549,68 @@ async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::e
Ok(())
}
/// s3s v0.16 validates the aws-chunked decoded length while RustFS consumes the
/// body stream. Mismatches are client body errors and must not leak as 500s.
#[tokio::test]
async fn aws_chunked_decoded_length_mismatch_returns_incomplete_body() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
for (key, declared_len) in [
("decoded-length-overrun.bin", 3_usize),
("decoded-length-shortfall.bin", 9_usize),
] {
let decoded = b"decoded";
assert_ne!(declared_len, decoded.len(), "test case must exercise a mismatch");
let encoded_body = encode_unsigned_aws_chunked_with_sha256_trailer(decoded);
let decoded_content_length = declared_len.to_string();
let path = format!("/{BUCKET}/{key}");
let signer = SigV4::new(&env);
let extra_signed_headers = [
("content-encoding", "aws-chunked"),
("x-amz-decoded-content-length", decoded_content_length.as_str()),
("x-amz-trailer", "x-amz-checksum-sha256"),
];
let headers = signer.sign_with_extra_headers("PUT", &path, "", UNSIGNED_PAYLOAD_TRAILER, &extra_signed_headers);
let response = local_http_client()
.put(format!("{}{}", env.url, path))
.header("authorization", &headers.authorization)
.header("content-encoding", "aws-chunked")
.header("x-amz-content-sha256", &headers.content_sha256)
.header("x-amz-date", &headers.amz_date)
.header("x-amz-decoded-content-length", &decoded_content_length)
.header("x-amz-trailer", "x-amz-checksum-sha256")
.body(encoded_body)
.timeout(std::time::Duration::from_secs(10))
.send()
.await?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"decoded length mismatch must be a client error, body:\n{body}"
);
assert_error_code(&body, "IncompleteBody");
let absent = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(key)
.send()
.await
.expect_err("decoded length mismatch must not publish an object");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
}
env.stop_server();
Ok(())
}
/// (e) A request whose `x-amz-date` is skewed beyond the server's tolerance
/// (s3s default 900s / 15 min) must be rejected with RequestTimeTooSkewed /
/// 403. The signature is otherwise valid: the credential-scope date and
@@ -0,0 +1,254 @@
// 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.
//! On-demand migration backfill job scenarios (ODM-12, rustfs/backlog#2159):
//! a full backfill of a small-object source, cancellation, and resuming from
//! the persisted continuation token after a server restart.
use super::common::{BackfillOp, BackfillRequest, ODM_SERVER_ENV, OdmSourceSpec, OdmTestEnv, SeedObject};
use crate::fake_s3_target::Operation;
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const SOURCE_BUCKET: &str = "odm-backfill-source";
const LOCAL_BUCKET: &str = "odm-backfill-local";
const KEY_PREFIX: &str = "cold/";
/// Keys per scenario. The fake source retains at most 4,096 object versions
/// and 4,096 journal entries, and one pull is a HEAD plus a GET, so a
/// scenario that asserts on the journal stays below ~2,000 keys. The job
/// lists 1,000 keys per page, so this still spans several pages and exercises
/// the continuation token, which is what the scenarios are about.
const SEEDED_KEYS: usize = 1500;
fn key(i: usize) -> String {
format!("{KEY_PREFIX}{i:05}")
}
/// Content that identifies the key so a mis-stored object is caught.
fn body(i: usize) -> Bytes {
Bytes::from(format!("object-{i:05}-payload"))
}
fn seed(env: &OdmTestEnv, count: usize) {
let objects: Vec<SeedObject> = (0..count).map(|i| SeedObject::new(key(i), body(i))).collect();
let etags = env.seed_source(SOURCE_BUCKET, &objects);
assert_eq!(etags.len(), count);
}
async fn configure(env: &OdmTestEnv, spec: &OdmSourceSpec) -> TestResult {
let response = env.configure_source(LOCAL_BUCKET, spec).await?;
assert_eq!(response.status, 200, "configure: {}", response.body);
// The probe issued a one-key listing; count only the job's traffic.
env.source.take_requests();
Ok(())
}
fn source_lists(env: &OdmTestEnv) -> Vec<Option<String>> {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::ListObjectsV2)
.map(|record| record.continuation_token)
.collect()
}
fn source_gets(env: &OdmTestEnv) -> usize {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::GetObject)
.count()
}
fn counter(job: &serde_json::Value, name: &str) -> u64 {
job[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {job}"))
}
#[tokio::test]
async fn backfill_pulls_every_source_object_across_list_pages() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
configure(&env, &env.fake_source_spec(SOURCE_BUCKET)).await?;
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
let job = started.json()?["job"].clone();
assert_eq!(job["state"], "running");
assert_eq!(job["skip_existing"], "always");
assert_eq!(job["dry_run"], false);
let job_id = job["job_id"].as_str().expect("job id").to_string();
// A second start while the job holds its lease is a conflict.
let again = env
.backfill(LOCAL_BUCKET, BackfillOp::Start(BackfillRequest::default()))
.await?;
assert_eq!(again.status, 409, "second start: {}", again.body);
assert!(again.body.contains("OnDemandMigrationBackfillRunning"), "{}", again.body);
let done = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(240), |job| job["state"] == "completed")
.await?;
assert_eq!(done["job_id"], job_id.as_str());
assert_eq!(counter(&done, "listed"), COUNT as u64);
assert_eq!(counter(&done, "enqueued"), COUNT as u64);
assert_eq!(counter(&done, "pulled"), COUNT as u64);
assert_eq!(counter(&done, "failed"), 0);
assert_eq!(counter(&done, "skipped_existing"), 0);
assert!(done["continuation_token"].is_null(), "a finished job carries no cursor");
assert_eq!(done["last_key"], key(COUNT - 1));
assert!(done["failed_keys"].as_array().is_some_and(Vec::is_empty));
let expected_bytes: u64 = (0..COUNT).map(|i| body(i).len() as u64).sum();
assert_eq!(counter(&done, "bytes"), expected_bytes);
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
for i in [0, 999, 1000, 1200, COUNT - 1] {
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
}
let lists = source_lists(&env);
assert_eq!(lists.len(), COUNT.div_ceil(1000), "{COUNT} keys at 1000 per page: {lists:?}");
assert!(lists[0].is_none(), "the first page starts without a cursor");
assert!(lists[1..].iter().all(Option::is_some), "every later page carries the cursor");
assert_eq!(source_gets(&env), COUNT, "every object is fetched exactly once");
// The status endpoint summarises the same job.
let status = env.status(LOCAL_BUCKET).await?;
assert_eq!(status.status, 200);
let summary = status.json()?["backfill"].clone();
assert_eq!(summary["job_id"], job_id.as_str());
assert_eq!(summary["state"], "completed");
assert_eq!(counter(&summary, "pulled"), COUNT as u64);
Ok(())
}
#[tokio::test]
async fn backfill_cancel_stops_enqueueing_and_persists_cancelled() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.max_concurrent_pulls = 1;
configure(&env, &spec).await?;
// Cancelling before any job exists is a 404, not a silent success.
let nothing = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(nothing.status, 404, "cancel without a job: {}", nothing.body);
assert!(nothing.body.contains("NoSuchBackfillJob"), "{}", nothing.body);
let unread = env.backfill(LOCAL_BUCKET, BackfillOp::Status).await?;
assert_eq!(unread.status, 404, "status without a job: {}", unread.body);
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
env.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(60), |job| {
job["state"] == "running" && counter(job, "enqueued") > 0
})
.await?;
let cancelled = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(cancelled.status, 200, "cancel: {}", cancelled.body);
let job = cancelled.json()?["job"].clone();
assert_eq!(job["state"], "cancelled");
let enqueued_at_cancel = counter(&job, "enqueued");
assert!(enqueued_at_cancel < COUNT as u64, "the job was cancelled mid-way: {job}");
// Nothing is queued after the cancel: the checkpoint and the source
// traffic both stop moving once the few in-flight pulls drain.
tokio::time::sleep(Duration::from_secs(2)).await;
let persisted = env.backfill_job(LOCAL_BUCKET).await?.expect("checkpoint kept for inspection");
assert_eq!(persisted["state"], "cancelled");
assert_eq!(counter(&persisted, "enqueued"), enqueued_at_cancel);
let gets_after_drain = source_gets(&env);
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(source_gets(&env), gets_after_drain, "no source GET after the cancel drained");
assert!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await? < COUNT);
// Cancel is idempotent and the status endpoint reports the final state.
let again = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(again.status, 200, "second cancel: {}", again.body);
assert_eq!(again.json()?["job"]["state"], "cancelled");
let status = env.status(LOCAL_BUCKET).await?;
assert_eq!(status.json()?["backfill"]["state"], "cancelled");
// A cancelled job releases the bucket: a new job can start.
let restarted = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(restarted.status, 200, "restart after cancel: {}", restarted.body);
assert_ne!(restarted.json()?["job"]["job_id"], job["job_id"]);
let _ = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
Ok(())
}
#[tokio::test]
async fn backfill_resumes_from_continuation_token_after_restart() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let mut env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.max_concurrent_pulls = 2;
configure(&env, &spec).await?;
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
let job_id = started.json()?["job"]["job_id"].as_str().expect("job id").to_string();
// Wait for the first page to be committed (cursor persisted), then kill
// the server while the job is still running.
let mid = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(120), |job| {
job["state"] == "running" && job["continuation_token"].is_string()
})
.await?;
assert!(counter(&mid, "listed") >= 1000 && counter(&mid, "listed") < COUNT as u64, "{mid}");
env.source.take_requests();
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
let done = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(300), |job| job["state"] == "completed")
.await?;
assert_eq!(done["job_id"], job_id.as_str(), "the same job continues after the restart");
assert_eq!(counter(&done, "failed"), 0);
assert!(
counter(&done, "listed") >= COUNT as u64,
"the resumed job listed the rest (the interrupted page is listed twice): {done}"
);
// Keys pulled before the crash are re-listed and skipped, never re-pulled;
// a pull whose report died with the old process is counted neither way,
// so only the lower bound and the queue accounting are exact.
assert!(counter(&done, "pulled") + counter(&done, "skipped_existing") >= COUNT as u64, "{done}");
assert!(counter(&done, "pulled") <= counter(&done, "enqueued"), "{done}");
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
for i in [0, 500, 999, 1000, COUNT - 1] {
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
}
let lists = source_lists(&env);
assert!(!lists.is_empty(), "the resumed job listed the source");
assert!(
lists.iter().all(Option::is_some),
"after the restart every source listing carries a continuation-token: {lists:?}"
);
assert!(
lists.len() <= COUNT.div_ceil(1000),
"the listing did not start over from the first page: {lists:?}"
);
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,183 @@
// 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.
//! Concurrency limits of on-demand migration (rustfs/backlog#2158):
//! single-flight on one key, the `max_concurrent_pulls` ceiling, and a full
//! background pull queue.
//!
//! The point of each case is what the source is spared, so the source
//! journal (`count_requests`) carries the assertion in every one of them.
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
use crate::fake_s3_target::Operation;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-concurrency-source";
/// Background pulls land after the response that queued them.
const SETTLE: Duration = Duration::from_secs(120);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
fn source_get_count(env: &OdmTestEnv, key: &str) -> usize {
env.source.count_requests(Operation::GetObject, key)
}
/// Case 9: 32 concurrent misses on one key coalesce into a single-flight
/// pull. At most two source GETs are allowed: the leader plus one follower
/// that gave up waiting and streamed through.
#[tokio::test]
async fn test_odm_concurrent_misses_on_one_key_coalesce() -> TestResult {
let bucket = "odm-concurrency-singleflight";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let key = "singleflight/asset.bin";
let body = payload(512 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let responses: Vec<RawResponse> = futures::future::try_join_all((0..32).map(|_| env.raw_get(bucket, key))).await?;
for (index, response) in responses.iter().enumerate() {
assert_eq!(response.status, 200, "reader {index}: {}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body, "reader {index} received different bytes");
}
let source_gets = source_get_count(&env, key);
assert!(
(1..=2).contains(&source_gets),
"32 concurrent misses must not become {source_gets} source GETs"
);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the leader stores the object");
env.assert_local_present(bucket, key, &body).await;
let versions = env.client.list_object_versions().bucket(bucket).prefix(key).send().await?;
assert_eq!(
versions.versions().len(),
1,
"the coalesced pull commits exactly one version: {:?}",
versions.versions()
);
assert_eq!(
source_get_count(&env, key),
source_gets,
"nothing pulls the object again once it is local"
);
Ok(())
}
/// Case 10: 64 misses on distinct keys never exceed `max_concurrent_pulls`
/// in flight, and all of them eventually land.
#[tokio::test]
async fn test_odm_concurrent_pulls_respect_the_configured_ceiling() -> TestResult {
let bucket = "odm-concurrency-ceiling";
const MAX_CONCURRENT_PULLS: u32 = 4;
const KEYS: usize = 64;
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.max_concurrent_pulls = MAX_CONCURRENT_PULLS;
})
.await?;
let body = payload(256 * 1024);
let keys: Vec<String> = (0..KEYS).map(|index| format!("ceiling/object-{index:03}.bin")).collect();
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
env.seed_source(SOURCE_BUCKET, &seeds);
let reads = futures::future::try_join_all(keys.iter().map(|key| env.raw_get(bucket, key)));
let (responses, peak_inflight) = env.peak_inflight_pulls(bucket, reads).await?;
let responses = responses?;
for (key, response) in keys.iter().zip(&responses) {
assert_eq!(response.status, 200, "{key}: {}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body, "{key} received different bytes");
}
assert!(
peak_inflight <= u64::from(MAX_CONCURRENT_PULLS),
"in-flight pulls peaked at {peak_inflight}, above the configured {MAX_CONCURRENT_PULLS}"
);
assert!(
peak_inflight >= 1,
"the poll never observed a pull in flight, so the ceiling assertion proves nothing"
);
for key in &keys {
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{key} must be stored locally");
assert_eq!(source_get_count(&env, key), 1, "{key} is pulled exactly once");
}
assert_eq!(env.status_counter(bucket, "/inflight_pulls").await?, 0, "every pull slot is released");
Ok(())
}
/// Case 11: with a small background queue, a burst of Range reads overflows
/// it. The overflow is counted and dropped, never turned into a client
/// failure: every reader still gets its 206 from the source.
#[tokio::test]
async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients() -> TestResult {
let bucket = "odm-concurrency-queue-full";
const REQUESTS: usize = 100;
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.pull_queue_capacity = 8;
spec.policy.max_concurrent_pulls = 1;
})
.await?;
let body = payload(128 * 1024);
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
env.seed_source(SOURCE_BUCKET, &seeds);
let responses: Vec<RawResponse> = futures::future::try_join_all(
keys.iter()
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
)
.await?;
for (key, response) in keys.iter().zip(&responses) {
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body.slice(0..1024), "{key} served the wrong range");
assert_eq!(
response.header("content-range"),
Some(format!("bytes 0-1023/{}", body.len()).as_str()),
"{key}"
);
}
let queue_full = env
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
.await?;
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
assert!(
ranged_reads >= REQUESTS,
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
);
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
assert!(
dropped > 0,
"the overflowed keys are the ones with no backfill GET, but every key got one"
);
Ok(())
}
@@ -0,0 +1,551 @@
// 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.
//! Source-failure scenarios for on-demand migration (rustfs/backlog#2158):
//! access denied, the circuit breaker, first-byte and mid-body stream
//! failures (a cut body and a stalled one), ETag integrity, the negative
//! cache, and an unsupported (SSE-C) source object.
//!
//! Every case asserts what the source was asked for, not only what the
//! client received: a fault that silently turned into a second source
//! request would otherwise pass.
use super::common::{BoxError, OdmTestEnv, SeedObject, start_configured_env};
use crate::fake_s3_target::{FaultAction, Operation};
use bytes::Bytes;
use std::time::{Duration, Instant};
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-fault-source";
/// Header the GET/HEAD paths add when the answer came from the source.
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Status of the `SourceUnavailable` error the `propagate` policy returns.
const SOURCE_UNAVAILABLE_STATUS: u16 = 424;
/// Background pulls and their counters land after the response.
const SETTLE: Duration = Duration::from_secs(60);
/// Consecutive counted source failures that open the breaker
/// (`BREAKER_FAILURE_THRESHOLD` in ecstore).
const BREAKER_FAILURE_THRESHOLD: usize = 5;
/// Position-dependent payload so a misaligned or truncated copy is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// A source object with a well-formed but deliberately wrong single-part
/// ETag: the fake source retains `x-rustfs-source-etag` verbatim, so HEAD
/// and GET advertise an MD5 the body does not have.
async fn seed_with_etag(env: &OdmTestEnv, key: &str, body: Bytes, etag: &str) -> TestResult {
let response = env
.source_client()
.put_object()
.bucket(SOURCE_BUCKET)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.customize()
.mutate_request({
let etag = etag.to_string();
move |request| {
request.headers_mut().insert("x-rustfs-source-etag", etag.clone());
}
})
.send()
.await?;
assert_eq!(
response.e_tag(),
Some(format!("\"{etag}\"").as_str()),
"the fake source stores the announced ETag"
);
Ok(())
}
/// A source object that reports SSE-C: the fake source echoes the customer
/// algorithm it captured from the replication passthrough transport header.
async fn seed_with_ssec(env: &OdmTestEnv, key: &str, body: Bytes) -> TestResult {
env.source_client()
.put_object()
.bucket(SOURCE_BUCKET)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.customize()
.mutate_request(|request| {
request.headers_mut().insert("x-rustfs-replication-ssec-algorithm", "AES256");
})
.send()
.await?;
Ok(())
}
/// Case 1: a 403 from the source is a configuration error, not a health
/// signal. `propagate` answers 424 and records the class; `not_found` hides
/// it as a 404. Neither counts toward the breaker.
#[tokio::test]
async fn test_odm_source_access_denied_propagates_without_opening_the_breaker() -> TestResult {
let propagating = "odm-fault-denied-propagate";
let hiding = "odm-fault-denied-notfound";
let env = start_configured_env(propagating, SOURCE_BUCKET, |_| {}).await?;
let mut hiding_spec = env.fake_source_spec(SOURCE_BUCKET);
hiding_spec.policy.source_error = "not_found".to_string();
env.configure_and_wait(hiding, &hiding_spec).await?;
let propagate_key = "denied/propagate.bin";
let hidden_key = "denied/hidden.bin";
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(propagate_key, payload(4096)),
SeedObject::new(hidden_key, payload(4096)),
],
);
env.source
.inject_for_key(Operation::HeadObject, propagate_key, FaultAction::ResponseStatus(403), 1);
let denied = env.raw_get(propagating, propagate_key).await?;
assert_eq!(denied.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&denied.body));
assert!(
String::from_utf8_lossy(&denied.body).contains("SourceUnavailable"),
"the propagated error names the ODM source code: {}",
String::from_utf8_lossy(&denied.body)
);
assert_eq!(env.source.count_requests(Operation::HeadObject, propagate_key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, propagate_key),
0,
"a denied HEAD never reaches the body"
);
let status = env.status_json(propagating).await?;
assert_eq!(
status.pointer("/last_source_error/class").and_then(|v| v.as_str()),
Some("access_denied"),
"{status}"
);
assert_eq!(
status.pointer("/breaker/state").and_then(|v| v.as_str()),
Some("closed"),
"a configuration error must not open the breaker: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/source_error")
.and_then(|v| v.as_u64()),
Some(1),
"{status}"
);
env.source
.inject_for_key(Operation::HeadObject, hidden_key, FaultAction::ResponseStatus(403), 1);
let hidden = env.raw_get(hiding, hidden_key).await?;
assert_eq!(hidden.status, 404, "{}", String::from_utf8_lossy(&hidden.body));
assert_eq!(env.source.count_requests(Operation::HeadObject, hidden_key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, hidden_key), 0);
env.assert_local_absent(propagating, propagate_key).await;
env.assert_local_absent(hiding, hidden_key).await;
Ok(())
}
/// Case 2: repeated transport failures open the breaker; while it is open
/// the source is not touched at all, and the half-open probe after the open
/// window closes it again. The open window is a compiled-in 30 s constant
/// (`BREAKER_OPEN_DURATION`), so this case waits in real time.
///
/// The source client disables SDK retries, so one logical source call is
/// exactly one wire request: the script is exactly as deep as the number of
/// breaker failures it has to produce, and the scripted fault count and the
/// observed source request count must agree.
#[tokio::test]
async fn test_odm_repeated_source_errors_open_the_breaker_and_recover() -> TestResult {
let bucket = "odm-fault-breaker";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "breaker/doc.bin";
let body = payload(8192);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
env.source
.inject_for_key(Operation::HeadObject, key, FaultAction::ResponseStatus(503), BREAKER_FAILURE_THRESHOLD);
for attempt in 1..=BREAKER_FAILURE_THRESHOLD {
let response = env.raw_get(bucket, key).await?;
assert_eq!(
response.status,
SOURCE_UNAVAILABLE_STATUS,
"attempt {attempt}: {}",
String::from_utf8_lossy(&response.body)
);
}
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/breaker/state")
.and_then(|v| v.as_str()),
Some("open"),
"the threshold of consecutive source failures must open the breaker"
);
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
BREAKER_FAILURE_THRESHOLD,
"every counted failure is exactly one source request"
);
// With the script cleared, the only thing that can still fail a read is
// the open breaker itself.
env.source.clear_faults();
let source_requests = env.source.count_requests(Operation::HeadObject, key);
let rejected = env.raw_get(bucket, key).await?;
assert_eq!(rejected.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&rejected.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
source_requests,
"an open breaker never touches the source"
);
assert!(
env.status_counter(bucket, "/counters/requests_total/get/breaker_open")
.await?
>= 1,
"the rejected request is counted as breaker_open"
);
// Half-open admits exactly one probe once the open window elapses.
let deadline = Instant::now() + Duration::from_secs(120);
let recovered = loop {
let response = env.raw_get(bucket, key).await?;
if response.status == 200 {
break response;
}
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS);
assert!(Instant::now() < deadline, "the breaker never left the open state");
tokio::time::sleep(Duration::from_secs(1)).await;
};
assert_eq!(recovered.body, body, "the recovered read serves the source bytes");
assert_eq!(recovered.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
source_requests + 1,
"only the half-open probe reached the source"
);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/breaker/state")
.and_then(|v| v.as_str()),
Some("closed"),
"a successful probe closes the breaker"
);
Ok(())
}
/// Case 3: a source that holds the response past `first_byte_ms` is a
/// timeout, and the client never sees a 200 head. One logical source call is
/// one wire request, so a single scripted stall is enough to fail the read.
#[tokio::test]
async fn test_odm_source_stall_times_out_before_the_first_byte() -> TestResult {
let bucket = "odm-fault-stall";
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.source_timeout.first_byte_ms = 500;
})
.await?;
let key = "stall/doc.bin";
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(4096))]);
env.source
.inject_for_key(Operation::HeadObject, key, FaultAction::Stall(Duration::from_secs(5)), 1);
let started = Instant::now();
let response = env.raw_get(bucket, key).await?;
let elapsed = started.elapsed();
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
1,
"the stalled HEAD is the only source request"
);
assert!(
elapsed < Duration::from_secs(5),
"the read timeout must cut the attempt short, took {elapsed:?}"
);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
0,
"a timed-out HEAD never starts a body read"
);
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/last_source_error/class")
.and_then(|v| v.as_str()),
Some("timeout"),
);
env.assert_local_absent(bucket, key).await;
Ok(())
}
/// Case 4: the source cuts the body of an inline pull. The client sees a
/// short read, nothing is stored, and no multipart upload is left behind.
#[tokio::test]
async fn test_odm_inline_pull_aborts_when_the_source_body_is_cut() -> TestResult {
let bucket = "odm-fault-inline-cut";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "cut/inline.bin";
let body = payload(256 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
env.source
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(1024), 1);
// The client sees a transport failure while reading the body: the
// announced Content-Length is never delivered.
env.raw_get(bucket, key)
.await
.expect_err("a cut source body must not read back as a complete object");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"an aborted inline pull is not retried on the same request"
);
// Give a stray background pull time to appear before asserting absence.
tokio::time::sleep(Duration::from_secs(3)).await;
env.assert_local_absent(bucket, key).await;
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
assert!(
uploads.uploads().is_empty(),
"an aborted pull leaves no multipart upload: {:?}",
uploads.uploads()
);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"nothing re-reads the source afterwards"
);
Ok(())
}
/// Case 5: the source answers, sends part of the body and then goes quiet
/// for longer than `source_timeout.idle_ms`. The inline tee must end both
/// ends: the client gets a short read rather than a silently truncated 200,
/// the pull is counted as a source timeout, and nothing (object or multipart
/// upload) is left behind locally.
#[tokio::test]
async fn test_odm_inline_pull_aborts_when_the_source_body_stalls() -> TestResult {
let bucket = "odm-fault-inline-stall";
const IDLE_MS: u64 = 1_000;
let idle = Duration::from_millis(IDLE_MS);
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.source_timeout.idle_ms = IDLE_MS;
})
.await?;
let key = "stall/inline.bin";
let body = payload(256 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
// The head and the first slice arrive at once; the source then pauses for
// four times the idle budget, which is what a stalled source looks like.
env.source.inject_for_key(
Operation::GetObject,
key,
FaultAction::SlowSendBody {
chunk_bytes: 32 * 1024,
delay: idle * 4,
},
1,
);
let started = Instant::now();
env.raw_get(bucket, key)
.await
.expect_err("a stalled source body must not read back as a complete object");
let elapsed = started.elapsed();
assert!(
elapsed < idle * 4,
"the idle budget, not the source's own pause, must end the read (took {elapsed:?})"
);
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"an aborted inline pull is not retried on the same request"
);
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/source_timeout", 1, SETTLE)
.await?;
// The leader releases its slot just after it records the failure.
let deadline = Instant::now() + SETTLE;
loop {
let inflight = env
.status_json(bucket)
.await?
.pointer("/inflight_pulls")
.and_then(|value| value.as_u64());
if inflight == Some(0) {
break;
}
assert!(Instant::now() < deadline, "the aborted pull never released its slot: {inflight:?}");
tokio::time::sleep(Duration::from_millis(200)).await;
}
env.assert_local_absent(bucket, key).await;
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
assert!(
uploads.uploads().is_empty(),
"a stalled pull leaves no multipart upload: {:?}",
uploads.uploads()
);
Ok(())
}
/// Case 6: the background pull of a large object hits a cut body, counts the
/// failure, and the retry stores the object.
#[tokio::test]
async fn test_odm_background_pull_retries_a_truncated_source_body() -> TestResult {
let bucket = "odm-fault-background-cut";
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| spec.policy.inline_max_bytes = 4096).await?;
let key = "cut/background.bin";
let body = payload(512 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
// The faults are consumed in order by the two GETs the large-object path
// makes: the passthrough that answers the client (unaffected), then the
// background pull (cut).
env.source
.inject_for_key(Operation::GetObject, key, FaultAction::Delay(Duration::ZERO), 1);
env.source
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(2048), 1);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body, "the passthrough is unaffected by the pull's fault");
// The cut body ends the pull attempt as a retryable source transport
// failure; the retry stores the object, so the pull as a whole succeeds
// and no failure is counted (only a pull that gives up is).
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the retry must store the object");
env.assert_local_present(bucket, key, &body).await;
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
3,
"one passthrough, one cut pull, one successful retry"
);
let status = env.status_json(bucket).await?;
assert_eq!(
status
.pointer("/counters/pulled_objects_total/background")
.and_then(|v| v.as_u64()),
Some(1),
"{status}"
);
assert_eq!(
status
.pointer("/counters/pull_failures_total")
.and_then(|failures| failures.as_object())
.map(|failures| failures.values().filter_map(serde_json::Value::as_u64).sum::<u64>()),
Some(0),
"a retried attempt is not a failed pull: {status}"
);
Ok(())
}
/// Case 7: the source advertises an ETag its bytes do not match. The client
/// still gets every byte; the write-back is discarded as an integrity
/// failure and nothing is stored.
#[tokio::test]
async fn test_odm_wrong_source_etag_discards_the_write_back() -> TestResult {
let bucket = "odm-fault-etag";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "etag/mismatch.bin";
let body = payload(64 * 1024);
seed_with_etag(&env, key, body.clone(), "0123456789abcdef0123456789abcdef").await?;
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body, "the client receives the complete source bytes");
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/etag_mismatch", 1, SETTLE)
.await?;
env.assert_local_absent(bucket, key).await;
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"a discarded write-back is not re-read"
);
Ok(())
}
/// Case 8: a source miss is remembered for `negative_cache_ttl_secs`, and
/// re-checked once the entry expires.
#[tokio::test]
async fn test_odm_source_not_found_is_negative_cached_for_the_ttl() -> TestResult {
let bucket = "odm-fault-negative-cache";
let ttl = Duration::from_secs(3);
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.negative_cache_ttl_secs = ttl.as_secs();
})
.await?;
let key = "negative/nowhere.bin";
for attempt in 1..=10 {
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 404, "attempt {attempt}: {}", String::from_utf8_lossy(&response.body));
}
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
1,
"nine of the ten misses stop at the negative cache"
);
assert!(
env.status_counter(bucket, "/counters/requests_total/get/negative_cached")
.await?
>= 9,
"the cached misses are counted"
);
tokio::time::sleep(ttl + Duration::from_secs(2)).await;
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 404);
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
2,
"an expired entry re-checks the source once"
);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
Ok(())
}
/// Case 9: an SSE-C source object cannot be migrated (the key belongs to the
/// source's client), so the read fails as unsupported without a body read.
#[tokio::test]
async fn test_odm_ssec_source_object_is_unsupported() -> TestResult {
let bucket = "odm-fault-ssec";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "ssec/secret.bin";
seed_with_ssec(&env, key, payload(4096)).await?;
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
0,
"an unsupported object is rejected on the HEAD"
);
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/counters/requests_total/get/unsupported")
.and_then(|v| v.as_u64()),
Some(1),
);
env.assert_local_absent(bucket, key).await;
Ok(())
}
@@ -0,0 +1,298 @@
// 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.
//! Basic read-through scenarios (rustfs/backlog#2156): inline pull and
//! local persistence, large-object passthrough with background backfill,
//! Range passthrough, source 404, `versionId` reads, a disabled bucket, and
//! the HEAD passthrough that stores nothing (rustfs/backlog#2155).
//! Every source-side expectation is asserted on the fake source's journal.
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject};
use crate::fake_s3_target::{BucketMode, Operation};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-get-source";
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Background pulls run after the response; generous for a loaded CI host.
const BACKFILL_WAIT: Duration = Duration::from_secs(60);
/// Position-dependent payload so a misaligned or truncated copy is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// RustFS with `local_bucket` migrating from `SOURCE_BUCKET` on the fake
/// source (unversioned, like a plain migration source); `adjust` tweaks the
/// policy before it is installed. Returns once the runtime consults the
/// source.
async fn configured_env(local_bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
env.rustfs.create_test_bucket(local_bucket).await?;
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
adjust(&mut spec);
let response = env.configure_source(local_bucket, &spec).await?;
assert_eq!(response.status, 200, "configure on-demand migration: {}", response.body);
env.wait_until_source_consulted(local_bucket).await?;
Ok(env)
}
fn source_get_ranges(env: &OdmTestEnv, key: &str) -> Vec<Option<String>> {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::GetObject && record.key.as_deref() == Some(key))
.map(|record| record.range)
.collect()
}
#[tokio::test]
async fn get_miss_pulls_inline_and_serves_locally_afterwards() -> TestResult {
let bucket = "odm-get-inline";
let env = configured_env(bucket, |_| {}).await?;
let key = "inline/report.bin";
let body = payload(200 * 1024);
let etag = env
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
.remove(0);
let quoted_etag = format!("\"{etag}\"");
let first = env.raw_get(bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
assert_eq!(first.header("etag"), Some(quoted_etag.as_str()), "inline answers carry the source ETag");
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(first.header("accept-ranges"), Some("bytes"));
assert_eq!(first.body, body, "the client receives the source bytes");
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "exactly one source GET");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert!(
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
"the inline pull must store the object locally"
);
let second = env.raw_get(bucket, key).await?;
assert_eq!(second.status, 200);
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
assert_eq!(second.body, body, "the local copy is the source bytes");
assert_eq!(second.header("etag"), Some(quoted_etag.as_str()), "preserve_etag keeps the source ETag");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the second GET is served locally"
);
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
Ok(())
}
#[tokio::test]
async fn get_large_object_streams_through_and_backfills_in_background() -> TestResult {
let bucket = "odm-get-large";
let env = configured_env(bucket, |spec| spec.policy.inline_max_bytes = 4096).await?;
let key = "large/archive.bin";
let body = payload(512 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(response.body, body, "the passthrough streams the whole object");
assert!(
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
"the background pull must store the object locally"
);
env.assert_local_present(bucket, key, &body).await;
assert_eq!(
source_get_ranges(&env, key),
vec![None, None],
"one passthrough GET plus one background pull, both unranged"
);
Ok(())
}
#[tokio::test]
async fn get_range_streams_206_and_backfills_the_whole_object() -> TestResult {
let bucket = "odm-get-range";
let env = configured_env(bucket, |_| {}).await?;
let key = "range/video.bin";
let body = payload(100_000);
let etag = env
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
.remove(0);
let response = env
.client
.get_object()
.bucket(bucket)
.key(key)
.range("bytes=10-19")
.send()
.await?;
assert_eq!(response.content_range(), Some("bytes 10-19/100000"), "the source's 206 is passed through");
assert_eq!(response.content_length(), Some(10));
assert_eq!(response.e_tag(), Some(format!("\"{etag}\"").as_str()));
assert_eq!(response.body.collect().await?.into_bytes(), body.slice(10..20));
assert_eq!(
source_get_ranges(&env, key),
vec![Some("bytes=10-19".to_string())],
"the Range is forwarded"
);
assert!(
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
"serve_and_backfill must pull the whole object"
);
env.assert_local_present(bucket, key, &body).await;
assert_eq!(
source_get_ranges(&env, key),
vec![Some("bytes=10-19".to_string()), None],
"the background pull fetches the whole object"
);
Ok(())
}
#[tokio::test]
async fn get_source_not_found_is_404_and_negative_cached() -> TestResult {
let bucket = "odm-get-missing";
let env = configured_env(bucket, |_| {}).await?;
let key = "missing/nowhere.bin";
for attempt in 1..=2 {
let err = env
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("a key missing on both sides is 404");
assert_eq!(err.code(), Some("NoSuchKey"), "attempt {attempt}: {err:?}");
}
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a source miss never pulls");
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
1,
"the second miss stops at the negative cache"
);
env.assert_local_absent(bucket, key).await;
Ok(())
}
#[tokio::test]
async fn get_with_version_id_does_not_consult_the_source() -> TestResult {
let bucket = "odm-get-versioned";
let env = configured_env(bucket, |_| {}).await?;
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let key = "versioned/doc.bin";
let body = payload(1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let err = env
.client
.get_object()
.bucket(bucket)
.key(key)
.version_id("11111111-2222-4333-8444-555555555555")
.send()
.await
.expect_err("a version read cannot be answered by the source");
assert!(
matches!(err.code(), Some("NoSuchVersion") | Some("NoSuchKey")),
"unexpected error: {err:?}"
);
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
env.assert_local_absent(bucket, key).await;
// The same key without versionId is still migrated: the gate is per request.
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
Ok(())
}
#[tokio::test]
async fn get_after_disable_does_not_consult_the_source() -> TestResult {
let bucket = "odm-get-disabled";
let env = configured_env(bucket, |_| {}).await?;
let key = "disabled/doc.bin";
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(1024))]);
let response = env.disable(bucket).await?;
assert_eq!(response.status, 204, "{}", response.body);
let err = env
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("a disabled bucket answers locally");
assert_eq!(err.code(), Some("NoSuchKey"), "{err:?}");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
env.assert_local_absent(bucket, key).await;
Ok(())
}
/// A HEAD miss is answered from the source but must not store anything: the
/// key stays absent locally, so a second HEAD consults the source again. This
/// is the smoke-lane guard for the HEAD passthrough (rustfs/backlog#2155).
#[tokio::test]
async fn head_miss_answers_from_the_source_without_persisting() -> TestResult {
let bucket = "odm-head-passthrough";
let env = configured_env(bucket, |_| {}).await?;
let key = "head/report.bin";
let body = payload(32 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let head = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
assert_eq!(head.status, 200, "{}", String::from_utf8_lossy(&head.body));
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
assert!(head.body.is_empty(), "a HEAD answer carries no body");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a HEAD must never pull the body");
env.assert_local_absent(bucket, key).await;
let again = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
assert_eq!(again.status, 200, "{}", String::from_utf8_lossy(&again.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
2,
"nothing was written back, so the second HEAD consults the source again"
);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
env.assert_local_absent(bucket, key).await;
Ok(())
}
@@ -0,0 +1,606 @@
// 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.
//! Self-test of the ODM harness (rustfs/backlog#2151): the fake source's
//! migration-facing surface (ListObjectsV2 paging, `Range`, unversioned
//! buckets, metadata replay, fault actions) and the two-server environment.
//! No ODM behavior is exercised here.
use super::common::{OdmTestEnv, SeedObject, fake_source_client, start_source_rustfs};
use crate::fake_s3_target::{BucketMode, FakeS3Target, FakeS3TargetOptions, FaultAction, Operation, SeedMetadata};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::{ByteStream, DateTime};
use bytes::Bytes;
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const SOURCE_BUCKET: &str = "odm-source";
/// Position-dependent payload so a misaligned range read is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
async fn fake_source() -> Result<(FakeS3Target, Client), Box<dyn std::error::Error + Send + Sync>> {
let source = FakeS3Target::start().await?;
source.create_bucket(SOURCE_BUCKET);
let client = fake_source_client(&source);
Ok((source, client))
}
/// Full ListObjectsV2 traversal. Returns `(keys, common prefixes, pages)` and
/// checks the page shape on the way: every page except the last is full and
/// truncated, the last carries no continuation token.
async fn list_all(
client: &Client,
prefix: Option<&str>,
delimiter: Option<&str>,
start_after: Option<&str>,
max_keys: i32,
) -> Result<(Vec<String>, Vec<String>, usize), Box<dyn std::error::Error + Send + Sync>> {
let mut keys = Vec::new();
let mut prefixes = Vec::new();
let mut pages = 0usize;
let mut token: Option<String> = None;
loop {
let page = client
.list_objects_v2()
.bucket(SOURCE_BUCKET)
.set_prefix(prefix.map(str::to_string))
.set_delimiter(delimiter.map(str::to_string))
.set_start_after(start_after.map(str::to_string))
.max_keys(max_keys)
.set_continuation_token(token.clone())
.send()
.await?;
pages += 1;
let page_keys: Vec<String> = page
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_string))
.collect();
let page_prefixes: Vec<String> = page
.common_prefixes()
.iter()
.filter_map(|common| common.prefix().map(str::to_string))
.collect();
let entries = page_keys.len() + page_prefixes.len();
assert_eq!(page.key_count(), Some(entries as i32), "KeyCount must count keys and prefixes");
assert_eq!(page.continuation_token(), token.as_deref(), "the request token must be echoed");
keys.extend(page_keys);
prefixes.extend(page_prefixes);
if page.is_truncated() == Some(true) {
assert_eq!(entries as i32, max_keys, "every truncated page must be full");
token = Some(
page.next_continuation_token()
.expect("truncated page must carry a continuation token")
.to_string(),
);
} else {
assert!(page.next_continuation_token().is_none(), "final page must not carry a token");
return Ok((keys, prefixes, pages));
}
}
}
#[tokio::test]
async fn fake_source_list_objects_v2_paginates_with_delimiter() -> TestResult {
let (source, client) = fake_source().await?;
let mut expected_keys = BTreeSet::new();
for directory in 0..30 {
for file in 0..30 {
expected_keys.insert(format!("d{directory:02}/k{file:03}"));
}
}
for index in 0..100 {
expected_keys.insert(format!("top-{index:03}"));
}
assert_eq!(expected_keys.len(), 1000);
for key in &expected_keys {
source.put_seed_object(SOURCE_BUCKET, key.clone(), Bytes::from(key.clone()), &SeedMetadata::new());
}
// A key whose current version is a delete marker must stay hidden.
client
.put_object()
.bucket(SOURCE_BUCKET)
.key("hidden/marker")
.body(ByteStream::from_static(b"gone"))
.send()
.await?;
client
.delete_object()
.bucket(SOURCE_BUCKET)
.key("hidden/marker")
.send()
.await?;
let expected_sorted: Vec<String> = expected_keys.iter().cloned().collect();
let expected_prefixes: Vec<String> = (0..30).map(|directory| format!("d{directory:02}/")).collect();
let expected_top: Vec<String> = (0..100).map(|index| format!("top-{index:03}")).collect();
// Flat traversal in byte order, 1000 keys in pages of 7.
let (keys, prefixes, pages) = list_all(&client, None, None, None, 7).await?;
assert_eq!(keys, expected_sorted);
assert!(prefixes.is_empty());
assert_eq!(pages, 143);
// Delimiter folding: 30 common prefixes then 100 top-level keys, pages of 7.
let (keys, prefixes, pages) = list_all(&client, None, Some("/"), None, 7).await?;
assert_eq!(prefixes, expected_prefixes);
assert_eq!(keys, expected_top);
assert_eq!(pages, 19);
// Empty prefix equals no prefix.
let (keys, _, _) = list_all(&client, Some(""), None, None, 1000).await?;
assert_eq!(keys, expected_sorted);
// No match: empty, not truncated, no token.
let (keys, prefixes, pages) = list_all(&client, Some("zzz/"), Some("/"), None, 7).await?;
assert!(keys.is_empty() && prefixes.is_empty());
assert_eq!(pages, 1);
let (keys, _, _) = list_all(&client, Some("hidden/"), None, None, 7).await?;
assert!(keys.is_empty(), "a current delete marker must hide its key");
// Exact page boundary: 30 keys under one directory, max-keys=30 -> one
// untruncated page.
let (keys, prefixes, pages) = list_all(&client, Some("d05/"), Some("/"), None, 30).await?;
assert_eq!(keys.len(), 30);
assert!(prefixes.is_empty());
assert_eq!(pages, 1);
// start-after skips keys at or before the marker.
let (keys, _, _) = list_all(&client, None, None, Some("top-097"), 1000).await?;
assert_eq!(keys, ["top-098", "top-099"]);
// max-keys is clamped to 1000; exactly 1000 keys fit in one page.
let (keys, _, pages) = list_all(&client, None, None, None, 5000).await?;
assert_eq!(keys.len(), 1000);
assert_eq!(pages, 1);
let listings: Vec<_> = source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::ListObjectsV2)
.collect();
assert!(listings.len() >= 143 + 19);
assert!(listings.iter().any(|record| record.prefix.as_deref() == Some("d05/")));
assert!(
listings.iter().any(|record| record.continuation_token.is_some()),
"resumed pages must journal their continuation token"
);
assert!(listings.iter().all(|record| record.user_agent.is_some()));
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_range_get_variants_and_416() -> TestResult {
let (source, client) = fake_source().await?;
let body = payload(1000);
source.put_seed_object(SOURCE_BUCKET, "ranged", body.clone(), &SeedMetadata::new());
for (range, expected_range, expected_slice) in [
("bytes=10-19", "bytes 10-19/1000", &body[10..20]),
("bytes=990-", "bytes 990-999/1000", &body[990..]),
("bytes=-5", "bytes 995-999/1000", &body[995..]),
("bytes=0-5000", "bytes 0-999/1000", &body[..]),
] {
let output = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("ranged")
.range(range)
.send()
.await?;
assert_eq!(output.content_range(), Some(expected_range), "{range}");
assert_eq!(output.accept_ranges(), Some("bytes"), "{range}");
assert_eq!(output.content_length(), Some(expected_slice.len() as i64), "{range}");
let collected = output.body.collect().await?.into_bytes();
assert_eq!(collected.as_ref(), expected_slice, "{range}");
}
let head = client
.head_object()
.bucket(SOURCE_BUCKET)
.key("ranged")
.range("bytes=10-19")
.send()
.await?;
assert_eq!(head.content_range(), Some("bytes 10-19/1000"));
assert_eq!(head.content_length(), Some(10));
for range in ["bytes=1000-", "bytes=-0"] {
let error = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("ranged")
.range(range)
.send()
.await
.expect_err("unsatisfiable range must fail");
let response = error.raw_response().expect("416 must retain the raw response");
assert_eq!(response.status().as_u16(), 416, "{range}");
assert_eq!(response.headers().get("content-range"), Some("bytes */1000"), "{range}");
assert_eq!(error.code(), Some("InvalidRange"), "{range}");
}
let ranged = source
.requests()
.into_iter()
.find(|record| record.operation == Operation::GetObject && record.range.as_deref() == Some("bytes=10-19"))
.expect("the Range header must be journaled verbatim");
assert_eq!(ranged.key.as_deref(), Some("ranged"));
assert!(source.count_requests(Operation::GetObject, "ranged") >= 6);
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_unversioned_bucket_overwrites_and_deletes() -> TestResult {
let (source, client) = fake_source().await?;
source.create_bucket_with_mode("plain-source", BucketMode::Unversioned);
let versioning = client.get_bucket_versioning().bucket("plain-source").send().await?;
assert!(versioning.status().is_none(), "unversioned bucket must report no versioning status");
let first = client
.put_object()
.bucket("plain-source")
.key("doc")
.body(ByteStream::from_static(b"first"))
.send()
.await?;
assert!(first.version_id().is_none());
let second = client
.put_object()
.bucket("plain-source")
.key("doc")
.body(ByteStream::from_static(b"second"))
.send()
.await?;
assert!(second.version_id().is_none());
let get = client.get_object().bucket("plain-source").key("doc").send().await?;
assert!(get.version_id().is_none(), "GET must not return x-amz-version-id");
assert_eq!(get.body.collect().await?.into_bytes().as_ref(), b"second");
let head = client.head_object().bucket("plain-source").key("doc").send().await?;
assert!(head.version_id().is_none(), "HEAD must not return x-amz-version-id");
assert_eq!(source.stored_versions("plain-source", "doc").len(), 1, "overwrite must replace in place");
let deleted = client.delete_object().bucket("plain-source").key("doc").send().await?;
assert!(deleted.delete_marker().is_none() && deleted.version_id().is_none());
let missing = client
.get_object()
.bucket("plain-source")
.key("doc")
.send()
.await
.expect_err("deleted object must be gone");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(missing.code(), Some("NoSuchKey"));
let missing_head = client
.head_object()
.bucket("plain-source")
.key("doc")
.send()
.await
.expect_err("deleted object must fail HEAD");
assert_eq!(missing_head.raw_response().map(|response| response.status().as_u16()), Some(404));
assert!(source.stored_versions("plain-source", "doc").is_empty(), "DELETE must not leave a marker");
// The versioned bucket on the same target keeps its version ids.
let versioned = client
.put_object()
.bucket(SOURCE_BUCKET)
.key("doc")
.body(ByteStream::from_static(b"versioned"))
.send()
.await?;
assert!(versioned.version_id().is_some());
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_replays_standard_and_user_metadata() -> TestResult {
let (source, client) = fake_source().await?;
let body = payload(4096);
let expected_etag = format!("\"{}\"", {
use md5::Digest as _;
hex_simd::encode_to_string(md5::Md5::digest(&body), hex_simd::AsciiCase::Lower)
});
// 2026-01-01T00:00:00Z rendered as an HTTP date by the SDK.
let expires = DateTime::from_secs(1_767_225_600);
client
.put_object()
.bucket(SOURCE_BUCKET)
.key("meta")
.body(ByteStream::from(body.clone()))
.content_type("application/x-odm")
.content_encoding("gzip")
.content_disposition("attachment; filename=\"meta.bin\"")
.content_language("en-US")
.cache_control("max-age=60")
.expires(expires)
.metadata("Foo-Bar", "mixed case name")
.metadata("UPPER", "upper name")
.metadata("already-lower", "lower name")
.send()
.await?;
let head = client.head_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
let get = client.get_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
for (label, content_type, content_encoding, content_disposition, content_language, cache_control, expires_string, e_tag) in [
(
"HEAD",
head.content_type(),
head.content_encoding(),
head.content_disposition(),
head.content_language(),
head.cache_control(),
head.expires_string(),
head.e_tag(),
),
(
"GET",
get.content_type(),
get.content_encoding(),
get.content_disposition(),
get.content_language(),
get.cache_control(),
get.expires_string(),
get.e_tag(),
),
] {
assert_eq!(content_type, Some("application/x-odm"), "{label}");
assert_eq!(content_encoding, Some("gzip"), "{label}");
assert_eq!(content_disposition, Some("attachment; filename=\"meta.bin\""), "{label}");
assert_eq!(content_language, Some("en-US"), "{label}");
assert_eq!(cache_control, Some("max-age=60"), "{label}");
assert_eq!(expires_string, Some("Thu, 01 Jan 2026 00:00:00 GMT"), "{label}");
assert_eq!(e_tag, Some(expected_etag.as_str()), "{label}");
}
for metadata in [head.metadata(), get.metadata()] {
let metadata = metadata.expect("user metadata must be replayed");
assert_eq!(metadata.get("foo-bar").map(String::as_str), Some("mixed case name"));
assert_eq!(metadata.get("upper").map(String::as_str), Some("upper name"));
assert_eq!(metadata.get("already-lower").map(String::as_str), Some("lower name"));
assert!(!metadata.contains_key("Foo-Bar") && !metadata.contains_key("UPPER"));
}
assert!(head.last_modified().is_some());
assert_eq!(head.last_modified(), get.last_modified());
assert_eq!(head.content_length(), Some(4096));
assert_eq!(get.body.collect().await?.into_bytes(), body);
// Seeded objects replay the same way.
let seeded_etag = source.put_seed_object(
SOURCE_BUCKET,
"seeded",
Bytes::from_static(b"seeded"),
&SeedMetadata::new()
.content_type("text/plain")
.content_encoding("identity")
.cache_control("no-store")
.user_metadata("Origin", "seed"),
);
let seeded = client.head_object().bucket(SOURCE_BUCKET).key("seeded").send().await?;
assert_eq!(seeded.e_tag(), Some(format!("\"{seeded_etag}\"").as_str()));
assert_eq!(seeded.content_type(), Some("text/plain"));
assert_eq!(seeded.content_encoding(), Some("identity"));
assert_eq!(seeded.cache_control(), Some("no-store"));
assert_eq!(
seeded
.metadata()
.and_then(|metadata| metadata.get("origin"))
.map(String::as_str),
Some("seed")
);
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_fault_actions_truncate_stall_and_status() -> TestResult {
let (source, client) = fake_source().await?;
let body = payload(4096);
source.put_seed_object(SOURCE_BUCKET, "faulty", body.clone(), &SeedMetadata::new());
// TruncateBodyAt: headers promise 4096 bytes, the body ends after 100.
source.inject_for_key(Operation::GetObject, "faulty", FaultAction::TruncateBodyAt(100), 1);
let truncated = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert_eq!(truncated.content_length(), Some(4096));
let short_read = truncated
.body
.collect()
.await
.expect_err("a truncated body must fail to collect");
let short_read = short_read.to_string();
assert!(!short_read.is_empty());
// ResponseStatus: arbitrary status with the matching S3 error code.
for (code, expected_code) in [
(429u16, "SlowDown"),
(404, "NoSuchKey"),
(500, "InternalError"),
(503, "ServiceUnavailable"),
] {
source.inject(Operation::GetObject, FaultAction::ResponseStatus(code), 1);
let error = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("faulty")
.send()
.await
.expect_err("scripted status must fail");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(code));
assert_eq!(error.code(), Some(expected_code));
}
// Stall: the fully computed response is held before its first byte.
source.inject(Operation::HeadObject, FaultAction::Stall(Duration::from_millis(400)), 1);
let started = Instant::now();
let stalled = client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert!(started.elapsed() >= Duration::from_millis(350), "stall must delay the first byte");
assert_eq!(stalled.content_length(), Some(4096));
let post_stall_started = Instant::now();
client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert!(post_stall_started.elapsed() < Duration::from_millis(350), "stall is consumed once");
// The object is intact once the script is drained.
let intact = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert_eq!(intact.body.collect().await?.into_bytes(), body);
assert_eq!(source.count_requests(Operation::GetObject, "faulty"), 6);
assert_eq!(source.count_requests(Operation::HeadObject, "faulty"), 2);
assert_eq!(source.count_requests(Operation::GetObject, "other"), 0);
let records = source.requests();
assert!(
records.iter().all(|record| record
.user_agent
.as_deref()
.is_some_and(|agent| agent.contains("aws-sdk-rust"))),
"the SDK user agent must be journaled"
);
assert!(
records
.iter()
.any(|record| record.fault == Some(FaultAction::TruncateBodyAt(100)))
);
assert!(
records
.iter()
.any(|record| record.fault == Some(FaultAction::Stall(Duration::from_millis(400))))
);
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_raised_object_cap_accepts_large_put() -> TestResult {
let source = FakeS3Target::start_with_options(FakeS3TargetOptions {
max_object_bytes: 96 * 1024 * 1024,
})
.await?;
source.create_bucket(SOURCE_BUCKET);
let client = fake_source_client(&source);
let len = 64 * 1024 * 1024 + 1;
client
.put_object()
.bucket(SOURCE_BUCKET)
.key("large")
.body(ByteStream::from(vec![7u8; len]))
.send()
.await?;
let head = client.head_object().bucket(SOURCE_BUCKET).key("large").send().await?;
assert_eq!(head.content_length(), Some(len as i64));
let tail = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("large")
.range("bytes=-1")
.send()
.await?;
assert_eq!(tail.content_range(), Some(format!("bytes {}-{}/{len}", len - 1, len - 1).as_str()));
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn odm_env_starts_rustfs_and_fake_source() -> TestResult {
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
let local_bucket = "odm-local";
env.rustfs.create_test_bucket(local_bucket).await?;
let etags = env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("seed/a", Bytes::from_static(b"alpha")),
SeedObject::new("seed/b", Bytes::from_static(b"beta"))
.with_metadata(SeedMetadata::new().content_type("text/plain").user_metadata("Kind", "seed")),
],
);
assert_eq!(etags.len(), 2);
assert!(env.source.requests().is_empty(), "seeding must not touch the journal");
let source_client = env.source_client();
let seeded = source_client.head_object().bucket(SOURCE_BUCKET).key("seed/b").send().await?;
assert_eq!(seeded.content_type(), Some("text/plain"));
assert_eq!(seeded.e_tag(), Some(format!("\"{}\"", etags[1]).as_str()));
assert_eq!(env.source.count_requests(Operation::HeadObject, "seed/b"), 1);
env.assert_local_absent(local_bucket, "seed/a").await;
env.client
.put_object()
.bucket(local_bucket)
.key("seed/a")
.body(ByteStream::from_static(b"alpha"))
.send()
.await?;
env.assert_local_present(local_bucket, "seed/a", b"alpha").await;
env.assert_local_absent(local_bucket, "seed/b").await;
let spec = env.fake_source_spec(SOURCE_BUCKET).to_json();
assert_eq!(spec["version"], 1);
assert_eq!(spec["enabled"], true);
assert_eq!(spec["source"]["provider"], "s3");
assert_eq!(spec["source"]["endpoint"], env.source.endpoint());
assert_eq!(spec["source"]["bucket"], SOURCE_BUCKET);
assert_eq!(spec["source"]["credentials"]["secret_key"], "fake-secret");
assert_eq!(spec["policy"]["source_timeout"]["first_byte_ms"], 15_000);
assert!(spec["policy"]["bandwidth_limit_bytes_per_sec"].is_null());
let debug = format!("{:?}", env.fake_source_spec(SOURCE_BUCKET));
assert!(!debug.contains("fake-secret"), "Debug output must redact the secret");
Ok(())
}
#[tokio::test]
async fn start_source_rustfs_round_trips_put_get() -> TestResult {
let env = OdmTestEnv::start().await?;
let source = start_source_rustfs().await?;
assert_ne!(source.url, env.rustfs.url, "the source must be a separate instance");
source.create_test_bucket(SOURCE_BUCKET).await?;
let source_client = source.create_s3_client();
let body = payload(70_000);
let put = source_client
.put_object()
.bucket(SOURCE_BUCKET)
.key("real/object")
.body(ByteStream::from(body.clone()))
.content_type("application/octet-stream")
.send()
.await?;
assert!(put.e_tag().is_some());
let get = source_client
.get_object()
.bucket(SOURCE_BUCKET)
.key("real/object")
.send()
.await?;
assert_eq!(get.content_type(), Some("application/octet-stream"));
assert_eq!(get.body.collect().await?.into_bytes(), body);
let visible_to_primary = env
.client
.list_buckets()
.send()
.await?
.buckets()
.iter()
.any(|bucket| bucket.name() == Some(SOURCE_BUCKET));
assert!(!visible_to_primary, "the two servers must not share state");
let spec = super::common::OdmSourceSpec::for_rustfs_source(&source, SOURCE_BUCKET).to_json();
assert_eq!(spec["source"]["provider"], "rustfs");
assert_eq!(spec["source"]["endpoint"], source.url);
Ok(())
}
@@ -0,0 +1,823 @@
// 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.
//! How on-demand migration composes with the rest of the bucket surface
//! (rustfs/backlog#2158): default encryption, Object Lock, quota,
//! notifications, replication, versioning and delete markers, the disable
//! switch, and the admin view.
//!
//! A pulled object goes through the internal put path, so it must be
//! indistinguishable from a client PUT. Each case pins both the resulting
//! local object and what the source was asked for.
use super::common::{
AdminResponse, BoxError, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env,
start_configured_env_with,
};
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
use crate::object_lock::common::put_object_lock_configuration;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault,
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, VersioningConfiguration,
};
use bytes::Bytes;
use local_ip_address::local_ip;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use serde_json::Value;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-interaction-source";
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// `userIdentity.principalId` every write-back event carries.
const ODM_PRINCIPAL_ID: &str = "rustfs-on-demand-migration";
const SETTLE: Duration = Duration::from_secs(120);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
async fn admin(
env: &RustFSTestEnvironment,
method: http::Method,
path: &str,
body: Option<Value>,
) -> Result<AdminResponse, BoxError> {
let url = format!("{}{path}", env.url);
let body = body.map(|value| serde_json::to_vec(&value)).transpose()?;
let content_type = body.is_some().then_some("application/json");
let response = signed_request(method, &url, &env.access_key, &env.secret_key, body, content_type).await?;
Ok(AdminResponse {
status: response.status().as_u16(),
body: response.text().await?,
})
}
async fn enable_versioning(env: &OdmTestEnv, bucket: &str) -> TestResult {
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
Ok(())
}
/// Case 12: a bucket that encrypts by default stores the pulled object
/// encrypted, and it reads back as plaintext afterwards without touching the
/// source again.
#[tokio::test]
async fn test_odm_pulled_object_uses_bucket_default_encryption() -> TestResult {
let bucket = "odm-interaction-sse";
let env = start_configured_env_with(
OdmEnvOptions {
local_kms: true,
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
env.client
.put_bucket_encryption()
.bucket(bucket)
.server_side_encryption_configuration(
ServerSideEncryptionConfiguration::builder()
.rules(
ServerSideEncryptionRule::builder()
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::Aes256)
.build()?,
)
.build(),
)
.build()?,
)
.send()
.await?;
let key = "sse/report.bin";
let body = payload(128 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let first = env.raw_get(bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(first.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let second = env.raw_get(bucket, key).await?;
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "the second read is local");
assert_eq!(
second.header("x-amz-server-side-encryption"),
Some("AES256"),
"the write-back honours the bucket default encryption"
);
assert_eq!(second.body, body, "the encrypted copy reads back as the source bytes");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the encrypted local copy serves the second read"
);
Ok(())
}
/// Case 13: a pulled object inherits the bucket's default Object Lock
/// retention, so it cannot be deleted while the retention holds.
#[tokio::test]
async fn test_odm_pulled_object_inherits_object_lock_retention() -> TestResult {
let bucket = "odm-interaction-object-lock";
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
env.client
.create_bucket()
.bucket(bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
put_object_lock_configuration(&env.client, bucket, ObjectLockRetentionMode::Compliance, Some(1), None).await?;
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(bucket, &spec).await?;
let key = "locked/record.bin";
let body = payload(32 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let pulled = env.raw_get(bucket, key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert_eq!(pulled.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
assert_eq!(
head.object_lock_mode().map(|mode| mode.as_str()),
Some("COMPLIANCE"),
"the default retention mode is applied to the pulled object"
);
assert!(head.object_lock_retain_until_date().is_some(), "a retain-until date is set");
let version_id = head.version_id().ok_or("an Object Lock bucket is versioned")?.to_string();
let error = env
.client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await
.expect_err("a COMPLIANCE-retained version cannot be deleted");
assert_eq!(error.code(), Some("AccessDenied"), "{error:?}");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the rejected delete never consults the source"
);
Ok(())
}
/// Case 14: the write-back obeys the bucket quota. The client is still
/// served from the source, but nothing is stored and the failure is counted.
#[tokio::test]
async fn test_odm_write_back_respects_the_bucket_quota() -> TestResult {
let bucket = "odm-interaction-quota";
let env = start_configured_env_with(
OdmEnvOptions {
env: vec![("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")],
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
// Fill the bucket past the quota it is about to get, so the write-back's
// admission check has to reject it.
let filler = payload(2 * 1024 * 1024);
env.client
.put_object()
.bucket(bucket)
.key("quota/filler.bin")
.body(aws_sdk_s3::primitives::ByteStream::from(filler.clone()))
.send()
.await?;
wait_for_bucket_usage(&env, bucket, filler.len() as u64).await?;
set_bucket_quota(&env, bucket, 1024 * 1024).await?;
let key = "quota/oversized.bin";
let body = payload(2 * 1024 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body, "a full bucket still serves the client from the source");
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/quota", 1, SETTLE)
.await?;
env.assert_local_absent(bucket, key).await;
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the rejected write-back is not retried against the source"
);
Ok(())
}
/// The quota route answers 503 until the durable-quota capability is
/// confirmed on the fresh single-node deployment, so the write is retried.
async fn set_bucket_quota(env: &OdmTestEnv, bucket: &str, quota_bytes: u64) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = admin(
&env.rustfs,
http::Method::PUT,
&format!("/rustfs/admin/v3/quota/{bucket}"),
Some(serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" })),
)
.await?;
if response.status < 300 {
return Ok(());
}
if response.status != 503 || Instant::now() >= deadline {
return Err(format!("set quota for {bucket}: {} {}", response.status, response.body).into());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
async fn wait_for_bucket_usage(env: &OdmTestEnv, bucket: &str, at_least: u64) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = admin(&env.rustfs, http::Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
if response.status == 200 {
let usage = serde_json::from_str::<Value>(&response.body)?
.get("current_usage")
.and_then(Value::as_u64)
.unwrap_or(0);
if usage >= at_least {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(format!("bucket usage for {bucket} did not reach {at_least} bytes: {}", response.body).into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Case 15: a pull emits an ordinary creation event attributed to the
/// migration principal, and `emit_events=false` silences it.
#[tokio::test]
async fn test_odm_pull_emits_object_created_events_unless_disabled() -> TestResult {
let emitting = "odm-interaction-events";
let silent = "odm-interaction-events-off";
// The collector binds first: the outbound guard rejects a webhook
// endpoint on a private address unless its origin is allowed at startup.
let (endpoint, mut events) = spawn_event_collector().await?;
let allowed_origin = reqwest::Url::parse(&endpoint)?.origin().ascii_serialization();
let env = start_configured_env_with(
OdmEnvOptions {
env: vec![(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())],
..OdmEnvOptions::default()
},
emitting,
SOURCE_BUCKET,
|_| {},
)
.await?;
let mut silent_spec = env.fake_source_spec(SOURCE_BUCKET);
silent_spec.policy.emit_events = false;
env.configure_and_wait(silent, &silent_spec).await?;
let target = "odm-events";
let switches = admin(
&env.rustfs,
http::Method::PUT,
"/rustfs/admin/v3/module-switches",
Some(serde_json::json!({ "notify_enabled": true, "audit_enabled": false })),
)
.await?;
assert_eq!(switches.status, 200, "{}", switches.body);
let queue_dir = format!("{}/notify-queue-{target}", env.rustfs.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
let configured = admin(
&env.rustfs,
http::Method::PUT,
&format!("/rustfs/admin/v3/target/notify_webhook/{target}"),
Some(serde_json::json!({
"key_values": [
{ "key": "endpoint", "value": endpoint },
{ "key": "queue_dir", "value": queue_dir },
]
})),
)
.await?;
assert_eq!(configured.status, 200, "{}", configured.body);
wait_for_target_online(&env.rustfs, target).await?;
for bucket in [emitting, silent] {
put_notification_config(&env, bucket, target).await?;
}
// Control: an ordinary client PUT must produce an event, so a missing
// one below is about the write-back and not about the pipeline.
let control_key = "events/control.bin";
env.client
.put_object()
.bucket(emitting)
.key(control_key)
.body(aws_sdk_s3::primitives::ByteStream::from(payload(1024)))
.send()
.await?;
let control = wait_for_event(&mut events, emitting, control_key, Duration::from_secs(60))
.await
.ok_or("the notification pipeline delivered no event for a plain PUT")?;
assert_eq!(
control.pointer("/eventName").and_then(Value::as_str),
Some("s3:ObjectCreated:Put"),
"{control}"
);
let emitting_key = "events/pulled.bin";
let silent_key = "events/quiet.bin";
let body = payload(16 * 1024);
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(emitting_key, body.clone()),
SeedObject::new(silent_key, body.clone()),
],
);
for (bucket, key) in [(emitting, emitting_key), (silent, silent_key)] {
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{bucket}: {}", String::from_utf8_lossy(&response.body));
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{bucket}/{key} must be stored");
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "{bucket}/{key}");
}
let record = wait_for_event(&mut events, emitting, emitting_key, Duration::from_secs(60))
.await
.ok_or("no creation event for the pulled object")?;
assert_eq!(
record.pointer("/eventName").and_then(Value::as_str),
Some("s3:ObjectCreated:Put"),
"{record}"
);
assert_eq!(
record.pointer("/userIdentity/principalId").and_then(Value::as_str),
Some(ODM_PRINCIPAL_ID),
"{record}"
);
// The silent bucket's object landed before the event above was observed,
// so a missing event here is a decision, not a race.
assert!(
wait_for_event(&mut events, silent, silent_key, Duration::from_secs(5))
.await
.is_none(),
"emit_events=false must not publish a creation event"
);
Ok(())
}
async fn put_notification_config(env: &OdmTestEnv, bucket: &str, target: &str) -> TestResult {
let queue = QueueConfiguration::builder()
.id(format!("{bucket}-rule"))
.queue_arn(format!("arn:rustfs:sqs:us-east-1:{target}:webhook"))
.events(Event::from("s3:ObjectCreated:*"))
.filter(
NotificationConfigurationFilter::builder()
.key(
S3KeyFilter::builder()
.filter_rules(FilterRule::builder().name(FilterRuleName::Prefix).value("events/").build())
.build(),
)
.build(),
)
.build()?;
env.client
.put_bucket_notification_configuration()
.bucket(bucket)
.notification_configuration(NotificationConfiguration::builder().queue_configurations(queue).build())
.send()
.await?;
Ok(())
}
async fn wait_for_target_online(env: &RustFSTestEnvironment, target: &str) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let response = admin(env, http::Method::GET, "/rustfs/admin/v3/target/list", None).await?;
if response.status == 200 {
let body: Value = serde_json::from_str(&response.body)?;
let online = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
endpoints.iter().any(|endpoint| {
endpoint["account_id"].as_str() == Some(target) && endpoint["status"].as_str() == Some("online")
})
});
if online {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(format!("webhook target {target} did not come online: {}", response.body).into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Minimal HTTP receiver: answers everything 200 (so the target's
/// reachability probe reports online) and forwards parsed POST bodies.
async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Value>), BoxError> {
let listener = TcpListener::bind("0.0.0.0:0").await?;
let port = listener.local_addr()?.port();
let endpoint = format!("http://{}/events", std::net::SocketAddr::new(local_ip()?, port));
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let tx = tx.clone();
tokio::spawn(async move {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
let mut content_length = 0usize;
let mut header_end = None;
while header_end.is_none() {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
header_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
}
let header_end = header_end.expect("loop exits only with a header end");
let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
for line in headers.split("\r\n").skip(1) {
if let Some((name, value)) = line.split_once(':')
&& name.trim().eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse().unwrap_or(0);
}
}
let body_offset = header_end + 4;
while buffer.len() - body_offset < content_length {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
}
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.await;
let _ = stream.shutdown().await;
if let Ok(value) = serde_json::from_slice::<Value>(&buffer[body_offset..body_offset + content_length]) {
let _ = tx.send(value);
}
});
}
});
Ok((endpoint, rx))
}
/// The first delivered record for `bucket`/`key`, or `None` on timeout.
async fn wait_for_event(
events: &mut mpsc::UnboundedReceiver<Value>,
bucket: &str,
key: &str,
timeout: Duration,
) -> Option<Value> {
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.checked_duration_since(Instant::now())?;
let envelope = tokio::time::timeout(remaining, events.recv()).await.ok()??;
for record in envelope["Records"].as_array().into_iter().flatten() {
// S3 event notifications URL-encode the object key.
let record_key = record.pointer("/s3/object/key").and_then(Value::as_str).map(|raw| {
urlencoding::decode(raw)
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| raw.to_string())
});
if record.pointer("/s3/bucket/name").and_then(Value::as_str) == Some(bucket) && record_key.as_deref() == Some(key) {
return Some(record.clone());
}
}
}
}
/// Case 16: a pulled object enters the replication pipeline like any other
/// write, and a configuration whose source is one of the bucket's own
/// replication targets is rejected.
#[tokio::test]
async fn test_odm_pulled_object_replicates_and_target_as_source_is_rejected() -> TestResult {
let bucket = "odm-interaction-replication";
let replica_bucket = "odm-replica";
let fast_env = replication_fast_env();
let env = start_configured_env_with(
OdmEnvOptions {
env: fast_env.clone(),
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
let replica = FakeS3Target::start().await?;
replica.create_bucket(replica_bucket);
enable_versioning(&env, bucket).await?;
let arn = set_remote_target(&env.rustfs, bucket, &replica.address(), replica_bucket).await?;
put_bucket_replication(&env.rustfs, bucket, &arn).await?;
let key = "replicated/asset.bin";
let body = payload(64 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let deadline = Instant::now() + SETTLE;
while !replica.has_object(replica_bucket, key) {
assert!(Instant::now() < deadline, "the pulled object was never replicated to the target");
tokio::time::sleep(Duration::from_millis(200)).await;
}
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"replication reads the local copy, never the migration source"
);
let looping = OdmSourceSpec::for_fake_source(&replica, replica_bucket);
let rejected = env.configure_source(bucket, &looping).await?;
assert_eq!(
rejected.status, 400,
"a bucket may not migrate from its own replication target: {}",
rejected.body
);
Ok(())
}
async fn set_remote_target(
env: &RustFSTestEnvironment,
bucket: &str,
endpoint: &str,
target_bucket: &str,
) -> Result<String, BoxError> {
let response = admin(
env,
http::Method::PUT,
&format!("/rustfs/admin/v3/set-remote-target?bucket={}", urlencoding::encode(bucket)),
Some(serde_json::json!({
"endpoint": endpoint,
"credentials": { "accessKey": FAKE_ACCESS_KEY, "secretKey": FAKE_SECRET_KEY },
"targetbucket": target_bucket,
"secure": false,
"skipTlsVerify": false,
"type": "replication"
})),
)
.await?;
if response.status != 200 {
return Err(format!("set remote target: {} {}", response.status, response.body).into());
}
Ok(serde_json::from_str(&response.body)?)
}
async fn put_bucket_replication(env: &RustFSTestEnvironment, bucket: &str, arn: &str) -> TestResult {
let body = format!(
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Role></Role>
<Rule>
<ID>odm-rule</ID>
<Priority>1</Priority>
<Status>Enabled</Status>
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{arn}</Bucket></Destination>
</Rule>
</ReplicationConfiguration>"#
);
let url = format!("{}/{bucket}?replication", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.into_bytes()),
Some("application/xml"),
)
.await?;
if response.status() != 200 {
let status = response.status();
return Err(format!("put bucket replication: {status} {}", response.text().await.unwrap_or_default()).into());
}
Ok(())
}
/// Case 17: a local delete marker is the authoritative answer in a versioned
/// bucket, while an unversioned delete leaves nothing behind and the key is
/// migrated again.
#[tokio::test]
async fn test_odm_delete_marker_shadows_the_source_but_a_plain_delete_does_not() -> TestResult {
let versioned = "odm-interaction-delete-marker";
let unversioned = "odm-interaction-plain-delete";
let env = start_configured_env(versioned, SOURCE_BUCKET, |_| {}).await?;
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(unversioned, &spec).await?;
enable_versioning(&env, versioned).await?;
let key = "deleted/doc.bin";
let source_body = payload(8 * 1024);
let local_body = payload(4 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, source_body.clone())]);
for bucket in [versioned, unversioned] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(local_body.clone()))
.send()
.await?;
env.client.delete_object().bucket(bucket).key(key).send().await?;
}
let shadowed = env.raw_get(versioned, key).await?;
assert_eq!(shadowed.status, 404, "{}", String::from_utf8_lossy(&shadowed.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
0,
"a local delete marker answers without the source"
);
let migrated = env.raw_get(unversioned, key).await?;
assert_eq!(migrated.status, 200, "{}", String::from_utf8_lossy(&migrated.body));
assert_eq!(migrated.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(migrated.body, source_body, "an unversioned delete leaves the source authoritative");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
Ok(())
}
/// Case 18: deleting the configuration stops all source traffic without
/// touching what was already migrated, and reinstalling it resumes.
#[tokio::test]
async fn test_odm_disable_keeps_pulled_objects_and_stops_source_traffic() -> TestResult {
let bucket = "odm-interaction-disable";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let pulled_key = "disable/pulled.bin";
let untouched_key = "disable/untouched.bin";
let body = payload(32 * 1024);
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(pulled_key, body.clone()),
SeedObject::new(untouched_key, body.clone()),
],
);
let pulled = env.raw_get(bucket, pulled_key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?);
let disabled = env.disable(bucket).await?;
assert_eq!(disabled.status, 204, "{}", disabled.body);
let still_readable = env.raw_get(bucket, pulled_key).await?;
assert_eq!(still_readable.status, 200, "{}", String::from_utf8_lossy(&still_readable.body));
assert_eq!(still_readable.body, body, "a migrated object survives the disable");
assert_eq!(still_readable.header(ODM_RESPONSE_HEADER), None);
assert_eq!(env.source.count_requests(Operation::GetObject, pulled_key), 1);
let missing = env.raw_get(bucket, untouched_key).await?;
assert_eq!(missing.status, 404, "{}", String::from_utf8_lossy(&missing.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, untouched_key),
0,
"a disabled bucket never reaches the source"
);
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(bucket, &spec).await?;
let resumed = env.raw_get(bucket, untouched_key).await?;
assert_eq!(resumed.status, 200, "{}", String::from_utf8_lossy(&resumed.body));
assert_eq!(resumed.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(resumed.body, body);
assert_eq!(env.source.count_requests(Operation::GetObject, untouched_key), 1);
Ok(())
}
/// Case 19: the admin surface an operator sees — the configuration read back
/// without its secret, and a status document whose counters match the source
/// journal exactly.
#[tokio::test]
async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source() -> TestResult {
let bucket = "odm-interaction-admin";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let hit_key = "admin/present.bin";
let miss_key = "admin/absent.bin";
let body = payload(16 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(hit_key, body.clone())]);
let config = env.get_config(bucket).await?;
assert_eq!(config.status, 200, "{}", config.body);
let config = config.json()?;
assert_eq!(
config
.pointer("/config/source/credentials/secret_key")
.and_then(Value::as_str),
Some("REDACTED"),
"{config}"
);
assert_eq!(
config
.pointer("/config/source/credentials/access_key")
.and_then(Value::as_str),
Some(FAKE_ACCESS_KEY),
"the access key stays readable: {config}"
);
assert!(
!config.to_string().contains(FAKE_SECRET_KEY),
"the secret must not appear anywhere in the response"
);
let hit = env.raw_get(bucket, hit_key).await?;
assert_eq!(hit.status, 200, "{}", String::from_utf8_lossy(&hit.body));
for _ in 0..2 {
let miss = env.raw_get(bucket, miss_key).await?;
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
}
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
let status = env.status_json(bucket).await?;
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/enabled").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/module_enabled").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/provider").and_then(Value::as_str), Some("s3"), "{status}");
assert_eq!(
status
.pointer("/counters/requests_total/get/source_hit")
.and_then(Value::as_u64),
Some(1),
"one source hit, matching the one source GET: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/source_miss")
.and_then(Value::as_u64),
Some(1),
"only the first miss reached the source: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/negative_cached")
.and_then(Value::as_u64),
Some(1),
"the second miss stopped at the negative cache: {status}"
);
assert_eq!(
status
.pointer("/counters/pulled_objects_total/inline")
.and_then(Value::as_u64),
Some(1),
"{status}"
);
assert_eq!(
status.pointer("/counters/pulled_bytes_total").and_then(Value::as_u64),
Some(body.len() as u64),
"{status}"
);
assert_eq!(env.source.count_requests(Operation::GetObject, hit_key), 1);
assert_eq!(
env.source.count_requests(Operation::HeadObject, miss_key),
1,
"the status counters and the source journal agree"
);
Ok(())
}
@@ -0,0 +1,243 @@
// 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.
//! Provider interoperability cases (ODM-20, rustfs/backlog#2167).
//!
//! One body per case, run against whichever source the environment names:
//! the in-process fake source locally, a MinIO container or a real cloud
//! provider under `.github/workflows/on-demand-migration-interop.yml`. The
//! source is resolved by [`OdmInteropEnv`], so a provider difference in
//! path-style addressing, region handling, ETag shape or list pagination
//! shows up as one of these assertions failing rather than as a second,
//! drifting copy of the suite.
//!
//! Consequently these cases assert only on what every S3 implementation has
//! to agree on — what the client receives and what RustFS stored — never on
//! the fake source's request journal, which a real provider does not have.
//! The journal-backed expectations stay in `get_basic_test.rs` and
//! `interaction_test.rs`.
//!
//! The first three cases are the minimum a cloud provider is asked for (GET
//! miss, HEAD miss, merged list pagination); the backfill case runs against
//! the MinIO container, whose object count the lane raises well past the fake
//! source's caps.
use super::common::{BackfillRequest, BoxError, OdmInteropEnv, SeedObject, interop_backfill_objects};
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), BoxError>;
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Background pulls land after the response that triggered them; generous for
/// a loaded runner talking to a container.
const SETTLE: Duration = Duration::from_secs(90);
/// Position-dependent payload so a misaligned or truncated copy is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// A GET miss is answered from the source with the source's own ETag, and the
/// object it stored serves every later read locally.
#[tokio::test]
async fn interop_get_miss_pulls_from_the_source_and_serves_locally() -> TestResult {
let case =
OdmInteropEnv::start("interop_get_miss_pulls_from_the_source_and_serves_locally", "odm-interop-get", |_| {}).await?;
let key = "interop/report.bin";
let body = payload(200 * 1024);
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
let quoted_etag = format!("\"{etag}\"");
let first = case.env.raw_get(&case.bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(
first.header("etag"),
Some(quoted_etag.as_str()),
"the source ETag is passed through unchanged"
);
assert_eq!(first.body, body, "the client receives the source bytes");
assert!(
case.env.wait_local_listed(&case.bucket, key, SETTLE).await?,
"the inline pull must store the object locally"
);
let second = case.env.raw_get(&case.bucket, key).await?;
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
assert_eq!(second.body, body, "the local copy is the source bytes");
assert_eq!(
second.header("etag"),
Some(quoted_etag.as_str()),
"preserve_etag keeps the source ETag on the stored object"
);
case.finish().await
}
/// A HEAD miss is proxied with the source's size and ETag and stores nothing.
#[tokio::test]
async fn interop_head_miss_answers_from_the_source_without_persisting() -> TestResult {
let case =
OdmInteropEnv::start("interop_head_miss_answers_from_the_source_without_persisting", "odm-interop-head", |_| {}).await?;
let key = "interop/head-only.bin";
let body = payload(9_000);
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
let head = case
.env
.raw_object_request(http::Method::HEAD, &case.bucket, key, &[])
.await?;
assert_eq!(head.status, 200, "HEAD must be answered from the source");
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(head.header("etag"), Some(format!("\"{etag}\"").as_str()));
assert!(head.body.is_empty(), "a HEAD carries no body");
case.env.assert_local_absent(&case.bucket, key).await;
// A key the source does not hold is a plain 404, not a source error.
let missing = case
.env
.raw_object_request(http::Method::HEAD, &case.bucket, "interop/absent.bin", &[])
.await?;
assert_eq!(missing.status, 404, "a source miss is a 404");
case.finish().await
}
/// The merged `ListObjectsV2` pages the source namespace in byte order, keeps
/// every page within `max_keys`, and lets a local object win a shared key.
#[tokio::test]
async fn interop_list_through_pages_the_source_namespace() -> TestResult {
const SOURCE_KEYS: usize = 120;
const PAGE_SIZE: i32 = 50;
const SOURCE_BODY_LEN: usize = 3;
const LOCAL_BODY_LEN: usize = 11;
let case = OdmInteropEnv::start("interop_list_through_pages_the_source_namespace", "odm-interop-list", |spec| {
spec.policy.list_through = true
})
.await?;
let keys: Vec<String> = (0..SOURCE_KEYS).map(|index| format!("page/obj-{index:05}")).collect();
let seeds: Vec<SeedObject> = keys
.iter()
.map(|key| SeedObject::new(key.clone(), payload(SOURCE_BODY_LEN)))
.collect();
case.seed(&seeds).await?;
// Five keys the local bucket also holds, with a body length that tells the
// two sides apart in the listing.
let shared: Vec<String> = keys.iter().step_by(25).cloned().collect();
for key in &shared {
case.env
.client
.put_object()
.bucket(&case.bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(payload(LOCAL_BODY_LEN)))
.send()
.await?;
}
let mut listed: Vec<(String, i64)> = Vec::new();
let mut token: Option<String> = None;
let mut completed = false;
for _ in 0..SOURCE_KEYS {
let page = case
.env
.client
.list_objects_v2()
.bucket(&case.bucket)
.prefix("page/")
.max_keys(PAGE_SIZE)
.set_continuation_token(token.take())
.send()
.await?;
assert!(page.contents().len() <= PAGE_SIZE as usize, "a merged page must not exceed max_keys");
for object in page.contents() {
listed.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
}
if !page.is_truncated().unwrap_or(false) {
completed = true;
break;
}
token = Some(
page.next_continuation_token()
.ok_or("truncated merged page without a continuation token")?
.to_string(),
);
}
assert!(completed, "the merged listing did not terminate");
let listed_keys: Vec<String> = listed.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed_keys, keys, "the merged listing is the source namespace in byte order");
for (key, size) in &listed {
let expected = if shared.contains(key) {
LOCAL_BODY_LEN
} else {
SOURCE_BODY_LEN
};
assert_eq!(*size, expected as i64, "{key} must be reported by the side that wins it");
}
case.finish().await
}
/// A backfill pulls every object under the run's source prefix. The count
/// comes from the environment: the fake source caps out around 4,096 stored
/// versions, while the MinIO lane runs the full production-shaped batch.
#[tokio::test]
async fn interop_backfill_pulls_every_source_object() -> TestResult {
const KEY_PREFIX: &str = "cold/";
let count = interop_backfill_objects()?;
assert!(count > 0, "the backfill case needs at least one source object");
let case = OdmInteropEnv::start("interop_backfill_pulls_every_source_object", "odm-interop-backfill", |_| {}).await?;
let objects: Vec<SeedObject> = (0..count)
.map(|index| SeedObject::new(format!("{KEY_PREFIX}{index:06}"), Bytes::from(format!("object-{index:06}"))))
.collect();
case.seed(&objects).await?;
let started = case.env.start_backfill(&case.bucket, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start backfill: {}", started.body);
// One pull is a HEAD plus a GET plus a local write; the ceiling scales
// with the object count so raising it in the workflow does not need a
// second knob here.
let timeout = Duration::from_secs(180 + count as u64 / 5);
let done = case
.env
.wait_for_backfill(&case.bucket, timeout, |job| job["state"] == "completed")
.await?;
for (name, expected) in [
("listed", count as u64),
("enqueued", count as u64),
("pulled", count as u64),
("failed", 0),
] {
assert_eq!(
done[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {done}")),
expected,
"backfill {name}"
);
}
assert_eq!(
case.env.local_key_count(&case.bucket, KEY_PREFIX).await?,
count,
"every source object must be stored locally"
);
case.env
.assert_local_present(&case.bucket, &objects[count - 1].key, &objects[count - 1].body)
.await;
case.finish().await
}
@@ -0,0 +1,365 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Optional merged `ListObjectsV2` (`policy.list_through`, ODM-17,
//! rustfs/backlog#2164): full pagination over a source and a local namespace,
//! common-prefix union under a delimiter, the continuation-token contract, and
//! the two `source_error` behaviours when the source listing fails.
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env};
use crate::fake_s3_target::{FaultAction, Operation};
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-list-source";
const LIST_HEADER: &str = "x-rustfs-on-demand-migration-list";
/// Byte lengths that tell a local object from a source one in a listing.
const SOURCE_BODY_LEN: usize = 3;
const LOCAL_BODY_LEN: usize = 11;
fn body(len: usize) -> Bytes {
vec![b'x'; len].into()
}
/// RustFS migrating `bucket` from `SOURCE_BUCKET` with `list_through` on.
async fn list_through_env(bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.list_through = true;
adjust(spec);
})
.await
}
/// Every key the bucket lists, walked through the merged continuation token.
/// Also returns the size each page reported per key and the page sizes, so a
/// caller can assert who won a shared key and that no page exceeded `max_keys`.
async fn walk_listing(
env: &OdmTestEnv,
bucket: &str,
delimiter: Option<&str>,
max_keys: i32,
) -> Result<(Vec<(String, i64)>, Vec<String>, Vec<usize>), BoxError> {
let mut objects = Vec::new();
let mut prefixes = Vec::new();
let mut page_sizes = Vec::new();
let mut token: Option<String> = None;
for _ in 0..1000 {
let page = env
.client
.list_objects_v2()
.bucket(bucket)
.max_keys(max_keys)
.set_delimiter(delimiter.map(str::to_string))
.set_continuation_token(token.take())
.send()
.await?;
let listed = page.contents().len() + page.common_prefixes().len();
page_sizes.push(listed);
for object in page.contents() {
objects.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
}
for prefix in page.common_prefixes() {
prefixes.push(prefix.prefix().unwrap_or_default().to_string());
}
if !page.is_truncated().unwrap_or(false) {
return Ok((objects, prefixes, page_sizes));
}
token = Some(
page.next_continuation_token()
.ok_or("truncated page without a continuation token")?
.to_string(),
);
}
Err("merged listing did not terminate".into())
}
#[tokio::test]
async fn list_through_merges_the_whole_namespace_across_full_pagination() -> TestResult {
let bucket = "odm-list-merge";
let env = list_through_env(bucket, |_| {}).await?;
// 2000 source keys, 80 of them also local, plus 10 local-only keys that
// interleave between source keys ("obj-00010x" sorts after "obj-00010").
let source_keys: Vec<String> = (0..2000).map(|index| format!("obj-{index:05}")).collect();
let seeds: Vec<SeedObject> = source_keys
.iter()
.map(|key| SeedObject::new(key.clone(), body(SOURCE_BODY_LEN)))
.collect();
env.seed_source(SOURCE_BUCKET, &seeds);
let shared: Vec<String> = source_keys.iter().step_by(25).cloned().collect();
let local_only: Vec<String> = (0..10).map(|index| format!("obj-{:05}x", index * 7)).collect();
for key in shared.iter().chain(local_only.iter()) {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
}
let max_keys = 97;
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, None, max_keys).await?;
assert!(prefixes.is_empty(), "no delimiter means no common prefixes");
let mut expected: Vec<String> = source_keys.iter().chain(local_only.iter()).cloned().collect();
expected.sort();
expected.dedup();
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed, expected, "the merged listing is the sorted, deduplicated union");
assert!(
page_sizes.iter().all(|size| *size <= max_keys as usize),
"no page may exceed max_keys: {page_sizes:?}"
);
let shared_sizes: Vec<i64> = objects
.iter()
.filter(|(key, _)| shared.contains(key))
.map(|(_, size)| *size)
.collect();
assert_eq!(shared_sizes.len(), shared.len(), "every shared key is listed exactly once");
assert!(
shared_sizes.iter().all(|size| *size == LOCAL_BODY_LEN as i64),
"the local object wins a key both sides hold"
);
let source_sizes: Vec<i64> = objects
.iter()
.filter(|(key, _)| !shared.contains(key) && !local_only.contains(key))
.map(|(_, size)| *size)
.collect();
assert!(
source_sizes.iter().all(|size| *size == SOURCE_BODY_LEN as i64),
"source-only keys report the source's own size"
);
Ok(())
}
#[tokio::test]
async fn list_through_unions_common_prefixes_under_a_delimiter() -> TestResult {
let bucket = "odm-list-delimiter";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("p1/a", body(SOURCE_BODY_LEN)),
SeedObject::new("p1/b", body(SOURCE_BODY_LEN)),
SeedObject::new("p2/a", body(SOURCE_BODY_LEN)),
SeedObject::new("top-s", body(SOURCE_BODY_LEN)),
],
);
for key in ["p1/c", "p3/a", "top-l"] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
}
// A page size of two forces the prefix union to survive page boundaries.
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, Some("/"), 2).await?;
assert_eq!(prefixes, vec!["p1/", "p2/", "p3/"], "prefixes are unioned and deduplicated");
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed, vec!["top-l", "top-s"]);
assert!(page_sizes.iter().all(|size| *size <= 2), "{page_sizes:?}");
Ok(())
}
#[tokio::test]
async fn list_through_propagates_a_source_listing_failure() -> TestResult {
let bucket = "odm-list-propagate";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
env.client
.put_object()
.bucket(bucket)
.key("local")
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
env.source
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
let failure = env
.client
.list_objects_v2()
.bucket(bucket)
.send()
.await
.expect_err("propagate must surface the source failure");
let failure = failure.into_service_error();
assert_eq!(failure.meta().code(), Some("SourceUnavailable"), "{failure:?}");
// The next listing sees a healthy source again and merges both sides.
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed, vec!["local", "remote"]);
Ok(())
}
#[tokio::test]
async fn list_through_degrades_to_local_only_under_the_not_found_policy() -> TestResult {
let bucket = "odm-list-degrade";
let env = list_through_env(bucket, |spec| spec.policy.source_error = "not_found".to_string()).await?;
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
env.client
.put_object()
.bucket(bucket)
.key("local")
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
env.source
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
let degraded = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
assert_eq!(degraded.status, 200, "{}", String::from_utf8_lossy(&degraded.body));
assert_eq!(
degraded.header(LIST_HEADER),
Some("local_only"),
"a degraded listing must say so in the response header"
);
let xml = String::from_utf8_lossy(&degraded.body).to_string();
assert!(xml.contains("<Key>local</Key>"), "{xml}");
assert!(!xml.contains("<Key>remote</Key>"), "a degraded listing shows local state only: {xml}");
let healthy = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
assert_eq!(healthy.status, 200);
assert_eq!(healthy.header(LIST_HEADER), None, "a healthy merge carries no degradation marker");
assert!(String::from_utf8_lossy(&healthy.body).contains("<Key>remote</Key>"));
Ok(())
}
#[tokio::test]
async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
let bucket = "odm-list-token";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("a", body(SOURCE_BODY_LEN)),
SeedObject::new("b", body(SOURCE_BODY_LEN)),
SeedObject::new("c", body(SOURCE_BODY_LEN)),
],
);
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
let token = page.next_continuation_token().ok_or("first page must be truncated")?;
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":2").as_bytes());
let rejected = env
.raw_list_objects_v2(bucket, &format!("continuation-token={tampered}"))
.await?;
assert_eq!(
rejected.status,
400,
"a bumped token version is a client error: {}",
String::from_utf8_lossy(&rejected.body)
);
Ok(())
}
#[tokio::test]
async fn a_merged_token_keeps_paginating_after_list_through_is_turned_off() -> TestResult {
let bucket = "odm-list-token-off";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("s1", body(SOURCE_BODY_LEN)),
SeedObject::new("s2", body(SOURCE_BODY_LEN)),
],
);
for key in ["l1", "l2"] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
}
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
assert_eq!(page.contents()[0].key(), Some("l1"));
let token = page
.next_continuation_token()
.ok_or("first page must be truncated")?
.to_string();
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.list_through = false;
env.configure_and_wait(bucket, &spec).await?;
let resumed = env
.client
.list_objects_v2()
.bucket(bucket)
.max_keys(10)
.continuation_token(token)
.send()
.await?;
let listed: Vec<&str> = resumed.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(listed, vec!["l2"], "a merged token falls back to its local cursor");
Ok(())
}
#[tokio::test]
async fn a_local_delete_marker_hides_the_source_key_from_a_merged_listing() -> TestResult {
let bucket = "odm-list-delete-marker";
let env = list_through_env(bucket, |_| {}).await?;
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("kept", body(SOURCE_BODY_LEN)),
SeedObject::new("shadowed", body(SOURCE_BODY_LEN)),
],
);
env.client
.put_object()
.bucket(bucket)
.key("shadowed")
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
env.client.delete_object().bucket(bucket).key("shadowed").send().await?;
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(
listed,
vec!["kept"],
"a local delete marker shadows the source key the same way it does on GET"
);
Ok(())
}
@@ -0,0 +1,40 @@
// 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.
//! On-demand migration (ODM) end-to-end suite (rustfs/backlog#2147).
//!
//! `common` is the shared environment: one RustFS under test, one programmable
//! fake S3 source, admin-API wrappers, seeding and local-state assertions.
//! `harness_self_test` proves the harness itself; `get_basic_test` covers the
//! GET read-through (rustfs/backlog#2156) and `backfill_test` the background
//! backfill job (ODM-12, rustfs/backlog#2159); `list_through_test` covers the
//! optional merged `ListObjectsV2` (ODM-17, rustfs/backlog#2164). The fault, concurrency,
//! interaction and real-source matrix is rustfs/backlog#2158; its lane split
//! lives in `.config/nextest.toml` (fault / concurrency / real source run
//! nightly, the rest in the merge lane). `interop_test` is the provider
//! interoperability lane (ODM-20, rustfs/backlog#2167): the same case bodies
//! against the fake source locally and against a real provider named by the
//! environment in `.github/workflows/on-demand-migration-interop.yml`.
pub mod common;
mod backfill_test;
mod concurrency_test;
mod fault_test;
mod get_basic_test;
mod harness_self_test;
mod interaction_test;
mod interop_test;
mod list_through_test;
mod real_source_test;
@@ -0,0 +1,266 @@
// 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.
//! On-demand migration against a real RustFS source (rustfs/backlog#2158).
//!
//! These cases start a second (and, for the loop guard, a third) RustFS
//! process, so they carry the `_real_single_node` marker and run in the
//! nightly lane. A real source keeps no request journal, so "the source was
//! not consulted" is proven by removing the object from the source and
//! showing the read still succeeds, or by pointing the *second* server at a
//! fake source whose journal must stay empty.
use super::common::{
AdminResponse, BoxError, ODM_ADMIN_ROUTE, OdmSourceSpec, OdmTestEnv, RawResponse, SeedObject, start_source_rustfs,
start_source_rustfs_with_odm,
};
use crate::common::{RustFSTestEnvironment, signed_request};
use crate::fake_s3_target::{BucketMode, Operation};
use aws_sdk_s3::Client;
use bytes::Bytes;
use std::time::{Duration, Instant};
type TestResult = Result<(), BoxError>;
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Reinstalled configurations are applied asynchronously; every phase polls
/// for the new behavior instead of sleeping.
const APPLY_TIMEOUT: Duration = Duration::from_secs(30);
const SETTLE: Duration = Duration::from_secs(60);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// `PUT /rustfs/admin/v3/on-demand-migration/{bucket}` against any server,
/// not just the one under test.
async fn configure_odm(env: &RustFSTestEnvironment, bucket: &str, spec: &OdmSourceSpec) -> Result<AdminResponse, BoxError> {
let url = format!("{}{ODM_ADMIN_ROUTE}/{bucket}", env.url);
let body = serde_json::to_vec(&spec.to_json())?;
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body),
Some("application/json"),
)
.await?;
Ok(AdminResponse {
status: response.status().as_u16(),
body: response.text().await?,
})
}
async fn put_object(client: &Client, bucket: &str, key: &str, body: Bytes) -> TestResult {
client
.put_object()
.bucket(bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.send()
.await?;
Ok(())
}
/// Polls a read against the server under test until it answers `expected`.
/// This is how a reinstalled configuration is waited for when the source
/// keeps no journal to probe.
async fn wait_for_get_status(env: &OdmTestEnv, bucket: &str, key: &str, expected: u16) -> Result<RawResponse, BoxError> {
let deadline = Instant::now() + APPLY_TIMEOUT;
loop {
let response = env.raw_get(bucket, key).await?;
if response.status == expected {
return Ok(response);
}
if Instant::now() >= deadline {
return Err(format!(
"GET {bucket}/{key} stayed at {} instead of {expected}: {}",
response.status,
String::from_utf8_lossy(&response.body)
)
.into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Case 20: a second RustFS as the migration source — the pull, a HEAD
/// passthrough, a Range read, and both prefix knobs.
#[tokio::test]
async fn test_odm_rustfs_source_serves_pull_head_range_and_prefixes_real_single_node() -> TestResult {
let bucket = "odm-real-source";
let source_bucket = "odm-real-origin";
let source = start_source_rustfs().await?;
let source_client = source.create_s3_client();
source.create_test_bucket(source_bucket).await?;
let env = OdmTestEnv::start().await?;
env.rustfs.create_test_bucket(bucket).await?;
let spec = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
let configured = configure_odm(&env.rustfs, bucket, &spec).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
// Phase 1: a miss is pulled and stored; removing it from the source
// afterwards proves the second read never goes back to the source.
let pulled_key = "real/pulled.bin";
let pulled_body = payload(256 * 1024);
put_object(&source_client, source_bucket, pulled_key, pulled_body.clone()).await?;
let pulled = wait_for_get_status(&env, bucket, pulled_key, 200).await?;
assert_eq!(pulled.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(pulled.body, pulled_body, "the client receives the source bytes");
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?, "the pull must store the object");
source_client
.delete_object()
.bucket(source_bucket)
.key(pulled_key)
.send()
.await?;
let local = env.raw_get(bucket, pulled_key).await?;
assert_eq!(local.status, 200, "{}", String::from_utf8_lossy(&local.body));
assert_eq!(local.header(ODM_RESPONSE_HEADER), None, "a local hit is not marked");
assert_eq!(local.body, pulled_body, "the object is served from the local copy");
// Phase 2: HEAD proxies metadata without storing anything.
let head_key = "real/head-only.bin";
let head_body = payload(9_000);
put_object(&source_client, source_bucket, head_key, head_body.clone()).await?;
let head = env.raw_object_request(http::Method::HEAD, bucket, head_key, &[]).await?;
assert_eq!(head.status, 200, "HEAD must be answered from the source");
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(head.header("content-length"), Some(head_body.len().to_string().as_str()));
env.assert_local_absent(bucket, head_key).await;
// Phase 3: a Range read is passed through as a 206.
let range_key = "real/range.bin";
let range_body = payload(100_000);
put_object(&source_client, source_bucket, range_key, range_body.clone()).await?;
let ranged = env
.raw_object_request(http::Method::GET, bucket, range_key, &[("range", "bytes=100-199")])
.await?;
assert_eq!(ranged.status, 206, "{}", String::from_utf8_lossy(&ranged.body));
assert_eq!(ranged.header("content-range"), Some("bytes 100-199/100000"));
assert_eq!(ranged.body, range_body.slice(100..200));
// Phase 4: `filter.prefix` decides which local keys may consult the
// source at all.
let allowed_key = "allowed/doc.bin";
let denied_key = "denied/doc.bin";
let filtered_body = payload(4_096);
put_object(&source_client, source_bucket, allowed_key, filtered_body.clone()).await?;
put_object(&source_client, source_bucket, denied_key, filtered_body.clone()).await?;
let mut filtered = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
filtered.filter.prefix = Some("allowed/".to_string());
let response = configure_odm(&env.rustfs, bucket, &filtered).await?;
assert_eq!(response.status, 200, "{}", response.body);
let denied = wait_for_get_status(&env, bucket, denied_key, 404).await?;
assert_eq!(denied.header(ODM_RESPONSE_HEADER), None);
env.assert_local_absent(bucket, denied_key).await;
let allowed = wait_for_get_status(&env, bucket, allowed_key, 200).await?;
assert_eq!(allowed.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(allowed.body, filtered_body);
// Phase 5: `filter.source_prefix` rewrites the key on the way out, so a
// local key resolves to a different key in the source bucket.
let rewritten_key = "rewritten/doc.bin";
let rewritten_body = payload(2_048);
put_object(&source_client, source_bucket, &format!("archive/{rewritten_key}"), rewritten_body.clone()).await?;
let mut rewriting = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
rewriting.filter.source_prefix = Some("archive/".to_string());
let response = configure_odm(&env.rustfs, bucket, &rewriting).await?;
assert_eq!(response.status, 200, "{}", response.body);
let rewritten = wait_for_get_status(&env, bucket, rewritten_key, 200).await?;
assert_eq!(rewritten.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(rewritten.body, rewritten_body, "the source prefix is prepended to the local key");
Ok(())
}
/// Case 21: two migrating servers pointed at each other must not build a
/// request loop. The middle server also has a fake source of its own, whose
/// journal is the evidence: a key it would happily fetch for a direct client
/// is never fetched for a request that arrived with the anti-loop marker.
#[tokio::test]
async fn test_odm_chained_sources_stop_at_the_loop_guard_real_single_node() -> TestResult {
let bucket = "odm-loop-guard";
let fake_bucket = "odm-loop-fake";
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(fake_bucket, BucketMode::Unversioned);
env.rustfs.create_test_bucket(bucket).await?;
let middle = start_source_rustfs_with_odm().await?;
let middle_client = middle.create_s3_client();
middle.create_test_bucket(bucket).await?;
let middle_spec = OdmSourceSpec::for_fake_source(&env.source, fake_bucket);
let configured = configure_odm(&middle, bucket, &middle_spec).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
let chained = OdmSourceSpec::for_rustfs_source(&middle, bucket);
let configured = configure_odm(&env.rustfs, bucket, &chained).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
// The first hop works: an object that only the middle server holds is
// migrated to the server under test.
let present_key = "loop/present.bin";
let present_body = payload(16 * 1024);
put_object(&middle_client, bucket, present_key, present_body.clone()).await?;
let served = wait_for_get_status(&env, bucket, present_key, 200).await?;
assert_eq!(served.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(served.body, present_body, "the first hop serves the middle server's object");
// The second hop does not: this key exists only on the middle server's
// own source, and the anti-loop marker stops the chain there.
let guarded_key = "loop/chain-guard.bin";
let guarded_body = payload(8 * 1024);
env.seed_source(fake_bucket, &[SeedObject::new(guarded_key, guarded_body.clone())]);
let guarded = env.raw_get(bucket, guarded_key).await?;
assert_eq!(guarded.status, 404, "{}", String::from_utf8_lossy(&guarded.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, guarded_key),
0,
"a chained request must not reach a third source"
);
assert_eq!(env.source.count_requests(Operation::GetObject, guarded_key), 0);
// Proof that the guard, and not a broken configuration, is what stopped
// it: the same key served directly by the middle server does reach the
// fake source.
let direct = middle_client.get_object().bucket(bucket).key(guarded_key).send().await?;
assert_eq!(direct.body.collect().await?.into_bytes(), guarded_body);
assert_eq!(
env.source.count_requests(Operation::GetObject, guarded_key),
1,
"an unmarked request does consult the middle server's source"
);
// Now make the pair mutual and prove the read still terminates.
let mutual = OdmSourceSpec::for_rustfs_source(&env.rustfs, bucket);
let configured = configure_odm(&middle, bucket, &mutual).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
let mutual_key = "loop/mutual.bin";
let started = Instant::now();
let response = wait_for_get_status(&env, bucket, mutual_key, 404).await?;
assert_eq!(response.header(ODM_RESPONSE_HEADER), None);
assert!(
started.elapsed() < Duration::from_secs(10),
"a mutual configuration must not loop, took {:?}",
started.elapsed()
);
assert_eq!(
env.source.count_requests(Operation::HeadObject, mutual_key),
0,
"the fake source is out of the chain once the pair is mutual"
);
Ok(())
}
@@ -856,6 +856,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn scanner_dirty_usage_snapshot(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn background_heal_status(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::BackgroundHealStatusRequest>,
@@ -904,6 +911,13 @@ impl NodeService for MinimalLockNodeService {
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadTransitionTierConfigResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn tier_daily_stats(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::TierDailyStatsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::TierDailyStatsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
}
/// Spawn a gRPC lock server on a random port
@@ -35,19 +35,22 @@ mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::time::{Duration, Instant, interval};
use tokio::net::TcpStream;
use tokio::time::{Duration, Instant, interval, sleep, timeout};
use tracing::info;
const ENABLE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E";
const NAMESPACE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E_IN_NAMESPACE";
const LOG_DIR_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_LOG_DIR";
const TARGET_NODE: usize = 1;
const TARGET_DRIVE: usize = 0;
const MOUNT_SIZE: &str = "size=128m,mode=0700";
const ABSENT_SCANNER_OBSERVATION_TIMEOUT_SECS: u64 = 180;
const REPLACEMENT_RECOVERY_DIR: &str = ".rustfs.sys/buckets/ahm-replacement";
const REPLACEMENT_INTENT_SUFFIX: &str = "_ahm_replacement_intent.json";
const REPLACEMENT_COMPLETION_PROOF_SUFFIX: &str = "_ahm_replacement_completion_proof.json";
const RESUME_CHECKPOINT_SUFFIX: &str = "_ahm_checkpoint.json";
const FAULT_WINDOW_OBJECT_COUNT: usize = 24;
const FAULT_WINDOW_OBJECT_BYTES: usize = 32 * 1024 * 1024;
#[derive(Debug)]
struct BaselineVersion {
@@ -65,6 +68,12 @@ mod tests {
CompletedWithIncomplete(BTreeSet<String>),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ReplacementScenario {
Baseline,
MidRebuildIoFault,
}
struct MountNamespaceGuard {
mounts: Vec<PathBuf>,
}
@@ -102,6 +111,19 @@ mod tests {
impl FaultableBlockMount {
fn mount(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
Self::mount_with_live_recovery(target, image_root, label, false)
}
fn mount_live_recovery(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
Self::mount_with_live_recovery(target, image_root, label, true)
}
fn mount_with_live_recovery(
target: &Path,
image_root: &Path,
label: &str,
live_recovery: bool,
) -> Result<Self, Box<dyn Error + Send + Sync>> {
fs::create_dir_all(image_root)?;
let image = image_root.join(format!("{label}.img"));
let file = fs::File::create(&image)?;
@@ -114,7 +136,15 @@ mod tests {
return Err("losetup --find --show returned an empty loop device".into());
}
run_command("mkfs.ext4", &["-F", &loop_device])?;
if live_recovery {
// Keep the filesystem and RustFS' persistent root descriptor attached
// across the transient all-block EIO. A journaling ext4 abort requires
// an unmount to recover, which would test process/disk reattachment
// instead of live I/O recovery.
run_command("mkfs.ext4", &["-F", "-O", "^has_journal", &loop_device])?;
} else {
run_command("mkfs.ext4", &["-F", &loop_device])?;
}
let sectors = run_command_stdout("blockdev", &["--getsz", &loop_device])?;
let dm_name = format!("rustfs_e2e_{label}_{}", std::process::id());
let table = format!("0 {sectors} linear {loop_device} 0");
@@ -122,7 +152,11 @@ mod tests {
run_command("dmsetup", &["create", &dm_name, "--table", &table])?;
let target_arg = path_to_string(target, "faultable mount target")?;
run_command("mount", &[&mapper, &target_arg])?;
if live_recovery {
run_command("mount", &["-o", "errors=continue", &mapper, &target_arg])?;
} else {
run_command("mount", &[&mapper, &target_arg])?;
}
Ok(Self {
target: target.to_path_buf(),
@@ -142,18 +176,42 @@ mod tests {
run_command("dmsetup", &["resume", &self.dm_name])
}
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
fn verify_raw_io_is_unavailable(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let mapper = format!("/dev/mapper/{}", self.dm_name);
let output = Command::new("dd")
.env("LC_ALL", "C")
.arg(format!("if={mapper}"))
.args(["of=/dev/null", "bs=4096", "count=1", "iflag=direct", "status=none"])
.output()?;
if output.status.success() {
return Err(format!("dm-error target unexpectedly allowed a raw read from {mapper}").into());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("Input/output error") {
return Err(format!("raw read from dm-error target failed unexpectedly: {stderr}").into());
}
Ok(())
}
fn restore_linear_table(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
let linear_table = format!("0 {sectors} linear {} 0", self.loop_device);
run_command("dmsetup", &["suspend", &self.dm_name])?;
// An ext4 journal abort can leave the mounted filesystem internally
// read-only. Avoid dmsetup's filesystem freeze/flush in that state;
// all I/O sent to the error target has already completed with EIO.
run_command("dmsetup", &["suspend", "--noflush", &self.dm_name])?;
run_command("dmsetup", &["load", &self.dm_name, "--table", &linear_table])?;
run_command("dmsetup", &["resume", &self.dm_name])
}
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
self.restore_linear_table()
}
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut first_error: Option<Box<dyn Error + Send + Sync>> = None;
if self.dm_created {
let _ = self.restore_available();
let _ = self.restore_linear_table();
}
if self.mounted {
if let Err(error) = detach_mount(&self.target) {
@@ -194,6 +252,72 @@ mod tests {
}
}
struct ZramBlockMount {
target: PathBuf,
device: String,
mounted: bool,
}
impl ZramBlockMount {
fn reserve(target: &Path) -> Result<Self, Box<dyn Error + Send + Sync>> {
if !Path::new("/dev/zram-control").exists() {
run_command("modprobe", &["zram"])?;
}
let device = run_command_stdout("zramctl", &["--find", "--size", "256M"])?;
if device.is_empty() {
return Err("zramctl --find --size returned an empty device".into());
}
Ok(Self {
target: target.to_path_buf(),
device,
mounted: false,
})
}
fn mount_target(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let result = (|| {
run_command("mkfs.ext4", &["-F", &self.device])?;
let target_arg = path_to_string(&self.target, "zram replacement mount target")?;
run_command("mount", &[&self.device, &target_arg])
})();
if let Err(error) = result {
let _ = self.cleanup();
return Err(error);
}
self.mounted = true;
Ok(())
}
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut first_error: Option<Box<dyn Error + Send + Sync>> = None;
if self.mounted {
if let Err(error) = detach_mount(&self.target) {
first_error.get_or_insert(error);
} else {
self.mounted = false;
}
}
if !self.device.is_empty() {
if let Err(error) = run_command("zramctl", &["--reset", &self.device]) {
first_error.get_or_insert(error);
} else {
self.device.clear();
}
}
if let Some(error) = first_error {
return Err(error);
}
Ok(())
}
}
impl Drop for ZramBlockMount {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
fn checked_command_output(program: &str, args: &[&str]) -> Result<std::process::Output, Box<dyn Error + Send + Sync>> {
let output = Command::new(program).args(args).output()?;
if output.status.success() {
@@ -298,6 +422,18 @@ mod tests {
Err(format!("{ENABLE_ENV}=1 requires root or CAP_SYS_ADMIN; unshare exited with status {status}").into())
}
fn replacement_node_log_path(
cluster_temp_dir: &str,
parity: usize,
node_index: usize,
) -> Result<PathBuf, Box<dyn Error + Send + Sync>> {
let log_dir = std::env::var_os(LOG_DIR_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(cluster_temp_dir));
fs::create_dir_all(&log_dir)?;
Ok(log_dir.join(format!("replacement-ec{parity}-node{node_index}-{}.log", std::process::id())))
}
fn payload(len: usize, seed: u8) -> Vec<u8> {
let mut next = seed;
(0..len)
@@ -374,7 +510,11 @@ mod tests {
Ok((completed.version_id().map(str::to_owned), digest))
}
async fn seed_baseline(client: &Client, target_disk: &Path) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
async fn seed_baseline(
client: &Client,
target_disk: &Path,
extra_object_count: usize,
) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
let plain_bucket = "priv-replacement-plain";
let versioned_bucket = "priv-replacement-versions";
let null_bucket = "priv-replacement-null";
@@ -437,7 +577,7 @@ mod tests {
put_object_version(client, null_bucket, "null/current.bin", payload(512 * 1024, 8)).await?;
versions.push((null_bucket, "null/current.bin", version_id, Some(body_sha256)));
let versions = versions
let mut versions = versions
.into_iter()
.map(|(bucket, key, version_id, body_sha256)| {
let expected = census_object_version_on_disk(target_disk, bucket, key, version_id.as_deref())?;
@@ -453,6 +593,23 @@ mod tests {
})
})
.collect::<Result<Vec<_>, Box<dyn Error + Send + Sync>>>()?;
for index in 0..extra_object_count {
let key = format!("fault-window/object-{index:04}.bin");
let seed = u8::try_from(index + 32)?;
let (version_id, body_sha256) =
put_object_version(client, plain_bucket, &key, payload(FAULT_WINDOW_OBJECT_BYTES, seed)).await?;
let expected = census_object_version_on_disk(target_disk, plain_bucket, &key, version_id.as_deref())?;
if !expected.is_complete() {
return Err(format!("fault-window baseline census is incomplete for {plain_bucket}/{key}: {expected:?}").into());
}
versions.push(BaselineVersion {
bucket: plain_bucket.to_string(),
key,
version_id,
body_sha256: Some(body_sha256),
expected,
});
}
let inline = versions
.iter()
.find(|version| version.key == "history/inline.bin")
@@ -472,8 +629,20 @@ mod tests {
if let Some(version_id) = &version.version_id {
request = request.version_id(version_id);
}
let response = request.send().await?;
let body = response.body.collect().await?.into_bytes();
let response = request.send().await.map_err(|error| {
format!("body GET failed for {}/{}@{:?}: {error}", version.bucket, version.key, version.version_id)
})?;
let body = response
.body
.collect()
.await
.map_err(|error| {
format!(
"body stream failed for {}/{}@{:?}: {error}",
version.bucket, version.key, version.version_id
)
})?
.into_bytes();
assert_eq!(
sha256_hex(&body),
*expected_sha256,
@@ -582,81 +751,6 @@ mod tests {
Ok(())
}
fn log_tail(log: &str) -> String {
let mut lines = log.lines().rev().take(80).collect::<Vec<_>>();
lines.reverse();
lines.join("\n")
}
fn log_len(path: &Path) -> Result<u64, Box<dyn Error + Send + Sync>> {
match fs::metadata(path) {
Ok(metadata) => Ok(metadata.len()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(error) => Err(format!("failed to stat target node log {path:?}: {error}").into()),
}
}
fn log_from_offset(path: &Path, offset: u64) -> Result<String, Box<dyn Error + Send + Sync>> {
let log = match fs::read(path) {
Ok(log) => log,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(format!("failed to read target node log {path:?}: {error}").into()),
};
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(log.len());
Ok(String::from_utf8_lossy(&log[start..]).into_owned())
}
fn live_disk_loss_scan_completed(log: &str, target_disk: &Path) -> bool {
let target = target_disk.to_string_lossy();
let mut saw_live_loss = false;
for line in log.lines() {
if line.contains("Heal auto-scan disk inspection failed")
&& line.contains("check_failed")
&& line.contains(target.as_ref())
{
saw_live_loss = true;
continue;
}
if saw_live_loss && (line.contains("Heal auto disk scanner idle") || line.contains("Heal auto-scan cycle completed"))
{
return true;
}
}
false
}
fn live_disk_loss_scan_completed_from_path(
log_path: &Path,
start_offset: u64,
target_disk: &Path,
) -> Result<bool, Box<dyn Error + Send + Sync>> {
Ok(live_disk_loss_scan_completed(&log_from_offset(log_path, start_offset)?, target_disk))
}
async fn wait_for_live_disk_loss_observation(
log_path: &Path,
target_disk: &Path,
start_offset: u64,
timeout_secs: u64,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
let mut tick = interval(Duration::from_secs(1));
loop {
if live_disk_loss_scan_completed_from_path(log_path, start_offset, target_disk)? {
return Ok(());
}
if Instant::now() >= deadline {
let log = log_from_offset(log_path, start_offset)?;
return Err(format!(
"scanner did not finish a live target-loss scan for {target_disk:?} within {timeout_secs}s; log tail:\n{}",
log_tail(&log)
)
.into());
}
tick.tick().await;
}
}
fn cluster_status_is_definitive(status: &serde_json::Value) -> Result<bool, Box<dyn Error + Send + Sync>> {
status["cluster"]["definitive"]
.as_bool()
@@ -707,6 +801,105 @@ mod tests {
.collect()
}
fn target_record_details(
status: &serde_json::Value,
target_disk: &Path,
) -> Result<Vec<(String, String)>, Box<dyn Error + Send + Sync>> {
let target = target_disk.to_string_lossy();
let records = status["cluster"]["records"]
.as_array()
.ok_or_else(|| format!("replacement recovery status omitted cluster.records: {status}"))?;
records
.iter()
.filter(|record| {
record["targetSlots"]
.as_array()
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.any(|slot| slot.contains(target.as_ref()))
})
.map(|record| {
let task_id = record["taskId"]
.as_str()
.filter(|task_id| !task_id.is_empty())
.ok_or_else(|| format!("replacement recovery record omitted taskId: {record}"))?;
let state = record["state"]
.as_str()
.filter(|state| !state.is_empty())
.ok_or_else(|| format!("replacement recovery record omitted state: {record}"))?;
Ok((task_id.to_string(), state.to_string()))
})
.collect()
}
fn running_target_generation(
status: &serde_json::Value,
target_disk: &Path,
) -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
if !cluster_status_is_definitive(status)? {
return Ok(None);
}
let records = target_record_details(status, target_disk)?;
if records.len() == 1 && records[0].1 == "running" {
return Ok(Some(records[0].0.clone()));
}
Ok(None)
}
fn assert_target_generation_nonterminal(
status: &serde_json::Value,
target_disk: &Path,
expected_task_id: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
if !cluster_status_is_definitive(status)? {
return Err(format!("replacement recovery became non-definitive during target EIO: {status}").into());
}
let records = target_record_details(status, target_disk)?;
let matching = records
.iter()
.filter(|(task_id, _)| task_id == expected_task_id)
.collect::<Vec<_>>();
if matching.len() != 1 {
return Err(format!(
"replacement generation {expected_task_id} must remain uniquely observable during target EIO: {records:?}"
)
.into());
}
match matching[0].1.as_str() {
"waiting_for_replacement" | "running" | "incomplete" => Ok(()),
state => Err(format!(
"replacement generation {expected_task_id} reached invalid state {state:?} during target EIO: {status}"
)
.into()),
}
}
fn assert_target_generation_completed(
status: &serde_json::Value,
target_disk: &Path,
expected_task_id: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
if !cluster_status_is_definitive(status)? {
return Err(format!("completed replacement recovery status is non-definitive: {status}").into());
}
let records = target_record_details(status, target_disk)?;
if records == [(expected_task_id.to_string(), "completed".to_string())] {
return Ok(());
}
Err(
format!("replacement generation {expected_task_id} did not retain its identity through EIO recovery: {records:?}")
.into(),
)
}
fn is_transient_recovery_version_absence(error: &(dyn Error + 'static)) -> bool {
matches!(
error.downcast_ref::<rustfs_filemeta::Error>(),
Some(rustfs_filemeta::Error::FileVersionNotFound)
)
}
fn incomplete_versions(
target_disk: &Path,
versions: &[BaselineVersion],
@@ -714,7 +907,21 @@ mod tests {
let mut missing = BTreeSet::new();
for version in versions {
let actual =
census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref())?;
match census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref()) {
Ok(actual) => actual,
// During replacement recovery, xl.meta may arrive before this
// particular historical version. The generic census helper
// correctly reports that as an error; this progress poll must
// instead wait for the version to be restored.
Err(error) if is_transient_recovery_version_absence(error.as_ref()) => {
missing.insert(format!(
"{}/{}@{:?}: version metadata not yet present on replacement",
version.bucket, version.key, version.version_id
));
continue;
}
Err(error) => return Err(error),
};
if !actual.matches_manifest(&version.expected) {
missing.insert(format!("{}/{}@{:?}: {actual:?}", version.bucket, version.key, version.version_id));
}
@@ -722,6 +929,165 @@ mod tests {
Ok(missing)
}
async fn wait_for_partial_replacement<'a>(
cluster: &RustFSTestClusterEnvironment,
target_disk: &Path,
versions: &'a [BaselineVersion],
timeout_secs: u64,
) -> Result<(usize, &'a BaselineVersion, String), Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop {
let missing = incomplete_versions(target_disk, versions)?;
let completed = versions.len().saturating_sub(missing.len());
if completed > 0 && completed < versions.len() {
let witness = versions.iter().find(|version| {
census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref())
.is_ok_and(|actual| actual.matches_manifest(&version.expected))
});
if let Some(witness) = witness {
let status = replacement_status(cluster).await?;
if let Some(task_id) = running_target_generation(&status, target_disk)? {
return Ok((completed, witness, task_id));
}
}
}
if completed == versions.len() {
return Err(format!(
"replacement rebuilt all {} baseline versions before target EIO could be injected",
versions.len()
)
.into());
}
if Instant::now() >= deadline {
let status = replacement_status(cluster).await?;
return Err(format!(
"replacement made no observable running partial progress within {timeout_secs}s: completed={completed}/{} status={status}",
versions.len()
)
.into());
}
sleep(Duration::from_millis(10)).await;
}
}
fn cluster_process_ids(cluster: &RustFSTestClusterEnvironment) -> Result<Vec<u32>, Box<dyn Error + Send + Sync>> {
cluster
.nodes
.iter()
.enumerate()
.map(|(index, node)| {
node.process
.as_ref()
.map(std::process::Child::id)
.ok_or_else(|| format!("cluster node {index} process is not running").into())
})
.collect()
}
async fn assert_cluster_processes_and_listeners_unchanged(
cluster: &mut RustFSTestClusterEnvironment,
expected_pids: &[u32],
) -> Result<(), Box<dyn Error + Send + Sync>> {
if cluster.nodes.len() != expected_pids.len() {
return Err("cluster node count changed during target EIO".into());
}
for (index, (node, expected_pid)) in cluster.nodes.iter_mut().zip(expected_pids).enumerate() {
let process = node
.process
.as_mut()
.ok_or_else(|| format!("cluster node {index} process disappeared during target EIO"))?;
if process.id() != *expected_pid {
return Err(format!(
"cluster node {index} PID changed during target EIO: expected {expected_pid}, got {}",
process.id()
)
.into());
}
if let Some(status) = process.try_wait()? {
return Err(format!("cluster node {index} exited during target EIO with {status}").into());
}
match timeout(Duration::from_secs(2), TcpStream::connect(&node.address)).await {
Ok(Ok(stream)) => drop(stream),
Ok(Err(error)) => {
return Err(format!("cluster node {index} TCP listener failed during target EIO: {error}").into());
}
Err(_) => return Err(format!("cluster node {index} TCP listener timed out during target EIO").into()),
}
}
Ok(())
}
async fn exercise_mid_rebuild_io_fault(
cluster: &mut RustFSTestClusterEnvironment,
replacement_mount: &FaultableBlockMount,
target_disk: &Path,
versions: &[BaselineVersion],
) -> Result<String, Box<dyn Error + Send + Sync>> {
let partial_timeout_secs = std::env::var("RUSTFS_HEAL_DISK_IO_PARTIAL_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(120);
let (partial_count, witness, task_id) =
wait_for_partial_replacement(cluster, target_disk, versions, partial_timeout_secs).await?;
let expected_pids = cluster_process_ids(cluster)?;
replacement_mount
.make_unavailable()
.map_err(|error| format!("failed to install dm-error on the active replacement: {error}"))?;
let fault_result = async {
replacement_mount
.verify_raw_io_is_unavailable()
.map_err(|error| format!("active replacement dm-error was not proven by direct I/O: {error}"))?;
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
let observation_deadline = Instant::now() + Duration::from_secs(2);
loop {
let status = timeout(Duration::from_secs(5), replacement_status(cluster))
.await
.map_err(|_| "replacement recovery status timed out during target EIO")??;
assert_target_generation_nonterminal(&status, target_disk, &task_id)?;
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
if Instant::now() >= observation_deadline {
break;
}
sleep(Duration::from_millis(100)).await;
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
}
.await;
let restore_result = replacement_mount
.restore_available()
.map_err(|error| format!("failed to restore the active replacement after dm-error: {error}"));
if let Err(error) = fault_result {
if let Err(restore_error) = restore_result {
info!(%restore_error, "replacement restore also failed while preserving target EIO failure");
}
return Err(error);
}
restore_result?;
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
let actual = census_object_version_on_disk(target_disk, &witness.bucket, &witness.key, witness.version_id.as_deref())?;
if !actual.matches_manifest(&witness.expected) {
return Err(format!(
"witnessed replacement shard did not survive target EIO for {}/{}@{:?}: {actual:?}",
witness.bucket, witness.key, witness.version_id
)
.into());
}
let completed_after_restore = versions
.len()
.saturating_sub(incomplete_versions(target_disk, versions)?.len());
if completed_after_restore < partial_count {
return Err(format!(
"replacement progress regressed across target EIO: before={partial_count}, after={completed_after_restore}"
)
.into());
}
Ok(task_id)
}
fn replacement_completion_state(
status: &serde_json::Value,
target_disk: &Path,
@@ -810,7 +1176,11 @@ mod tests {
}
}
async fn run_replacement_e2e(parity: usize, test_name: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
async fn run_replacement_e2e(
parity: usize,
test_name: &str,
scenario: ReplacementScenario,
) -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
if !privileged_run_enabled()? {
return Ok(());
@@ -822,13 +1192,15 @@ mod tests {
let mut mount_ns = MountNamespaceGuard::new()?;
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(3, 4)).await?;
let target_log_path = PathBuf::from(&cluster.temp_dir).join(format!("replacement-node{TARGET_NODE}.log"));
cluster.set_node_capture_log_path(TARGET_NODE, target_log_path.to_string_lossy())?;
for node_index in 0..cluster.nodes.len() {
let node_log_path = replacement_node_log_path(&cluster.temp_dir, parity, node_index)?;
cluster.set_node_capture_log_path(node_index, node_log_path.to_string_lossy())?;
}
let target_disk = PathBuf::from(&cluster.nodes[TARGET_NODE].data_dirs[TARGET_DRIVE]);
// Each drive below is an independent tmpfs mount, so this privileged
// path must exercise the production distinct-device/readiness fences.
// The blank target uses a temporary zram block device, so the
// replacement readiness fence sees no root or sibling alias.
cluster.extra_env.retain(|(key, _)| key != "RUSTFS_UNSAFE_BYPASS_DISK_CHECK");
let image_root = PathBuf::from(&cluster.temp_dir).join("replacement-faultable-images");
let image_root = PathBuf::from(&cluster.temp_dir).join("replacement-block-images");
let mut target_mount = None;
for (node_index, node) in cluster.nodes.iter().enumerate() {
for (drive_index, drive) in node.data_dirs.iter().enumerate() {
@@ -845,6 +1217,10 @@ mod tests {
}
}
let mut target_mount = target_mount.ok_or("target drive was not mounted with the faultable block fixture")?;
let mut zram_replacement = match scenario {
ReplacementScenario::Baseline => Some(ZramBlockMount::reserve(&target_disk)?),
ReplacementScenario::MidRebuildIoFault => None,
};
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "true");
@@ -852,28 +1228,55 @@ mod tests {
cluster.set_env("RUSTFS_SCANNER_CYCLE", "1");
cluster.set_env("RUSTFS_SCANNER_START_DELAY_SECS", "0");
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
cluster.set_node_env(TARGET_NODE, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
if scenario == ReplacementScenario::MidRebuildIoFault {
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
}
for node_index in 0..cluster.nodes.len() {
cluster.set_node_env(node_index, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
}
cluster.start().await?;
let clients = cluster.create_all_clients()?;
let versions = seed_baseline(&clients[0], &target_disk).await?;
verify_bodies(&clients[0], &versions).await?;
let extra_object_count = match scenario {
ReplacementScenario::Baseline => 0,
ReplacementScenario::MidRebuildIoFault => FAULT_WINDOW_OBJECT_COUNT,
};
let versions = seed_baseline(&clients[0], &target_disk, extra_object_count)
.await
.map_err(|error| format!("pre-fault baseline seeding failed: {error}"))?;
verify_bodies(&clients[0], &versions)
.await
.map_err(|error| format!("pre-fault body verification failed: {error}"))?;
let live_loss_log_offset = log_len(&target_log_path)?;
target_mount.make_unavailable()?;
wait_for_live_disk_loss_observation(
&target_log_path,
&target_disk,
live_loss_log_offset,
ABSENT_SCANNER_OBSERVATION_TIMEOUT_SECS,
)
.await?;
assert_no_replacement_status_records(&cluster, &target_disk).await?;
assert_no_replacement_admission_artifacts(&cluster, &target_disk)?;
target_mount
.make_unavailable()
.map_err(|error| format!("failed to install the dm-error target: {error}"))?;
target_mount
.verify_raw_io_is_unavailable()
.map_err(|error| format!("dm-error target was not proven by a direct raw read: {error}"))?;
assert_no_replacement_status_records(&cluster, &target_disk)
.await
.map_err(|error| format!("live-fault replacement status check failed: {error}"))?;
assert_no_replacement_admission_artifacts(&cluster, &target_disk)
.map_err(|error| format!("live-fault replacement artifact check failed: {error}"))?;
cluster.stop_node(TARGET_NODE)?;
cluster.stop_node_gracefully(TARGET_NODE).await?;
target_mount.cleanup()?;
mount_ns.mount_tmpfs(&target_disk, &format!("rustfs-e2e-p{parity}-replacement"))?;
let mut faultable_replacement = match scenario {
ReplacementScenario::Baseline => {
zram_replacement
.as_mut()
.ok_or("baseline replacement zram was not reserved")?
.mount_target()?;
None
}
ReplacementScenario::MidRebuildIoFault => Some(FaultableBlockMount::mount_live_recovery(
&target_disk,
&image_root,
&format!("p{parity}_replacement_node{TARGET_NODE}_drive{TARGET_DRIVE}"),
)?),
};
let missing_before_restart = incomplete_versions(&target_disk, &versions)?;
assert_eq!(
missing_before_restart.len(),
@@ -882,46 +1285,42 @@ mod tests {
);
cluster.start_node(TARGET_NODE).await?;
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
verify_bodies(&clients[0], &versions).await?;
let recovery_result = async {
let faulted_task_id = match faultable_replacement.as_ref() {
Some(replacement) => {
Some(exercise_mid_rebuild_io_fault(&mut cluster, replacement, &target_disk, &versions).await?)
}
None => None,
};
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
if let Some(task_id) = faulted_task_id {
let status = replacement_status(&cluster).await?;
assert_target_generation_completed(&status, &target_disk, &task_id)?;
}
verify_bodies(&clients[0], &versions).await
}
.await;
let stop_result = cluster.stop_node_gracefully(TARGET_NODE).await;
let replacement_cleanup_result = match faultable_replacement.as_mut() {
Some(replacement) => replacement.cleanup(),
None => zram_replacement
.as_mut()
.ok_or("baseline replacement zram disappeared before cleanup")?
.cleanup(),
};
Ok(())
}
if let Err(error) = recovery_result {
if let Err(stop_error) = stop_result {
info!(%stop_error, "replacement target stop failed while preserving recovery failure");
}
if let Err(cleanup_error) = replacement_cleanup_result {
info!(%cleanup_error, "replacement zram cleanup failed while preserving recovery failure");
}
return Err(error);
}
stop_result?;
replacement_cleanup_result?;
#[test]
fn live_loss_barrier_requires_scanner_failure_after_log_offset() -> Result<(), Box<dyn Error + Send + Sync>> {
let target = Path::new("/mnt/target");
assert!(live_disk_loss_scan_completed(
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto-scan cycle completed",
target
));
assert!(live_disk_loss_scan_completed(
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
target
));
assert!(!live_disk_loss_scan_completed(
"Heal auto disk scanner idle\nHeal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed",
target
));
assert!(!live_disk_loss_scan_completed(
"event=disk_health_check_failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
target
));
assert!(!live_disk_loss_scan_completed(
"Heal auto-scan disk inspection failed endpoint=/mnt/other disk_state=check_failed\nHeal auto disk scanner idle",
target
));
let path = std::env::temp_dir().join(format!("rustfs-replacement-scan-{}.log", std::process::id()));
let stale =
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
fs::write(&path, stale)?;
let offset = log_len(&path)?;
assert!(!live_disk_loss_scan_completed_from_path(&path, offset, target)?);
let fresh =
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
fs::write(&path, format!("{stale}{fresh}"))?;
assert!(live_disk_loss_scan_completed_from_path(&path, offset, target)?);
fs::remove_file(path)?;
Ok(())
}
@@ -954,6 +1353,15 @@ mod tests {
);
}
#[test]
fn recovery_census_only_treats_missing_version_as_transient() {
let missing_version: Box<dyn Error + Send + Sync> = Box::new(rustfs_filemeta::Error::FileVersionNotFound);
let missing_file: Box<dyn Error + Send + Sync> = Box::new(rustfs_filemeta::Error::FileNotFound);
assert!(is_transient_recovery_version_absence(missing_version.as_ref()));
assert!(!is_transient_recovery_version_absence(missing_file.as_ref()));
}
#[tokio::test]
async fn completion_poll_samples_census_before_status() {
let order = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
@@ -1046,6 +1454,65 @@ mod tests {
assert_eq!(status_samples.borrow().len(), 1);
}
#[test]
fn target_eio_status_preserves_one_nonterminal_generation() {
let target = Path::new("/mnt/target");
for state in ["waiting_for_replacement", "running", "incomplete"] {
let status = serde_json::json!({
"cluster": {
"definitive": true,
"records": [{
"taskId": "generation-a",
"state": state,
"targetSlots": ["http://127.0.0.1:9000/mnt/target"]
}]
}
});
assert!(assert_target_generation_nonterminal(&status, target, "generation-a").is_ok());
}
let running = serde_json::json!({
"cluster": {
"definitive": true,
"records": [{
"taskId": "generation-a",
"state": "running",
"targetSlots": ["/mnt/target"]
}]
}
});
assert_eq!(running_target_generation(&running, target).unwrap().as_deref(), Some("generation-a"));
}
#[test]
fn target_eio_status_rejects_false_or_replaced_completion() {
let target = Path::new("/mnt/target");
let completed = serde_json::json!({
"cluster": {
"definitive": true,
"records": [{
"taskId": "generation-a",
"state": "completed",
"targetSlots": ["/mnt/target"]
}]
}
});
assert!(assert_target_generation_nonterminal(&completed, target, "generation-a").is_err());
assert!(assert_target_generation_completed(&completed, target, "generation-a").is_ok());
assert!(assert_target_generation_completed(&completed, target, "generation-b").is_err());
let duplicate = serde_json::json!({
"cluster": {
"definitive": true,
"records": [
{"taskId": "generation-a", "state": "running", "targetSlots": ["/mnt/target"]},
{"taskId": "generation-a", "state": "incomplete", "targetSlots": ["/mnt/target"]}
]
}
});
assert!(assert_target_generation_nonterminal(&duplicate, target, "generation-a").is_err());
}
#[test]
fn absent_status_requires_definitive_empty_records() {
let target = Path::new("/mnt/target");
@@ -1066,6 +1533,7 @@ mod tests {
run_replacement_e2e(
4,
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec8_plus_4_without_admin_heal",
ReplacementScenario::Baseline,
)
.await
}
@@ -1079,6 +1547,20 @@ mod tests {
run_replacement_e2e(
6,
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec6_plus_6_without_admin_heal",
ReplacementScenario::Baseline,
)
.await
}
/// Linux mount namespaces are per-thread; keep mount setup and process
/// spawning on one OS thread so child RustFS nodes inherit the test mounts.
#[tokio::test(flavor = "current_thread")]
#[ignore = "requires Linux root/CAP_SYS_ADMIN and RUSTFS_PRIVILEGED_REPLACEMENT_E2E=1"]
async fn test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio() -> Result<(), Box<dyn Error + Send + Sync>> {
run_replacement_e2e(
4,
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio",
ReplacementScenario::MidRebuildIoFault,
)
.await
}
+442 -18
View File
@@ -69,7 +69,7 @@ use std::net::IpAddr;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use time::{Duration as TimeDuration, OffsetDateTime};
use tokio::fs;
use tokio::net::TcpListener;
@@ -86,7 +86,7 @@ type BacklogMetricPoints = Arc<Mutex<BTreeMap<String, BTreeMap<String, (u64, f64
/// default. This suite opts its source servers into the loopback allowance explicitly
/// so the shared harness (`RustFSTestEnvironment` / the cluster harness) stays
/// fail-closed and every other e2e scenario keeps exercising the production SSRF policy.
const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
pub(crate) const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
/// Short data-scanner cycle for the failure-recovery tests (backlog#1147 repl-5).
///
@@ -402,14 +402,14 @@ fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String),
Ok((access_key, secret_key, session_token))
}
struct ReplicationTargetOptions<'a> {
endpoint: &'a str,
access_key: &'a str,
secret_key: &'a str,
target_bucket: &'a str,
secure: bool,
skip_tls_verify: bool,
ca_cert_pem: Option<&'a str>,
pub(crate) struct ReplicationTargetOptions<'a> {
pub(crate) endpoint: &'a str,
pub(crate) access_key: &'a str,
pub(crate) secret_key: &'a str,
pub(crate) target_bucket: &'a str,
pub(crate) secure: bool,
pub(crate) skip_tls_verify: bool,
pub(crate) ca_cert_pem: Option<&'a str>,
}
async fn set_replication_target(
@@ -434,7 +434,7 @@ async fn set_replication_target(
.await
}
async fn set_replication_target_with_options(
pub(crate) async fn set_replication_target_with_options(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
options: ReplicationTargetOptions<'_>,
@@ -504,7 +504,7 @@ async fn send_set_replication_target_request(
.await
}
async fn put_bucket_replication(
pub(crate) async fn put_bucket_replication(
env: &RustFSTestEnvironment,
bucket: &str,
target_arn: &str,
@@ -643,7 +643,10 @@ async fn get_bucket_replication(
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
pub(crate) async fn enable_bucket_versioning(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
set_bucket_versioning(env, bucket, BucketVersioningStatus::Enabled).await
}
@@ -2005,6 +2008,87 @@ fn proxy_error_response(error: impl std::fmt::Display) -> Response<Full<bytes::B
.expect("static proxy response must be valid")
}
#[derive(Clone)]
struct ReplicationResponseHoldRuntime {
armed: Arc<AtomicBool>,
backend_committed: watch::Sender<bool>,
release: watch::Receiver<bool>,
}
impl ReplicationResponseHoldRuntime {
fn try_claim(&self) -> bool {
self.armed
.compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
}
struct ReplicationResponseHold {
armed: Arc<AtomicBool>,
backend_committed_signal: watch::Sender<bool>,
backend_committed: watch::Receiver<bool>,
release: watch::Sender<bool>,
}
impl ReplicationResponseHold {
fn arm(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
if self.armed.load(Ordering::Acquire) {
return Err("replication response hold was already armed".into());
}
self.backend_committed_signal
.send(false)
.map_err(|_| "replication response hold closed before rearming")?;
self.release
.send(false)
.map_err(|_| "replication response hold closed before rearming")?;
self.armed
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.map_err(|_| "replication response hold was already armed")?;
Ok(())
}
async fn wait_for_backend_commit(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let wait = async {
while !*self.backend_committed.borrow() {
self.backend_committed
.changed()
.await
.map_err(|_| "replication response hold closed before the backend committed")?;
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
};
timeout(Duration::from_secs(60), wait)
.await
.map_err(|_| "timed out waiting for the replication backend to commit")?
}
fn release(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
self.release
.send(true)
.map_err(|_| "replication response hold closed before release")?;
Ok(())
}
}
fn replication_response_hold() -> (ReplicationResponseHoldRuntime, ReplicationResponseHold) {
let armed = Arc::new(AtomicBool::new(false));
let (backend_committed, backend_committed_rx) = watch::channel(false);
let (release, release_rx) = watch::channel(false);
(
ReplicationResponseHoldRuntime {
armed: armed.clone(),
backend_committed: backend_committed.clone(),
release: release_rx,
},
ReplicationResponseHold {
armed,
backend_committed_signal: backend_committed,
backend_committed: backend_committed_rx,
release,
},
)
}
async fn forward_replication_proxy_request(
request: Request<Incoming>,
backend_url: &str,
@@ -2012,6 +2096,7 @@ async fn forward_replication_proxy_request(
request_count: &AtomicU64,
mut replication_enabled: watch::Receiver<bool>,
mut held_tagging: watch::Receiver<Option<String>>,
mut response_hold: ReplicationResponseHoldRuntime,
) -> Response<Full<bytes::Bytes>> {
let (parts, body) = request.into_parts();
let is_replication = parts
@@ -2037,6 +2122,7 @@ async fn forward_replication_proxy_request(
}
}
}
let hold_response = is_replication && parts.method == http::Method::PUT && response_hold.try_claim();
let Some(path_and_query) = parts.uri.path_and_query() else {
return proxy_error_response("request URI omitted path");
@@ -2059,6 +2145,16 @@ async fn forward_replication_proxy_request(
Ok(body) => body,
Err(error) => return proxy_error_response(error),
};
if hold_response && status.is_success() {
if response_hold.backend_committed.send(true).is_err() {
return proxy_error_response("replication response hold closed after the backend committed");
}
while !*response_hold.release.borrow() {
if response_hold.release.changed().await.is_err() {
return proxy_error_response("replication response hold closed before release");
}
}
}
let mut proxied = Response::builder().status(status);
for (name, value) in &headers {
proxied = proxied.header(name, value);
@@ -2070,7 +2166,7 @@ async fn start_replication_counting_proxy(
backend_url: &str,
tasks: &mut JoinSet<()>,
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>), Box<dyn Error + Send + Sync>> {
let (proxy_url, request_count, replication_enabled, _held_tagging) =
let (proxy_url, request_count, replication_enabled, _held_tagging, _response_hold) =
start_replication_counting_proxy_with_tag_hold(backend_url, tasks).await?;
Ok((proxy_url, request_count, replication_enabled))
}
@@ -2082,7 +2178,16 @@ async fn start_replication_counting_proxy(
async fn start_replication_counting_proxy_with_tag_hold(
backend_url: &str,
tasks: &mut JoinSet<()>,
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>, watch::Sender<Option<String>>), Box<dyn Error + Send + Sync>> {
) -> Result<
(
String,
Arc<AtomicU64>,
watch::Sender<bool>,
watch::Sender<Option<String>>,
ReplicationResponseHold,
),
Box<dyn Error + Send + Sync>,
> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let proxy_url = format!("http://{}", listener.local_addr()?);
let backend_url = backend_url.to_string();
@@ -2090,6 +2195,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
let task_request_count = request_count.clone();
let (replication_enabled, task_replication_enabled) = watch::channel(true);
let (held_tagging, task_held_tagging) = watch::channel(None);
let (response_hold_runtime, response_hold) = replication_response_hold();
tasks.spawn(async move {
let client = local_http_client();
let mut connections = JoinSet::new();
@@ -2102,6 +2208,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
let request_count = task_request_count.clone();
let replication_enabled = task_replication_enabled.clone();
let held_tagging = task_held_tagging.clone();
let response_hold = response_hold_runtime.clone();
connections.spawn(async move {
let service = service_fn(move |request| {
let backend_url = backend_url.clone();
@@ -2109,6 +2216,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
let request_count = request_count.clone();
let replication_enabled = replication_enabled.clone();
let held_tagging = held_tagging.clone();
let response_hold = response_hold.clone();
async move {
Ok::<_, Infallible>(
forward_replication_proxy_request(
@@ -2118,6 +2226,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
&request_count,
replication_enabled,
held_tagging,
response_hold,
)
.await,
)
@@ -2130,7 +2239,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
}
}
});
Ok((proxy_url, request_count, replication_enabled, held_tagging))
Ok((proxy_url, request_count, replication_enabled, held_tagging, response_hold))
}
async fn site_replication_remove(
@@ -3821,6 +3930,12 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
// This matrix verifies request-time rule/admission behavior. Keep the
// background existing-object scanner outside the observation window: with
// ExistingObjectReplication enabled it may legitimately discover an
// object after its tags change, which is a separate data-replication path
// that PR #5696 intentionally did not alter.
source_env_vars.push(("RUSTFS_SCANNER_START_DELAY_SECS", "300"));
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env_a = RustFSTestEnvironment::new().await?;
@@ -4049,6 +4164,13 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
.await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
// A metadata edit must not retroactively admit data that failed the tag
// filter at PUT time. This is the PR #5696 safety boundary: the target ARN
// has no persisted data-admission state for this version, so adding the
// matching tag later remains metadata-only and fails closed.
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
source_client
.put_object()
.bucket(source_bucket)
@@ -7152,6 +7274,27 @@ async fn put_single_tag(
Ok(())
}
async fn put_single_tag_current(
client: &Client,
bucket: &str,
key: &str,
tag_key: &str,
tag_value: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
client
.put_object_tagging()
.bucket(bucket)
.key(key)
.tagging(
aws_sdk_s3::types::Tagging::builder()
.tag_set(aws_sdk_s3::types::Tag::builder().key(tag_key).value(tag_value).build()?)
.build()?,
)
.send()
.await?;
Ok(())
}
async fn get_single_tag(
client: &Client,
bucket: &str,
@@ -7199,6 +7342,28 @@ async fn wait_for_single_tag(
}
}
/// Poll one site until `tag_key` is absent from the selected version.
async fn wait_for_tag_absent(
client: &Client,
bucket: &str,
key: &str,
version_id: &str,
tag_key: &str,
site: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
loop {
let observed = get_single_tag(client, bucket, key, version_id, tag_key).await?;
if observed.is_none() {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("{site}: {bucket}/{key}?versionId={version_id} tag {tag_key} remained {observed:?}").into());
}
sleep(Duration::from_millis(200)).await;
}
}
/// Tag key the dual-node LWW scenario edits on both sites.
const LWW_TAG_KEY: &str = "owner";
@@ -7246,6 +7411,265 @@ async fn wait_for_proxy_replication_requests(
}
}
/// rustfs/backlog#2099: metadata admission must not drop a tag edit made after
/// the target has committed the initial object but before the source persists
/// that replication as COMPLETED.
#[tokio::test]
async fn test_site_replication_tagging_during_initial_pending_window_converges() -> TestResult {
init_logging();
// `RustFSTestEnvironment::start_rustfs_server_with_env` resolves (and on a
// cold checkout builds) this binary synchronously. Keep that setup outside
// the scenario timeout so 180 seconds measures the runtime race rather
// than compilation latency.
let _rustfs_binary = rustfs_binary_path();
match timeout(Duration::from_secs(180), async {
const PAYLOAD: &str = "tagging during pending replication";
const TAG_KEY: &str = "window";
const TAG_VALUE: &str = "pending";
const DELETE_PAYLOAD: &str = "tag deletion during pending replication";
const DELETE_TAG_KEY: &str = "remove";
const DELETE_TAG_VALUE: &str = "while-pending";
let mut site_env = replication_fast_env();
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
let mut site_a_env = RustFSTestEnvironment::new().await?;
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut site_b_env = RustFSTestEnvironment::new().await?;
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let site_a_client = site_a_env.create_s3_client();
let site_b_client = site_b_env.create_s3_client();
let mut proxy_tasks = JoinSet::new();
let (
site_b_proxy,
_site_b_replication_requests,
_site_b_replication_enabled,
_site_b_held_tagging,
mut site_b_response_hold,
) = start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
let add_status = site_replication_add(
&site_a_env,
&[
PeerSite {
name: "pending-site-a".to_string(),
endpoint: site_a_env.url.clone(),
access_key: site_a_env.access_key.clone(),
secret_key: site_a_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "pending-site-b".to_string(),
endpoint: site_b_env.url.clone(),
access_key: site_b_env.access_key.clone(),
secret_key: site_b_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
let site_info = wait_for_site_replication_enabled(&site_a_env, 2).await?;
wait_for_site_replication_enabled(&site_b_env, 2).await?;
let mut site_b_peer = site_info
.sites
.iter()
.find(|peer| peer.endpoint == site_b_env.url.as_str())
.ok_or("site B peer missing from replication info")?
.clone();
site_b_peer.endpoint = site_b_proxy.clone();
site_b_peer.sync_state = SyncStatus::Enable;
let edit = site_replication_edit(&site_a_env, "", &site_b_peer).await?;
assert!(edit.success, "unexpected site B endpoint edit: {edit:?}");
for env in [&site_a_env, &site_b_env] {
wait_for_site_replication_info(env, |info| info.sites.iter().any(|peer| peer.endpoint == site_b_proxy)).await?;
}
let bucket = "site-repl-tag-pending";
let key = "pending-window.txt";
site_a_client.create_bucket().bucket(bucket).send().await?;
wait_for_bucket_on_target(&site_b_client, bucket).await?;
site_b_response_hold.arm()?;
let put_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
tokio::spawn(async move {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(PAYLOAD.as_bytes()))
.send()
.await
})
};
// The proxy reads B's complete successful response before parking it,
// so B's GET proves the object is committed while A's worker is still
// unable to persist COMPLETED.
site_b_response_hold.wait_for_backend_commit().await?;
wait_for_replicated_object(&site_b_client, bucket, key, PAYLOAD).await?;
let source_head = site_a_client.head_object().bucket(bucket).key(key).send().await?;
let version_id = source_head
.version_id()
.ok_or("source HEAD omitted the pending version ID")?
.to_string();
assert_eq!(
source_head.replication_status().map(|status| status.as_str()),
Some("PENDING"),
"source must still report PENDING while the initial replication response is held"
);
let tag_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
// The original real-machine failure used the current-version S3
// API (no versionId). The delete phase below deliberately keeps an
// explicit versionId so both request shapes stay covered.
tokio::spawn(async move { put_single_tag_current(&client, &bucket, &key, TAG_KEY, TAG_VALUE).await })
};
wait_for_single_tag(&site_a_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site A").await?;
assert_eq!(
head_replication_status(&site_a_client, bucket, key, &version_id)
.await?
.as_deref(),
Some("PENDING"),
"tag update must be authored before the initial replication reaches COMPLETED"
);
site_b_response_hold.release()?;
let put_output = timeout(Duration::from_secs(60), put_task)
.await
.map_err(|_| "source PutObject remained blocked after releasing the replication response")???;
timeout(Duration::from_secs(60), tag_task)
.await
.map_err(|_| "PutObjectTagging remained blocked after releasing the replication response")???;
assert_eq!(put_output.version_id(), Some(version_id.as_str()));
wait_for_single_tag(&site_b_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site B").await?;
wait_for_version_replication_status(&site_a_client, bucket, key, &version_id, &["COMPLETED"], "site A").await?;
wait_for_version_replication_status(&site_b_client, bucket, key, &version_id, &["REPLICA"], "site B").await?;
let source_state = list_replication_state(&site_a_client, bucket).await?;
let target_state = list_replication_state(&site_b_client, bucket).await?;
assert_eq!(source_state, target_state, "tag replication must not fork the object version");
assert_eq!(source_state.len(), 1, "tag replication must leave exactly one object version");
assert_eq!(source_state[0].key, key);
assert_eq!(source_state[0].version_id, version_id);
// Re-arm the same response barrier for an initially tagged object.
// Deleting its tag while A is still PENDING proves the same admission
// rule covers DeleteObjectTagging rather than only the original PUT
// symptom.
let delete_key = "pending-delete-window.txt";
site_b_response_hold.arm()?;
let delete_put_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = delete_key.to_string();
tokio::spawn(async move {
client
.put_object()
.bucket(bucket)
.key(key)
.tagging(format!("{DELETE_TAG_KEY}={DELETE_TAG_VALUE}"))
.body(ByteStream::from_static(DELETE_PAYLOAD.as_bytes()))
.send()
.await
})
};
site_b_response_hold.wait_for_backend_commit().await?;
wait_for_replicated_object(&site_b_client, bucket, delete_key, DELETE_PAYLOAD).await?;
let delete_source_head = site_a_client.head_object().bucket(bucket).key(delete_key).send().await?;
let delete_version_id = delete_source_head
.version_id()
.ok_or("source HEAD omitted the pending tagged version ID")?
.to_string();
assert_eq!(
delete_source_head.replication_status().map(|status| status.as_str()),
Some("PENDING"),
"source tagged object must remain PENDING while its initial response is held"
);
wait_for_single_tag(
&site_b_client,
bucket,
delete_key,
&delete_version_id,
DELETE_TAG_KEY,
DELETE_TAG_VALUE,
"site B",
)
.await?;
let delete_tag_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = delete_key.to_string();
let version_id = delete_version_id.clone();
tokio::spawn(async move {
client
.delete_object_tagging()
.bucket(bucket)
.key(key)
.version_id(version_id)
.send()
.await
})
};
wait_for_tag_absent(&site_a_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site A").await?;
assert_eq!(
head_replication_status(&site_a_client, bucket, delete_key, &delete_version_id)
.await?
.as_deref(),
Some("PENDING"),
"tag deletion must be authored before the initial replication reaches COMPLETED"
);
site_b_response_hold.release()?;
let delete_put_output = timeout(Duration::from_secs(60), delete_put_task)
.await
.map_err(|_| "source tagged PutObject remained blocked after releasing the replication response")???;
timeout(Duration::from_secs(60), delete_tag_task)
.await
.map_err(|_| "DeleteObjectTagging remained blocked after releasing the replication response")???;
assert_eq!(delete_put_output.version_id(), Some(delete_version_id.as_str()));
wait_for_tag_absent(&site_b_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site B").await?;
wait_for_version_replication_status(&site_a_client, bucket, delete_key, &delete_version_id, &["COMPLETED"], "site A")
.await?;
wait_for_version_replication_status(&site_b_client, bucket, delete_key, &delete_version_id, &["REPLICA"], "site B")
.await?;
let source_state = list_replication_state(&site_a_client, bucket).await?;
let target_state = list_replication_state(&site_b_client, bucket).await?;
assert_eq!(source_state, target_state, "tag deletion must not fork the object version");
assert_eq!(source_state.len(), 2, "pending-window scenarios must leave exactly two object versions");
assert!(
source_state
.iter()
.any(|entry| entry.key == delete_key && entry.version_id == delete_version_id),
"tag deletion must preserve the original version identity"
);
proxy_tasks.abort_all();
Ok(())
})
.await
{
Ok(result) => result,
Err(_) => Err("pending-window site-replication tagging test timed out".into()),
}
}
/// rustfs/backlog#1953 (audit A4/P1-6): receiver-side LWW for replicated
/// metadata categories, exercised end to end over the real dual-node
/// active-active site-replication control plane — sender, worker, status
@@ -7282,9 +7706,9 @@ async fn test_site_replication_tagging_lww_converges_active_active_real_dual_nod
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut proxy_tasks = JoinSet::new();
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging) =
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging, _site_a_response_hold) =
start_replication_counting_proxy_with_tag_hold(&site_a_env.url, &mut proxy_tasks).await?;
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging) =
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging, _site_b_response_hold) =
start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
let site_a_client = site_a_env.create_s3_client();
@@ -0,0 +1,519 @@
// 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.
//! Outbound target matrix: every object shape RustFS replicates, against
//! every remote-target failure mode the fake target models.
//!
//! The matrix exists because a fix for one target class shipped a regression
//! for another (rustfs#6895 fixed rustfs#6853 and caused rustfs#7082; see
//! `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`).
//! Each row is one target mode with its own RustFS source and fake target;
//! each cell is one object shape. [`expectation`] is the single place that
//! says what a cell must do today:
//!
//! - `Completed` cells must replicate and the target must hold the source
//! bytes; the journal must also show the wire shape the cell relies on.
//! - `KnownFailing` cells pin an open issue. They must fail for the recorded
//! reason, and the moment they start passing the test fails with an XPASS
//! message so the expectation is flipped in the same PR as the fix.
//!
//! Adding a target behavior the fleet has shown: add the mode to the fake
//! target, add a row here, and record any cell that is red before the fix.
use crate::common::{RustFSTestEnvironment, init_logging, replication_fast_env};
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
use crate::fake_s3_target::{FakeS3Target, Operation as FakeTargetOperation, RequestRecord};
use crate::on_demand_migration::common::fake_source_client;
use crate::replication_extension_test::{
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, put_bucket_replication,
set_replication_target_with_options,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::{ByteStream, DateTime};
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockMode};
use bytes::Bytes;
use std::error::Error;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{Duration, sleep, timeout};
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
/// A remote-target behavior the fleet has shown, as the fake target models it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TargetMode {
/// RustFS / MinIO-like target: adopts source version ids, decodes any
/// framing, enforces no checksum rule.
Baseline,
/// SeaweedFS 3.97 (rustfs#6853): refuses `aws-chunked` bodies. A sender
/// that frames its uploads gets a hard failure here instead of a
/// silently corrupted replica.
RejectAwsChunked,
/// AWS S3 / MinIO / Impossible Cloud (rustfs#7082): a PutObject with
/// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`.
RequireChecksumWithObjectLock,
/// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids
/// (rustfs/backlog#2085). Data must still land.
MintOwnVersionIds,
}
impl TargetMode {
const ALL: [TargetMode; 4] = [
TargetMode::Baseline,
TargetMode::RejectAwsChunked,
TargetMode::RequireChecksumWithObjectLock,
TargetMode::MintOwnVersionIds,
];
fn apply(self, target: &FakeS3Target) {
match self {
TargetMode::Baseline => {}
TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true),
TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true),
TargetMode::MintOwnVersionIds => target.assign_own_version_ids(true),
}
}
fn slug(self) -> &'static str {
match self {
TargetMode::Baseline => "baseline",
TargetMode::RejectAwsChunked => "reject-aws-chunked",
TargetMode::RequireChecksumWithObjectLock => "require-checksum-object-lock",
TargetMode::MintOwnVersionIds => "mint-own-version-ids",
}
}
}
/// An object shape the replication transport treats differently.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ObjectShape {
/// The exact rustfs#7082 reproduction: a zero-byte object.
Empty,
/// Small single-part object with no Object Lock parameters.
Plain,
/// Single-part object with a GOVERNANCE retention period.
Retention,
/// Single-part object with legal hold ON.
LegalHold,
/// Two-part multipart upload, no Object Lock parameters.
Multipart,
/// Two-part multipart upload with a GOVERNANCE retention period; the
/// lock headers travel on CreateMultipartUpload, which has no body.
LockedMultipart,
}
impl ObjectShape {
const ALL: [ObjectShape; 6] = [
ObjectShape::Empty,
ObjectShape::Plain,
ObjectShape::Retention,
ObjectShape::LegalHold,
ObjectShape::Multipart,
ObjectShape::LockedMultipart,
];
fn key(self) -> &'static str {
match self {
ObjectShape::Empty => "matrix/empty.bin",
ObjectShape::Plain => "matrix/plain.bin",
ObjectShape::Retention => "matrix/retention.bin",
ObjectShape::LegalHold => "matrix/legal-hold.bin",
ObjectShape::Multipart => "matrix/multipart.bin",
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
}
}
fn carries_object_lock_params(self) -> bool {
matches!(self, ObjectShape::Retention | ObjectShape::LegalHold | ObjectShape::LockedMultipart)
}
/// Upload the shape to the source and return the bytes the target must
/// end up holding.
async fn put(self, client: &Client, bucket: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
let key = self.key();
match self {
ObjectShape::Empty => {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b""))
.send()
.await?;
Ok(Bytes::new())
}
ObjectShape::Plain => {
let body = payload(64 * 1024, 0x11);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.send()
.await?;
Ok(body)
}
ObjectShape::Retention => {
let body = payload(48 * 1024, 0x22);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.object_lock_mode(ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until())
.send()
.await?;
Ok(body)
}
ObjectShape::LegalHold => {
let body = payload(32 * 1024, 0x33);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
.send()
.await?;
Ok(body)
}
ObjectShape::Multipart => multipart_put(client, bucket, key, 0x44, false).await,
ObjectShape::LockedMultipart => multipart_put(client, bucket, key, 0x55, true).await,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Expectation {
/// Replicates COMPLETED and the target holds the source bytes.
Completed,
/// Replicates FAILED today for a recorded reason; pinned to an open issue.
KnownFailing(&'static str),
}
/// The cells that are red today, each pinned to the open issue that owns it.
/// This is the single source of truth: a fix that turns a cell green must
/// remove its entry in the same PR, and [`check_known_failing_cell`] refuses
/// an unexpected pass so the table cannot go stale silently. rustfs#7082
/// (Retention and LegalHold against the checksum-requiring target) lived
/// here until the replication PUT started carrying a Content-MD5 derived
/// from the source ETag.
const KNOWN_FAILING_CELLS: &[(TargetMode, ObjectShape, &str)] = &[];
fn expectation(mode: TargetMode, shape: ObjectShape) -> Expectation {
KNOWN_FAILING_CELLS
.iter()
.find(|(known_mode, known_shape, _)| *known_mode == mode && *known_shape == shape)
.map(|(_, _, issue)| Expectation::KnownFailing(issue))
.unwrap_or(Expectation::Completed)
}
#[tokio::test]
async fn matrix_baseline_target() -> TestResult {
run_row(TargetMode::Baseline).await
}
#[tokio::test]
async fn matrix_reject_aws_chunked_target() -> TestResult {
run_row(TargetMode::RejectAwsChunked).await
}
#[tokio::test]
async fn matrix_require_checksum_with_object_lock_target() -> TestResult {
run_row(TargetMode::RequireChecksumWithObjectLock).await
}
#[tokio::test]
async fn matrix_mint_own_version_ids_target() -> TestResult {
run_row(TargetMode::MintOwnVersionIds).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]
fn known_failing_table_names_real_cells() {
for (mode, shape, issue) in KNOWN_FAILING_CELLS {
assert!(
TargetMode::ALL.contains(mode) && ObjectShape::ALL.contains(shape),
"{mode:?}/{shape:?} is not a matrix cell"
);
assert!(
issue.starts_with("rustfs#") || issue.starts_with("rustfs/backlog#"),
"{issue} must name an open issue"
);
assert_eq!(expectation(*mode, *shape), Expectation::KnownFailing(issue));
}
let red_cells = TargetMode::ALL
.iter()
.flat_map(|mode| ObjectShape::ALL.iter().map(move |shape| (*mode, *shape)))
.filter(|(mode, shape)| matches!(expectation(*mode, *shape), Expectation::KnownFailing(_)))
.count();
assert_eq!(red_cells, KNOWN_FAILING_CELLS.len());
}
async fn run_row(mode: TargetMode) -> TestResult {
init_logging();
let target = FakeS3Target::start().await?;
let target_bucket = format!("matrix-{}-dst", mode.slug());
target.create_bucket_with_object_lock(target_bucket.clone());
mode.apply(&target);
let mut source_env = RustFSTestEnvironment::new().await?;
let mut env_vars = replication_fast_env();
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
source_env.start_rustfs_server_with_env(vec![], &env_vars).await?;
let source_bucket = format!("matrix-{}-src", mode.slug());
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(&source_bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
enable_bucket_versioning(&source_env, &source_bucket).await?;
let target_arn = set_replication_target_with_options(
&source_env,
&source_bucket,
ReplicationTargetOptions {
endpoint: &target.address(),
access_key: FAKE_ACCESS_KEY,
secret_key: FAKE_SECRET_KEY,
target_bucket: &target_bucket,
secure: false,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await?;
put_bucket_replication(&source_env, &source_bucket, &target_arn).await?;
let target_client = fake_source_client(&target);
let mut failures = Vec::new();
for shape in ObjectShape::ALL {
let cell = format!("{}/{:?}", mode.slug(), shape);
let expected_body = shape.put(&source_client, &source_bucket).await?;
let status = wait_for_terminal_replication_status(&source_client, &source_bucket, shape.key()).await?;
let journal = target.requests();
let outcome = match expectation(mode, shape) {
Expectation::Completed => {
check_completed_cell(&cell, &status, &target_client, &target_bucket, shape, &expected_body, &journal).await
}
Expectation::KnownFailing(issue) => check_known_failing_cell(&cell, issue, &status, shape, &journal),
};
if let Err(err) = outcome {
failures.push(format!("{cell}: {err}"));
}
}
target.shutdown().await;
if failures.is_empty() {
Ok(())
} else {
Err(format!(
"{} matrix cell(s) violated their expectation:\n {}",
failures.len(),
failures.join("\n ")
)
.into())
}
}
async fn check_completed_cell(
cell: &str,
status: &str,
target_client: &Client,
target_bucket: &str,
shape: ObjectShape,
expected_body: &Bytes,
journal: &[RequestRecord],
) -> TestResult {
if status != "COMPLETED" {
return Err(format!("expected COMPLETED, source reports {status}").into());
}
let stored = target_client
.get_object()
.bucket(target_bucket)
.key(shape.key())
.send()
.await
.map_err(|err| format!("target GET failed after COMPLETED: {err}"))?
.body
.collect()
.await?
.into_bytes();
if stored != *expected_body {
return Err(format!(
"target holds {} bytes that differ from the {} source bytes (COMPLETED over a corrupted replica)",
stored.len(),
expected_body.len()
)
.into());
}
// The wire shape the cell relies on: plain signed payloads (rustfs#6853)
// for every upload of this key, and the lock headers present exactly when
// the shape carries them.
let uploads: Vec<&RequestRecord> = journal
.iter()
.filter(|record| {
record.key.as_deref() == Some(shape.key())
&& matches!(
record.operation,
FakeTargetOperation::PutObject | FakeTargetOperation::UploadPart | FakeTargetOperation::CreateMultipartUpload
)
})
.collect();
if uploads.is_empty() {
return Err("no upload reached the target although the source reports COMPLETED".into());
}
if let Some(framed) = uploads.iter().find(|record| record.transport.aws_chunked) {
return Err(format!("{cell}: an upload went out aws-chunked (rustfs#6853 framing): {framed:?}").into());
}
let lock_headers_seen = uploads.iter().any(|record| record.transport.object_lock_params);
if lock_headers_seen != shape.carries_object_lock_params() {
return Err(format!(
"object lock headers on the wire: {lock_headers_seen}, shape carries them: {}",
shape.carries_object_lock_params()
)
.into());
}
// rustfs#7082 contract: every PutObject that carries Object Lock
// parameters also carries Content-MD5 or an x-amz-checksum-* header,
// whatever the target's own policy is.
if let Some(bare) = uploads.iter().find(|record| {
record.operation == FakeTargetOperation::PutObject
&& record.transport.object_lock_params
&& record.transport.content_md5.is_none()
&& record.transport.checksum_headers.is_empty()
}) {
return Err(format!("a locked PutObject went out without any integrity header (rustfs#7082): {bare:?}").into());
}
Ok(())
}
fn check_known_failing_cell(cell: &str, issue: &str, status: &str, shape: ObjectShape, journal: &[RequestRecord]) -> TestResult {
if status == "COMPLETED" {
return Err(format!(
"XPASS: {cell} reached COMPLETED but the expectation table pins it to {issue}; \
the fix landed, so flip this cell to Expectation::Completed in the same PR"
)
.into());
}
if status != "FAILED" {
return Err(format!("expected FAILED ({issue}), source reports {status}").into());
}
// Fail for the recorded reason, not by accident: the PUT carried the lock
// headers and no integrity header at all.
let rejected = journal.iter().any(|record| {
record.operation == FakeTargetOperation::PutObject
&& record.key.as_deref() == Some(shape.key())
&& record.transport.object_lock_params
&& record.transport.content_md5.is_none()
&& record.transport.checksum_headers.is_empty()
});
if !rejected {
return Err(format!(
"FAILED, but not for the {issue} reason (a locked PUT without Content-MD5 / x-amz-checksum-*); journal: {journal:?}"
)
.into());
}
Ok(())
}
/// First terminal replication status (`COMPLETED` or `FAILED`) the source
/// reports for the key.
async fn wait_for_terminal_replication_status(
client: &Client,
bucket: &str,
key: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let wait = async {
loop {
let head = client.head_object().bucket(bucket).key(key).send().await?;
match head.replication_status().map(|status| status.as_str().to_string()) {
Some(status) if status == "COMPLETED" || status == "FAILED" => return Ok(status),
_ => sleep(Duration::from_millis(200)).await,
}
}
};
match timeout(Duration::from_secs(90), wait).await {
Ok(result) => result,
Err(_) => Err(format!("{key} reached no terminal replication status within 90 seconds").into()),
}
}
async fn multipart_put(
client: &Client,
bucket: &str,
key: &str,
fill: u8,
locked: bool,
) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
let part_one = payload(5 * 1024 * 1024, fill);
let part_two = payload(256 * 1024, fill.wrapping_add(1));
let mut create = client.create_multipart_upload().bucket(bucket).key(key);
if locked {
create = create
.object_lock_mode(ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until());
}
let upload_id = create
.send()
.await?
.upload_id()
.ok_or("CreateMultipartUpload returned no upload id")?
.to_string();
let mut completed = Vec::new();
for (number, part) in [(1, &part_one), (2, &part_two)] {
let etag = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(number)
.body(ByteStream::from(part.clone()))
.send()
.await?
.e_tag()
.ok_or("UploadPart returned no ETag")?
.to_string();
completed.push(CompletedPart::builder().part_number(number).e_tag(etag).build());
}
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
.send()
.await?;
let mut body = Vec::with_capacity(part_one.len() + part_two.len());
body.extend_from_slice(&part_one);
body.extend_from_slice(&part_two);
Ok(Bytes::from(body))
}
fn payload(len: usize, fill: u8) -> Bytes {
Bytes::from((0..len).map(|i| fill.wrapping_add((i % 251) as u8)).collect::<Vec<u8>>())
}
fn retain_until() -> DateTime {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_secs();
DateTime::from_secs(now as i64 + 86_400)
}
+1 -1
View File
@@ -15,7 +15,7 @@
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
pub(crate) use rustfs_ecstore::api::disk::{RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{
@@ -0,0 +1,144 @@
// 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.
//! Two-node gate for the tier stats wire contract (rustfs/backlog#2207).
//!
//! Before this contract, `GET /v3/tier-stats` answered from the process that
//! happened to receive the request, so the same query returned different
//! numbers depending on which node a client reached, with nothing in the body
//! saying so. These tests pin the two properties that fix costs: the answer is
//! node-independent, and it states how much of the cluster it covers.
use crate::common::{RustFSTestClusterEnvironment, admin_request, init_logging};
use http::Method;
use http::StatusCode;
use serde_json::Value;
use std::time::Duration;
use tokio::time::{Instant, sleep};
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
const TIER_STATS_PATH: &str = "/rustfs/admin/v3/tier-stats";
const PEER_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
async fn tier_stats(cluster: &RustFSTestClusterEnvironment, node: usize, query: &str) -> TestResult<(StatusCode, String)> {
admin_request(
&cluster.nodes[node].url,
Method::GET,
&format!("{TIER_STATS_PATH}{query}"),
None,
&cluster.access_key,
&cluster.secret_key,
)
.await
}
async fn tier_stats_json(cluster: &RustFSTestClusterEnvironment, node: usize) -> TestResult<Value> {
let (status, body) = tier_stats(cluster, node, "").await?;
assert_eq!(status, StatusCode::OK, "node {node} must answer tier-stats: {body}");
Ok(serde_json::from_str(&body)?)
}
/// Wait until every node reports the whole cluster.
///
/// Peer clients are established during startup, so a query issued in the first
/// moments can legitimately see a peer as unavailable. Polling separates that
/// startup window from the failure this test is about: an answer that stays
/// partial because the peer never reports at all.
async fn wait_for_complete_activity(cluster: &RustFSTestClusterEnvironment) -> TestResult<Vec<Value>> {
let deadline = Instant::now() + PEER_CONVERGENCE_TIMEOUT;
loop {
let mut bodies = Vec::with_capacity(cluster.nodes.len());
for node in 0..cluster.nodes.len() {
bodies.push(tier_stats_json(cluster, node).await?);
}
if bodies.iter().all(|body| body["activity"]["status"] == "complete") {
return Ok(bodies);
}
if Instant::now() >= deadline {
return Err(format!("tier-stats activity never became complete on every node: {bodies:?}").into());
}
sleep(Duration::from_millis(500)).await;
}
}
#[tokio::test]
async fn tier_stats_answers_the_same_cluster_result_from_either_node() -> TestResult {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
cluster.start().await?;
let bodies = wait_for_complete_activity(&cluster).await?;
let (first, second) = (&bodies[0], &bodies[1]);
for (node, body) in bodies.iter().enumerate() {
assert_eq!(body["contractVersion"], 2, "node {node} must name the current contract version");
assert_eq!(
body["activity"]["nodesExpected"], 2,
"node {node} must expect both cluster members, not only itself"
);
assert_eq!(
body["activity"]["nodesReporting"], 2,
"node {node} must include its peer's rolling window in the sum"
);
assert_eq!(
body["activity"]["unavailableNodes"],
serde_json::json!([]),
"node {node} reported a complete result while naming an unavailable member"
);
}
// A fixture cluster has no remote tier, so this pair is equal over an
// empty list; `nodesReporting` above is what proves the peer answered.
// The per-tier summing itself is pinned by the aggregator unit tests.
assert_eq!(
first["tiers"], second["tiers"],
"the same query must not depend on which node received it"
);
assert_eq!(
first["inventory"]["status"], second["inventory"]["status"],
"both nodes read the same persisted usage snapshot"
);
cluster.stop();
Ok(())
}
#[tokio::test]
async fn tier_stats_keeps_the_legacy_body_reachable_and_rejects_unknown_formats() -> TestResult {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
cluster.start().await?;
let (status, body) = tier_stats(&cluster, 0, "?format=legacy").await?;
assert_eq!(status, StatusCode::OK, "the pinned legacy body must stay reachable: {body}");
let legacy: Value = serde_json::from_str(&body)?;
assert!(
legacy.is_object() && legacy.get("contractVersion").is_none(),
"the legacy body is the bare tier map, not the current envelope: {legacy}"
);
let (status, body) = tier_stats(&cluster, 0, "?format=v3").await?;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"an unknown format must be rejected rather than answered in another shape: {body}"
);
cluster.stop();
Ok(())
}
+4 -4
View File
@@ -130,7 +130,7 @@ rustfs-concurrency.workspace = true
rustfs-credentials = { workspace = true }
rustfs-common.workspace = true
rustfs-heal-contracts.workspace = true
rustfs-scanner-contracts.workspace = true
rustfs-scanner-metrics.workspace = true
rustfs-policy.workspace = true
rustfs-protos.workspace = true
rustfs-replication.workspace = true
@@ -163,10 +163,10 @@ http-body = { workspace = true }
http-body-util.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v4", "fast-rng", "serde", "macro-diagnostics"] }
reed-solomon-erasure = { workspace = true, features = ["simd-accel"] }
rustfs-erasure-codec = { workspace = true, features = ["simd-accel"] }
reed-solomon-simd = { workspace = true }
lazy_static.workspace = true
moka = { workspace = true, features = ["future"] }
moka = { workspace = true, features = ["future", "sync"] }
rustfs-lock.workspace = true
rustfs-io-metrics.workspace = true
regex = { workspace = true }
@@ -185,7 +185,7 @@ hyper-rustls = { workspace = true, default-features = false, features = ["native
hostname.workspace = true
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
rustls-pki-types.workspace = true
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread"] }
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread", "time"] }
tonic = { workspace = true, features = ["gzip", "deflate"] }
xxhash-rust = { workspace = true, features = ["xxh64", "xxh3"] }
tower = { workspace = true, features = ["timeout"] }
+4 -4
View File
@@ -122,10 +122,10 @@ fn bench_encode_performance(c: &mut Criterion) {
});
group.finish();
// Test direct reed-solomon-erasure implementation for large shards (>= 512 bytes)
// Test direct rustfs-erasure-codec implementation for large shards (>= 512 bytes)
let shard_size = calc_shard_size(config.data_size, config.data_shards);
if shard_size >= 512 && config.parity_shards > 0 {
use reed_solomon_erasure::galois_8::ReedSolomon;
use rustfs_erasure_codec::galois_8::ReedSolomon;
let mut rse_group = c.benchmark_group("encode_rse_direct");
rse_group.throughput(Throughput::Bytes(config.data_size as u64));
@@ -204,10 +204,10 @@ fn bench_decode_performance(c: &mut Criterion) {
);
group.finish();
// Test direct reed-solomon-erasure decoding for large shards
// Test direct rustfs-erasure-codec decoding for large shards
let shard_size = calc_shard_size(config.data_size, config.data_shards);
if shard_size >= 512 && config.parity_shards > 0 {
use reed_solomon_erasure::galois_8::ReedSolomon;
use rustfs_erasure_codec::galois_8::ReedSolomon;
if let Ok(rs) = ReedSolomon::new(config.data_shards, config.parity_shards) {
let mut rse_group = c.benchmark_group("decode_rse_direct");
+76 -11
View File
@@ -49,7 +49,7 @@ pub mod bucket {
apply_transition_rule, enqueue_expiry_for_existing_objects, enqueue_transition_for_existing_objects,
enqueue_transition_for_existing_objects_scoped, enqueue_transition_for_existing_objects_scoped_with_cancel,
enqueue_transition_immediate, expire_transitioned_object, get_global_expiry_state, get_global_transition_state,
init_background_expiry, manual_transition_queue_snapshot, post_restore_opts,
init_background_expiry, lifecycle_version_delete_target, manual_transition_queue_snapshot, post_restore_opts,
run_stale_multipart_upload_cleanup_once, validate_transition_tier,
};
}
@@ -78,6 +78,7 @@ pub mod bucket {
#[cfg(feature = "test-util")]
pub use crate::bucket::lifecycle::transition_transaction::{
TransitionTransactionRecoveryStats, recover_transition_transaction_records,
recover_transition_transaction_records_at,
};
}
@@ -117,7 +118,7 @@ pub mod bucket {
}
pub mod tier_last_day_stats {
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
pub use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats, TierDailyStatsWire};
}
pub mod tier_sweeper {
@@ -128,7 +129,6 @@ pub mod bucket {
}
pub mod metadata {
pub use crate::bucket::metadata::BUCKET_DURABILITY_CONFIG;
pub use crate::bucket::metadata::{
BUCKET_ACCELERATE_CONFIG, BUCKET_CORS_CONFIG, BUCKET_LIFECYCLE_CONFIG, BUCKET_LOGGING_CONFIG,
BUCKET_NOTIFICATION_CONFIG, BUCKET_POLICY_CONFIG, BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, BUCKET_QUOTA_CONFIG_FILE,
@@ -137,6 +137,7 @@ pub mod bucket {
BUCKET_TARGETS_FILE, BUCKET_VERSIONING_CONFIG, BUCKET_WEBSITE_CONFIG, BucketMetadata, OBJECT_LOCK_CONFIG,
load_bucket_metadata, table_catalog_path_hash,
};
pub use crate::bucket::metadata::{BUCKET_DURABILITY_CONFIG, BUCKET_ON_DEMAND_MIGRATION_CONFIG};
}
pub mod durability {
@@ -145,6 +146,51 @@ pub mod bucket {
};
}
pub mod on_demand_migration {
pub use crate::bucket::on_demand_migration::{
ApplyOutcome, BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION,
Breaker, BreakerState, BreakerTransition, BreakerVerdict, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, GaugeGuard,
LastSourceError, LatencyBucketSnapshot, NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache, OdmBucketSnapshot, OdmLookup,
OdmOp, OdmOutcome, OdmStateError, OdmStats, OdmStatsSnapshot, OnDemandMigrationSys, PullError, PullFailureReason,
PullFollower, PullLeader, PullOutcome, PullPath, PullResult, PullSlot, SOURCE_LATENCY_BUCKET_BOUNDS_MS,
SourceLatencySnapshot, source_client_spec,
};
pub use crate::bucket::on_demand_migration::{
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy,
SourceConfig, SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
};
pub use crate::bucket::on_demand_migration::{
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS,
PullCompletion, PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody,
WriteBackError, WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with,
idle_guarded_body,
};
pub use crate::bucket::on_demand_migration::{
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
};
pub mod backfill {
pub use crate::bucket::on_demand_migration::backfill::{
BACKFILL_CHECKPOINT_FILE, BACKFILL_CHECKPOINT_FORMAT_VERSION, BACKFILL_FAILED_KEYS_CAPACITY, BACKFILL_LEASE,
BACKFILL_LEASE_LOCK_PREFIX, BACKFILL_LIST_PAGE_SIZE, BACKFILL_RECOVERY_INTERVAL, BACKFILL_SAVE_EVERY_KEYS,
BACKFILL_SAVE_INTERVAL, BackfillCheckpoint, BackfillContext, BackfillContextFactory, BackfillError,
BackfillLastError, BackfillOwner, BackfillRecoveryStats, BackfillRequest, BackfillRunner, BackfillState,
BucketBackfillContext, LocalBackfillObject, PriorityPullPermits, PullPermit, PullPriority, SkipExisting,
StoredCheckpoint, SysBackfillContexts, global_backfill_runner, install_global_backfill_runner, key_hash,
read_checkpoint, run_backfill_recovery_loop, spawn_backfill_recovery_loop,
};
}
pub mod source_client {
pub use crate::bucket::on_demand_migration::source_client::{
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceListRequest, SourceObject, SourcePage,
SourceProbe, SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
resolve_path_style,
};
}
}
pub mod metadata_sys {
#[cfg(feature = "test-util")]
pub use crate::bucket::metadata_sys::ConfigWriteLockProbe;
@@ -154,11 +200,11 @@ pub mod bucket {
delete_if_incarnation, delete_under_transaction_lock, get, get_accelerate_config, get_bucket_policy,
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
get_object_lock_config, get_object_lock_config_state, get_public_access_block_config, get_quota_config,
get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config,
get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata,
set_bucket_metadata, update, update_bucket_targets_under_transaction_lock, update_config_with, update_if_incarnation,
update_quota_if_incarnation, update_under_transaction_lock,
get_object_lock_config, get_object_lock_config_state, get_on_demand_migration_config, get_public_access_block_config,
get_quota_config, get_replication_config, get_request_payment_config, get_sse_config, get_tagging_config,
get_versioning_config, get_website_config, init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata,
remove_bucket_metadata, set_bucket_metadata, update, update_bucket_targets_under_transaction_lock,
update_config_with, update_if_incarnation, update_quota_if_incarnation, update_under_transaction_lock,
};
}
@@ -199,6 +245,20 @@ pub mod bucket {
}
}
pub mod remote_s3_client {
pub use crate::bucket::remote_s3_client::{
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, RemoteS3RetryPolicy, build_remote_s3_client,
validate_remote_endpoint,
};
}
pub mod sealed_credentials {
pub use crate::bucket::sealed_credentials::{
CredentialSealer, SEALED_CREDENTIAL_VERSION, SealScope, SealedCredential, SealedCredentialError,
SealedCredentialStore, credential_sealer, install_credential_sealer, seal_secret, unseal_secret,
};
}
pub mod replication {
pub use crate::bucket::replication::replication_pool::{
DurableMrfBacklogSummary, DurableMrfBucketBacklog, DurableMrfTargetBacklog, MrfBacklogObservabilitySummary,
@@ -419,7 +479,7 @@ 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::{
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
ClusterTierDailyStats, CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
};
@@ -440,6 +500,11 @@ pub mod object {
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
SnapshotConsistencyError,
};
#[cfg(feature = "test-util")]
pub mod test_util {
pub use crate::store::DeleteAfterObjectLockSnapshotBarrier;
}
}
pub mod rebalance {
@@ -467,8 +532,8 @@ pub mod rpc {
pub use crate::cluster::rpc::{
AuthenticatedChannel, KMS_SIGNAL_SUBSYSTEM, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS,
PeerRestClient, PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC,
ScannerBucketListing, ScannerPeerActivity, ScannerPublicationLease, TONIC_RPC_PREFIX, TonicInterceptor,
build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
ScannerBucketListing, ScannerPeerActivity, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease, TONIC_RPC_PREFIX,
TonicInterceptor, build_put_file_auth_trailer, check_and_record_signed_rpc_nonce, decode_heal_bucket_rpc_options,
encode_heal_bucket_rpc_options, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth,
normalize_tonic_rpc_audience, set_tonic_canonical_body_digest, sign_ns_scanner_capability,
File diff suppressed because it is too large Load Diff
@@ -16,8 +16,9 @@ use super::runtime_boundary as runtime_sources;
use crate::bucket::lifecycle::lifecycle;
use crate::object_api::ObjectInfo;
use crate::services::event_notification::{EventArgs, send_event};
use crate::storage_api_contracts::object::{DeletedObject, ObjectToDelete};
use rustfs_s3_types::EventName;
use rustfs_scanner_contracts::metrics::IlmAction;
use rustfs_scanner_metrics::metrics::IlmAction;
const LIFECYCLE_EXPIRY_USER_AGENT: &str = "Internal: [ILM-Expiry]";
const LIFECYCLE_TRANSITION_USER_AGENT: &str = "Internal: [ILM-Transition]";
@@ -76,6 +77,60 @@ pub(crate) fn emit_non_transitioned_expiration_event(action: IlmAction, source:
emit_lifecycle_event(event_name, deleted, LIFECYCLE_EXPIRY_USER_AGENT);
}
/// Emit the lifecycle expiration event for one version removed by the batch
/// `NewerNoncurrentVersions` expiry path.
///
/// That path never sent events, so a successful noncurrent-version expiry was
/// invisible to notification subscribers even though the equivalent
/// current-version path emits one (backlog#2202).
pub(crate) fn emit_noncurrent_expiration_event(bucket: &str, target: &ObjectToDelete, deleted: &DeletedObject, failed: bool) {
if let Some((event_name, object)) = noncurrent_expiration_event(bucket, target, deleted, failed) {
emit_lifecycle_event(event_name, object, LIFECYCLE_EXPIRY_USER_AGENT);
}
}
/// Decide which event a single batch entry earned, if any.
///
/// Only an entry that mutated something may be announced. "No error" is not
/// enough, and neither is `found`: the disk layer skips an absent version and
/// reports success (`delete_versions_internal` in `disk/local.rs` continues
/// past `FileVersionNotFound`), so a batch entry for a version that was
/// already gone comes back indistinguishable from a committed delete. The
/// delete plan's own source lookup is the signal that survives that, and the
/// lifecycle batch path always performs it because every target carries an
/// exact version identity.
fn noncurrent_expiration_event(
bucket: &str,
target: &ObjectToDelete,
deleted: &DeletedObject,
failed: bool,
) -> Option<(EventName, ObjectInfo)> {
if failed || !deleted.found || deleted.source_missing {
return None;
}
// A version removed by explicit version id is a plain versioned delete
// even when that version is itself a delete marker; only a request that
// carried no version id can publish a new delete marker. This is the rule
// the S3 DeleteObjects path applies (issue #6745). `delete_object_versions`
// now refuses targets without an exact version identity, so the
// marker-creation shape is unreachable from that caller; the mapping stays
// here so a future caller cannot silently announce the wrong mutation.
let created_delete_marker = deleted.delete_marker && target.version_id.is_none();
let (event_name, version_id) = if created_delete_marker {
(EventName::LifecycleExpirationDeleteMarkerCreated, deleted.delete_marker_version_id)
} else {
(EventName::LifecycleExpirationDelete, deleted.version_id.or(target.version_id))
};
let object = ObjectInfo {
bucket: bucket.to_string(),
name: target.object_name.clone(),
version_id,
delete_marker: deleted.delete_marker,
..Default::default()
};
Some((event_name, object))
}
fn emit_lifecycle_event(event_name: EventName, object: ObjectInfo, user_agent: &str) {
send_event(EventArgs {
event_name: event_name.to_string(),
@@ -113,6 +168,7 @@ fn non_transitioned_expiration_event_name(
#[cfg(test)]
mod tests {
use super::*;
use uuid::Uuid;
#[test]
fn transitioned_expiration_event_marks_delete_marker_creation() {
@@ -129,4 +185,87 @@ mod tests {
EventName::LifecycleExpirationDelete
);
}
fn deleted_version(version_id: Uuid) -> DeletedObject {
DeletedObject {
object_name: "object".to_string(),
version_id: Some(version_id),
found: true,
..Default::default()
}
}
fn target_version(version_id: Option<Uuid>) -> ObjectToDelete {
ObjectToDelete {
object_name: "object".to_string(),
version_id,
..Default::default()
}
}
#[test]
fn noncurrent_expiration_emits_versioned_delete_with_exact_identity() {
let version_id = Uuid::new_v4();
let (event_name, object) =
noncurrent_expiration_event("bucket", &target_version(Some(version_id)), &deleted_version(version_id), false)
.expect("a committed delete must emit");
assert_eq!(event_name, EventName::LifecycleExpirationDelete);
assert_eq!(object.bucket, "bucket");
assert_eq!(object.name, "object");
assert_eq!(object.version_id, Some(version_id));
}
/// Removing a noncurrent version that happens to be a delete marker is a
/// plain versioned delete, not a delete-marker creation.
#[test]
fn noncurrent_expiration_of_a_delete_marker_version_is_a_plain_delete() {
let version_id = Uuid::new_v4();
let deleted = DeletedObject {
delete_marker: true,
..deleted_version(version_id)
};
let (event_name, object) = noncurrent_expiration_event("bucket", &target_version(Some(version_id)), &deleted, false)
.expect("a committed delete must emit");
assert_eq!(event_name, EventName::LifecycleExpirationDelete);
assert_eq!(object.version_id, Some(version_id));
}
#[test]
fn noncurrent_expiration_reports_a_created_delete_marker() {
let marker_version_id = Uuid::new_v4();
let deleted = DeletedObject {
object_name: "object".to_string(),
delete_marker: true,
delete_marker_version_id: Some(marker_version_id),
found: true,
..Default::default()
};
let (event_name, object) =
noncurrent_expiration_event("bucket", &target_version(None), &deleted, false).expect("a committed delete must emit");
assert_eq!(event_name, EventName::LifecycleExpirationDeleteMarkerCreated);
assert_eq!(object.version_id, Some(marker_version_id));
}
/// A batch mixes successes with failures and versions that were already
/// gone; only a real mutation may produce an event. A version that was
/// already gone comes back with no error and `found` set, so
/// `source_missing` is the signal that keeps it silent.
#[test]
fn noncurrent_expiration_skips_failed_and_missing_versions() {
let version_id = Uuid::new_v4();
let target = target_version(Some(version_id));
assert!(noncurrent_expiration_event("bucket", &target, &deleted_version(version_id), true).is_none());
let absent = DeletedObject {
source_missing: true,
..deleted_version(version_id)
};
assert!(noncurrent_expiration_event("bucket", &target, &absent, false).is_none());
let not_found = DeletedObject {
found: false,
..deleted_version(version_id)
};
assert!(noncurrent_expiration_event("bucket", &target, &not_found, false).is_none());
}
}
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,7 @@ use crate::object_api::ObjectInfo;
pub use rustfs_lifecycle::{
Event, ExpirationOptions, IlmAction, Lifecycle, LifecycleCalculate, ObjectOpts, RuleValidate, TRANSITION_COMPLETE,
TRANSITION_PENDING, TransitionOptions, abort_incomplete_multipart_upload_due, expected_expiry_time,
expiration_action_has_valid_target,
};
pub fn object_opts_from_object_info(oi: &ObjectInfo) -> ObjectOpts {
@@ -182,6 +182,16 @@ pub(crate) enum DurableIlmRecordCheckpoint {
identity_sha256: String,
state: tier_delete_journal::TierDeleteDispatchManifestState,
},
TierDeleteDispatchParent {
content_sha256: String,
identity_sha256: String,
revision: u64,
next_chunk_sequence: u64,
completed_journal_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
active_chunk_identity_sha256: Option<String>,
completed: bool,
},
TransitionTransaction {
content_sha256: String,
identity_sha256: String,
@@ -220,6 +230,7 @@ impl DurableIlmRecordCheckpoint {
match self {
Self::TierDeleteJournal { content_sha256, .. }
| Self::TierDeleteDispatchManifest { content_sha256, .. }
| Self::TierDeleteDispatchParent { content_sha256, .. }
| Self::TransitionTransaction { content_sha256, .. }
| Self::ManualTransitionJob { content_sha256, .. }
| Self::ManualTransitionScope { content_sha256, .. }
@@ -341,6 +352,52 @@ impl DurableIlmRecordCheckpoint {
(Preparing, DispatchAuthorized | Aborting) | (Aborting, Aborted) | (DispatchAuthorized, Completed)
)
}
(
Self::TierDeleteDispatchParent {
identity_sha256: previous_identity,
revision: previous_revision,
next_chunk_sequence: previous_sequence,
completed_journal_count: previous_completed_journals,
active_chunk_identity_sha256: previous_active,
completed: previous_completed,
..
},
Self::TierDeleteDispatchParent {
identity_sha256: next_identity,
revision: next_revision,
next_chunk_sequence: next_sequence,
completed_journal_count: next_completed_journals,
active_chunk_identity_sha256: next_active,
completed: next_completed,
..
},
) => {
let Some((sequence_delta, completed_journal_delta)) = tier_delete_dispatch_parent_progress_delta(
*previous_sequence,
*previous_completed_journals,
*next_sequence,
*next_completed_journals,
) else {
return Err(Error::other("durable ILM record generation is not a monotonic successor"));
};
let same_position_transition = sequence_delta == 0
&& completed_journal_delta == 0
&& matches!(
(previous_active.as_ref(), next_active.as_ref(), previous_completed, next_completed),
(None, Some(_), false, false) | (Some(_), None, false, false) | (None, None, false, true)
);
let progress_transition = sequence_delta > 0
&& completed_journal_delta > 0
&& !matches!(
(previous_active.as_ref(), next_active.as_ref()),
(Some(previous), Some(next)) if previous == next
);
previous_identity == next_identity
&& !previous_completed
&& next_revision > previous_revision
&& (!next_completed || next_active.is_none())
&& (same_position_transition || progress_transition)
}
(
Self::TransitionTransaction {
identity_sha256: previous_identity,
@@ -475,11 +532,58 @@ impl DurableIlmRecordCheckpoint {
..
},
) => previous_identity == terminal_identity,
(
Self::TierDeleteDispatchParent {
identity_sha256: previous_identity,
revision: previous_revision,
next_chunk_sequence: previous_sequence,
completed_journal_count: previous_completed_journals,
active_chunk_identity_sha256: previous_active,
completed: false,
..
},
Self::TierDeleteDispatchParent {
identity_sha256: terminal_identity,
revision: terminal_revision,
next_chunk_sequence: terminal_sequence,
completed_journal_count: terminal_completed_journals,
active_chunk_identity_sha256: None,
completed: true,
..
},
) => {
previous_identity == terminal_identity
&& terminal_revision > previous_revision
&& tier_delete_dispatch_parent_progress_delta(
*previous_sequence,
*previous_completed_journals,
*terminal_sequence,
*terminal_completed_journals,
)
.is_some_and(|(sequence_delta, completed_journal_delta)| {
if sequence_delta == 0 && completed_journal_delta == 0 {
previous_active.is_none()
} else {
sequence_delta > 0 && completed_journal_delta > 0
}
})
}
_ => false,
}
}
}
fn tier_delete_dispatch_parent_progress_delta(
previous_sequence: u64,
previous_completed_journals: u64,
next_sequence: u64,
next_completed_journals: u64,
) -> Option<(u64, u64)> {
let sequence_delta = next_sequence.checked_sub(previous_sequence)?;
let completed_journal_delta = next_completed_journals.checked_sub(previous_completed_journals)?;
(sequence_delta <= completed_journal_delta).then_some((sequence_delta, completed_journal_delta))
}
fn transition_state_distance(
from: transition_transaction::TransitionTransactionState,
to: transition_transaction::TransitionTransactionState,
@@ -913,17 +1017,42 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
)
}
DurableIlmRecordKind::TierDeleteDispatchManifest => {
let (operation_id, identity_sha256, state) =
tier_delete_journal::validate_tier_delete_dispatch_manifest_record(path, data)?;
(
"operation_id",
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
DurableIlmRecordCheckpoint::TierDeleteDispatchManifest {
content_sha256,
match tier_delete_journal::validate_tier_delete_dispatch_manifest_record(path, data)? {
tier_delete_journal::TierDeleteDispatchDurableRecord::Manifest {
operation_id,
identity_sha256,
state,
},
)
} => (
"operation_id",
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
DurableIlmRecordCheckpoint::TierDeleteDispatchManifest {
content_sha256,
identity_sha256,
state,
},
),
tier_delete_journal::TierDeleteDispatchDurableRecord::Parent {
operation_id,
identity_sha256,
revision,
next_chunk_sequence,
completed_journal_count,
active_chunk_identity_sha256,
completed,
} => (
"operation_id",
hex_sha256(operation_id.as_bytes(), ToOwned::to_owned),
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
content_sha256,
identity_sha256,
revision,
next_chunk_sequence,
completed_journal_count,
active_chunk_identity_sha256,
completed,
},
),
}
}
DurableIlmRecordKind::TransitionTransaction => {
let transaction = transition_transaction::decode_transition_transaction_record(path, data)
@@ -1143,6 +1272,85 @@ mod tests {
assert!(aborted.validate_successor(&preparing).is_err());
}
#[test]
fn tier_delete_dispatch_parent_checkpoint_is_monotonic_across_chunks() {
let identity = "a".repeat(64);
let checkpoint = |revision, sequence, completed_journals, active: Option<&str>, completed| {
DurableIlmRecordCheckpoint::TierDeleteDispatchParent {
content_sha256: format!("{revision:064x}"),
identity_sha256: identity.clone(),
revision,
next_chunk_sequence: sequence,
completed_journal_count: completed_journals,
active_chunk_identity_sha256: active.map(ToOwned::to_owned),
completed,
}
};
let idle = checkpoint(0, 0, 0, None, false);
let first_child = "b".repeat(64);
let second_child = "c".repeat(64);
let bound = checkpoint(1, 0, 0, Some(&first_child), false);
let advanced = checkpoint(2, 1, 2, None, false);
let next_bound = checkpoint(3, 1, 2, Some(&second_child), false);
let completed = checkpoint(4, 2, 3, None, true);
let terminal_after_more_chunks = checkpoint(6, 4, 7, None, true);
idle.validate_successor(&bound).expect("an idle parent may bind one child");
bound
.validate_successor(&advanced)
.expect("a completed child may advance the parent sequence");
advanced
.validate_successor(&next_bound)
.expect("the next sequence may bind a new immutable child");
next_bound
.validate_successor(&completed)
.expect("receipt progress may skip directly to a later terminal checkpoint");
assert!(
bound.is_predecessor_of_terminal(&terminal_after_more_chunks),
"terminal cleanup may still recognize a valid multi-chunk predecessor"
);
assert!(
advanced.is_predecessor_of_terminal(&terminal_after_more_chunks),
"terminal cleanup may still skip over later valid parent generations"
);
assert!(
idle.validate_successor(&checkpoint(1, 0, 1, Some(&first_child), false))
.is_err()
);
assert!(bound.validate_successor(&checkpoint(2, 1, 0, None, false)).is_err());
assert!(
bound.validate_successor(&checkpoint(2, 2, 1, None, false)).is_err(),
"sequence cannot advance beyond completed journal evidence"
);
assert!(
advanced.validate_successor(&checkpoint(3, 1, 3, None, false)).is_err(),
"completed journal count cannot grow without a completed child sequence"
);
assert!(
bound.validate_successor(&checkpoint(2, 0, 0, None, true)).is_err(),
"an active child cannot be marked completed without completion evidence"
);
assert!(
bound
.validate_successor(&checkpoint(2, 0, 0, Some(&second_child), false))
.is_err(),
"an active child cannot be replaced at the same parent sequence"
);
assert!(
bound
.validate_successor(&checkpoint(2, 1, 1, Some(&first_child), false))
.is_err(),
"sequence growth cannot retain the same active child identity"
);
assert!(
!bound.is_predecessor_of_terminal(&checkpoint(2, 0, 0, None, true)),
"terminal cleanup must not treat an active child as completed without count evidence"
);
assert!(completed.validate_successor(&checkpoint(5, 3, 4, None, true)).is_err());
assert!(completed.validate_successor(&advanced).is_err());
assert!(advanced.validate_successor(&idle).is_err());
}
#[test]
fn tier_delete_journal_checkpoint_binds_dispatch_and_full_state_monotonically() {
use crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::{Committed, Dispatched, Prepared};
@@ -20,6 +20,7 @@ const LOG_SUBSYSTEM_LIFECYCLE: &str = "lifecycle";
const EVENT_LIFECYCLE_CLEANUP_SKIPPED: &str = "lifecycle_cleanup_skipped";
const EVENT_LIFECYCLE_CLEANUP_FAILED: &str = "lifecycle_cleanup_failed";
use crate::bucket::lifecycle::bucket_lifecycle_audit::emit_noncurrent_expiration_event;
use crate::bucket::lifecycle::lifecycle;
use crate::bucket::lifecycle::replication_sink::{self, ReplicationObjectBridge};
use crate::object_api::ObjectOptions;
@@ -34,7 +35,23 @@ pub async fn delete_object_versions(
to_del: &[ObjectToDelete],
_lc_event: lifecycle::Event,
bucket_incarnation_id: Uuid,
) {
) -> usize {
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())
}) {
debug!(
event = EVENT_LIFECYCLE_CLEANUP_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
bucket,
target_count = to_del.len(),
reason = "incomplete_version_identity",
"Skipped lifecycle noncurrent version cleanup"
);
return to_del.len();
}
let delete_config_snapshot = match ReplicationObjectBridge::delete_request_config(api, bucket).await {
Ok(snapshot) => Arc::new(snapshot),
Err(err) => {
@@ -47,10 +64,11 @@ pub async fn delete_object_versions(
reason = "delete_config_snapshot_unavailable",
"Skipped lifecycle noncurrent version cleanup"
);
return;
return to_del.len();
}
};
let mut remaining = to_del;
let mut failed = 0;
loop {
let mut to_del = remaining;
if to_del.len() > MAX_DELETE_LIST {
@@ -71,6 +89,7 @@ pub async fn delete_object_versions(
},
)
.await;
failed += errors.iter().filter(|err| err.is_some()).count();
for (i, deleted_obj) in deleted_objs.iter_mut().enumerate() {
if errors.get(i).and_then(|err| err.as_ref()).is_some() {
@@ -80,6 +99,12 @@ pub async fn delete_object_versions(
// version so it does not sit resident until TTL (ODC-26).
if let Some(target) = to_del.get(i) {
crate::object_api::notify_object_mutation(bucket, &target.object_name).await;
// Announce the version this batch actually removed. Cache
// eviction and replication scheduling keep their existing
// order and admission; the event is derived from the committed
// result, and a send failure never rolls back a delete that
// already happened (backlog#2202).
emit_noncurrent_expiration_event(bucket, target, deleted_obj, false);
}
if deleted_obj.replication_state.is_none() {
continue;
@@ -111,4 +136,5 @@ pub async fn delete_object_versions(
break;
}
}
failed
}
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use rustfs_scanner_contracts::metrics::IlmAction;
use rustfs_scanner_metrics::metrics::IlmAction;
use crate::bucket::lifecycle::lifecycle::ObjectOpts;
use crate::bucket::replication::ReplicationLifecycleBridge;
@@ -77,7 +77,7 @@ mod tests {
use crate::bucket::replication::{DeleteReplicationConfigSnapshot, ReplicationObjectBridge};
use crate::object_api::{ObjectInfo, ObjectOptions};
use crate::storage_api_contracts::object::ObjectToDelete;
use rustfs_scanner_contracts::metrics::IlmAction;
use rustfs_scanner_metrics::metrics::IlmAction;
use s3s::dto::{
BucketVersioningStatus, DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication,
DeleteReplicationStatus, Destination, ReplicationConfiguration, ReplicationRule, ReplicationRuleStatus,
File diff suppressed because it is too large Load Diff
@@ -32,7 +32,10 @@ use crate::store::ECStore;
use rustfs_filemeta::FileInfo;
pub const DEFAULT_FREE_VERSION_RECOVERY_LIMIT: usize = 1_000;
// These are page-wide repair budgets. Applying them per bucket would still let
// one recovery pass walk an unbounded namespace before the scheduler can cool down.
const DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT: usize = 10_000;
const DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT: usize = 100;
#[cfg(not(test))]
const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(test)]
@@ -41,6 +44,12 @@ const BACKGROUND_WALK_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
type ObjectInfoOrErr = StorageObjectInfoOrErr<ObjectInfo, crate::error::Error>;
type WalkOptions = StorageWalkOptions<fn(&FileInfo) -> bool>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct RecoveryWorkBudget {
pub(super) max_objects: usize,
pub(super) max_buckets: usize,
}
fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
WalkOptions {
include_free_versions: true,
@@ -58,6 +67,7 @@ fn recovery_walk_options(limit: usize, marker: Option<String>) -> WalkOptions {
#[cfg(test)]
pub(super) enum RecoveryWalkTestAction {
SendItems(Vec<ObjectInfo>),
SendItemsThenError(Vec<ObjectInfo>, crate::error::Error),
SendItemsThenHang(Vec<ObjectInfo>, Arc<tokio::sync::Notify>),
SendItemsUntilReceiverCloses(Arc<tokio::sync::Notify>),
@@ -277,6 +287,17 @@ pub(super) async fn list_tier_free_versions(
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
) -> Result<FreeVersionRecoveryPage> {
list_tier_free_versions_with_budget(api, limit, bucket_marker, object_marker, cancel_token, recovery_work_budget(limit)).await
}
pub(super) async fn list_tier_free_versions_with_budget(
api: Arc<ECStore>,
limit: usize,
bucket_marker: Option<String>,
object_marker: Option<String>,
cancel_token: CancellationToken,
work_budget: RecoveryWorkBudget,
) -> Result<FreeVersionRecoveryPage> {
let mut page = FreeVersionRecoveryPage {
items: Vec::new(),
@@ -290,6 +311,9 @@ pub(super) async fn list_tier_free_versions(
if limit == 0 {
return Ok(page);
}
if work_budget.max_objects == 0 || work_budget.max_buckets == 0 {
return Err(std::io::Error::other("free-version recovery work budget must be greater than zero").into());
}
let bucket_options = BucketOptions::default();
let list_buckets = async {
@@ -313,7 +337,7 @@ pub(super) async fn list_tier_free_versions(
};
let mut bucket_seen = bucket_marker.is_none();
let mut truncated_after: Option<RecoveryCursor> = None;
let walk_scan_limit = recovery_walk_scan_limit(limit);
let mut remaining_scan_objects = work_budget.max_objects;
for bucket in buckets {
if cancel_token.is_cancelled() {
@@ -329,12 +353,20 @@ pub(super) async fn list_tier_free_versions(
bucket_seen = true;
}
if page.buckets_scanned >= work_budget.max_buckets {
page.truncated = true;
page.next_bucket_marker = Some(bucket.name);
page.next_object_marker = None;
break;
}
page.buckets_scanned += 1;
let bucket_object_marker = if bucket_marker.as_deref() == Some(bucket.name.as_str()) {
object_marker.clone()
} else {
None
};
let bucket_walk_limit = remaining_scan_objects;
let (tx, mut rx) = mpsc::channel::<ObjectInfoOrErr>(100);
let cancel = cancel_token.child_token();
@@ -358,6 +390,21 @@ pub(super) async fn list_tier_free_versions(
#[cfg(test)]
if let Some(action) = test_action {
match action {
RecoveryWalkTestAction::SendItems(items) => {
for item in items {
if tx
.send(ObjectInfoOrErr {
item: Some(item),
err: None,
})
.await
.is_err()
{
return Ok(());
}
}
return Ok(());
}
RecoveryWalkTestAction::SendItemsThenError(items, err) => {
for item in items {
if tx
@@ -425,7 +472,7 @@ pub(super) async fn list_tier_free_versions(
}
}
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(walk_scan_limit, object_marker))
api.walk(cancel, &bucket_name, "", tx, recovery_walk_options(bucket_walk_limit, object_marker))
.await
}
});
@@ -466,6 +513,18 @@ pub(super) async fn list_tier_free_versions(
let Some(oi) = item.item else {
continue;
};
if last_seen_object.as_deref() != Some(oi.name.as_str()) && scanned_objects >= bucket_walk_limit {
// The disk listing limit follows S3-visible counting rules and
// does not charge metadata containing only hidden/free versions.
// Enforce the repair budget again at the decoded-object boundary.
page.truncated = true;
page.next_bucket_marker = Some(bucket.name.clone());
page.next_object_marker = last_seen_object.clone();
cancel.cancel();
draining_after_truncation = true;
drain_deadline = Some(tokio::time::Instant::now() + BACKGROUND_WALK_SHUTDOWN_TIMEOUT);
continue;
}
record_scanned_object(&mut last_seen_object, &mut scanned_objects, &oi.name);
if let Some(cursor) = &truncated_after
&& (cursor.bucket.as_str() != bucket.name.as_str() || cursor.object.as_str() != oi.name.as_str())
@@ -509,7 +568,8 @@ pub(super) async fn list_tier_free_versions(
return Err(err);
}
walk_result?;
mark_scan_truncated_if_needed(&mut page, scanned_objects, walk_scan_limit, &bucket.name, last_seen_object.as_deref());
remaining_scan_objects = remaining_scan_objects.saturating_sub(scanned_objects);
mark_scan_truncated_if_needed(&mut page, scanned_objects, bucket_walk_limit, &bucket.name, last_seen_object.as_deref());
if page.truncated {
break;
@@ -528,6 +588,13 @@ fn recovery_walk_scan_limit(limit: usize) -> usize {
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT.max(limit.saturating_add(1))
}
fn recovery_work_budget(limit: usize) -> RecoveryWorkBudget {
RecoveryWorkBudget {
max_objects: recovery_walk_scan_limit(limit),
max_buckets: DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT,
}
}
fn record_scanned_object(last_seen_object: &mut Option<String>, scanned_objects: &mut usize, object: &str) {
if last_seen_object.as_deref() == Some(object) {
return;
@@ -700,6 +767,13 @@ mod tests {
recovery_walk_scan_limit(DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT),
DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT + 1
);
assert_eq!(
recovery_work_budget(DEFAULT_FREE_VERSION_RECOVERY_LIMIT),
RecoveryWorkBudget {
max_objects: DEFAULT_FREE_VERSION_RECOVERY_SCAN_LIMIT,
max_buckets: DEFAULT_FREE_VERSION_RECOVERY_BUCKET_LIMIT,
}
);
}
#[test]
@@ -19,6 +19,7 @@
#![allow(clippy::all)]
use rustfs_data_usage::TierStats;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::HashMap;
use std::ops::Sub;
@@ -27,7 +28,24 @@ use tracing::{error, warn};
pub type DailyAllTierStats = HashMap<String, LastDayTierStats>;
#[derive(Clone)]
/// One bin per hour of the rolling day. The bin index is the UTC hour, so the
/// array is a ring the writer ages forward rather than a queue.
pub const TIER_DAILY_STATS_BINS: usize = 24;
/// Interchange form of [`LastDayTierStats`] for the internode tier-stats RPC.
///
/// The in-memory type keeps its bins private because the ring is only
/// meaningful together with `updated_at`; this type carries both across the
/// wire and is validated back into the ring by [`LastDayTierStats::from_wire`].
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TierDailyStatsWire {
pub bins: Vec<TierStats>,
/// Seconds since the Unix epoch. Bins are hour-resolution, so a coarser
/// timestamp than the in-memory `OffsetDateTime` loses nothing.
pub updated_at_unix_secs: i64,
}
#[derive(Clone, Debug)]
pub struct LastDayTierStats {
bins: [TierStats; 24],
updated_at: OffsetDateTime,
@@ -80,11 +98,57 @@ impl LastDayTierStats {
}
}
#[allow(
dead_code,
reason = "asserted by this file's tests; the lib target cannot see test-only consumers (backlog#1823)"
)]
fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
/// The rolling ring as observed, without aging it forward.
///
/// Only meaningful together with [`LastDayTierStats::updated_at`]: a bin
/// belongs to the hour of its index within the day that ends at
/// `updated_at`.
pub fn bins(&self) -> &[TierStats; TIER_DAILY_STATS_BINS] {
&self.bins
}
pub fn updated_at(&self) -> OffsetDateTime {
self.updated_at
}
pub fn to_wire(&self) -> TierDailyStatsWire {
TierDailyStatsWire {
bins: self.bins.to_vec(),
updated_at_unix_secs: self.updated_at.unix_timestamp(),
}
}
/// Rebuild the ring from a peer's response.
///
/// A ring of the wrong width or an unrepresentable timestamp is corrupt
/// peer input, not a zero sample: it returns an error so the caller can
/// report the node as non-reporting instead of merging a plausible but
/// wrong day into a cluster total.
pub fn from_wire(wire: TierDailyStatsWire) -> Result<Self, std::io::Error> {
let bins: [TierStats; TIER_DAILY_STATS_BINS] = wire.bins.try_into().map_err(|bins: Vec<TierStats>| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("tier daily stats must carry {TIER_DAILY_STATS_BINS} bins, got {}", bins.len()),
)
})?;
let updated_at = OffsetDateTime::from_unix_timestamp(wire.updated_at_unix_secs).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("tier daily stats carry an unrepresentable timestamp: {err}"),
)
})?;
Ok(Self { bins, updated_at })
}
/// Combine two independently observed rings.
///
/// Each node counts only the transitions it completed itself, so summing
/// bins across nodes is a cluster total rather than a double count. The
/// older ring is aged forward to the newer one's clock first, so a node
/// that stopped transitioning hours ago contributes its still-current
/// bins and not its expired ones.
pub fn merge(&self, m: LastDayTierStats) -> LastDayTierStats {
let mut cl = self.clone();
let mut cm = m;
let mut merged = LastDayTierStats::default();
@@ -108,6 +172,7 @@ impl LastDayTierStats {
#[cfg(test)]
mod test {
use super::*;
use time::Duration;
#[test]
fn total_sums_all_recorded_stats() {
@@ -132,4 +197,83 @@ mod test {
}
);
}
fn sample(total_size: u64) -> TierStats {
TierStats {
total_size,
num_versions: 1,
num_objects: 1,
}
}
#[test]
fn wire_round_trip_preserves_the_ring_and_its_clock() {
let mut stats = LastDayTierStats::default();
stats.add_stats(sample(10));
let restored = LastDayTierStats::from_wire(stats.to_wire()).expect("a ring this node produced must decode");
assert_eq!(restored.bins(), stats.bins(), "every bin must survive the wire");
assert_eq!(
restored.updated_at().unix_timestamp(),
stats.updated_at().unix_timestamp(),
"the ring's clock must survive the wire"
);
}
#[test]
fn a_ring_of_the_wrong_width_is_rejected() {
let mut wire = LastDayTierStats::default().to_wire();
wire.bins.pop();
let err = LastDayTierStats::from_wire(wire).expect_err("a short ring must not decode as a zero day");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn an_unrepresentable_clock_is_rejected() {
let mut wire = LastDayTierStats::default().to_wire();
wire.updated_at_unix_secs = i64::MIN;
let err = LastDayTierStats::from_wire(wire).expect_err("an unrepresentable clock must not decode");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn merge_sums_two_nodes_that_transitioned_in_the_same_hour() {
let mut left = LastDayTierStats::default();
left.add_stats(sample(10));
let mut right = LastDayTierStats::default();
right.add_stats(sample(20));
assert_eq!(
left.merge(right).total(),
TierStats {
total_size: 30,
num_versions: 2,
num_objects: 2,
},
"each node counts only its own completions, so a merge is a cluster total"
);
}
#[test]
fn merge_ages_out_a_peer_ring_older_than_a_day() {
let mut stale = LastDayTierStats::default();
stale.add_stats(sample(10));
stale.updated_at -= Duration::days(2);
let mut fresh = LastDayTierStats::default();
fresh.add_stats(sample(20));
assert_eq!(
fresh.merge(stale).total(),
TierStats {
total_size: 20,
num_versions: 1,
num_objects: 1,
},
"a node that stopped transitioning more than a day ago must not keep contributing"
);
}
}
@@ -575,6 +575,7 @@ pub(crate) async fn delete_confirmed_transition_candidate_exact_with_lease_idemp
#[cfg(test)]
static CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(test)]
pub(crate) async fn delete_confirmed_transition_candidate_exact_with_manager_and_identity(
obj_name: &str,
rv_id: &str,
@@ -25,14 +25,17 @@ use crate::bucket::lifecycle::durable_namespace::TRANSITION_TRANSACTION_NAMESPAC
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
use crate::bucket::lifecycle::tier_sweeper::{
delete_confirmed_transition_candidate_exact_with_lease_idempotent,
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
};
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result as EcstoreResult};
use crate::object_api::ObjectOptions;
use crate::services::tier::{tier::TierConfigMgr, warm_backend::TransitionCandidateProbe};
use crate::storage_api_contracts::{list::ListOperations as _, object::ObjectOperations as _};
use crate::storage_api_contracts::{
list::ListOperations as _,
namespace::NamespaceLocking as _,
object::{HTTPPreconditions, ObjectOperations as _},
};
use crate::store::ECStore;
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
@@ -110,7 +113,7 @@ pub struct TransitionRemoteVersion {
impl TransitionRemoteVersion {
pub fn known_from_put_response(version_id: impl Into<String>) -> Self {
let version_id = version_id.into();
if version_id.is_empty() || Uuid::parse_str(&version_id).is_ok_and(|parsed| parsed.is_nil()) {
if version_id.is_empty() {
Self::unversioned()
} else {
Self::versioned(version_id)
@@ -307,10 +310,16 @@ impl TransitionTransaction {
if self.write_id.is_nil() {
return Err(TransitionTransactionError::Corrupt("write_id is nil"));
}
if self.not_after_unix_nanos <= 0 {
return Err(TransitionTransactionError::Corrupt("ownership deadline is not positive"));
}
self.source.validate()?;
if self.tier_name.is_empty() {
return Err(TransitionTransactionError::Corrupt("tier name is empty"));
}
if self.backend_fingerprint == [0; 32] {
return Err(TransitionTransactionError::Corrupt("backend fingerprint is empty"));
}
if self.remote_object
!= canonical_transition_remote_object(self.deployment_id, &self.source.bucket, self.transaction_id, self.write_id)?
{
@@ -476,6 +485,18 @@ impl TransitionTransaction {
Ok(())
}
fn has_same_immutable_identity(&self, other: &Self) -> bool {
self.deployment_id == other.deployment_id
&& self.transaction_id == other.transaction_id
&& self.owner_epoch == other.owner_epoch
&& self.write_id == other.write_id
&& self.source == other.source
&& self.tier_name == other.tier_name
&& self.backend_fingerprint == other.backend_fingerprint
&& self.remote_object == other.remote_object
&& self.not_after_unix_nanos == other.not_after_unix_nanos
}
fn validate_cleanup_proof(&self, proof: &TransitionCleanupProof) -> Result<()> {
if proof.transaction_id != self.transaction_id
|| proof.write_id != self.write_id
@@ -585,20 +606,91 @@ pub(crate) async fn save_transition_transaction_record(
let object =
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
let data = transaction.encode().map_err(transition_transaction_store_error)?;
config_boundary::save_config(api.clone(), &object, data.clone()).await?;
config_boundary::save_config_with_opts(
api.clone(),
&object,
data.clone(),
&ObjectOptions {
max_parity: true,
http_preconditions: Some(HTTPPreconditions {
if_none_match: Some("*".to_string()),
..Default::default()
}),
..Default::default()
},
)
.await?;
// Box::pin: the durable-receipt state machine is large and sits on the
// already-deep transition worker poll chain; keeping it inline overflows
// the default 2 MiB tokio worker stack in debug builds.
Box::pin(api.record_durable_ilm_decommission_progress(&object, &data)).await
}
pub(crate) async fn save_transition_transaction_record_if_current(
api: Arc<ECStore>,
expected: &TransitionTransaction,
next: &TransitionTransaction,
) -> EcstoreResult<()> {
let object = transition_transaction_record_object_name(next.transaction_id).map_err(transition_transaction_store_error)?;
let revision_is_next = expected.revision.checked_add(1) == Some(next.revision);
let state_is_next = state_change_allowed(expected.state, next.state)
|| matches!(
(expected.state, next.state),
(
TransitionTransactionState::Uploaded
| TransitionTransactionState::UploadOutcomeUnknown
| TransitionTransactionState::LocalCommitStarted,
TransitionTransactionState::CleanupPending
)
);
let remote_version_is_monotonic = expected.remote_version.is_unknown() || expected.remote_version == next.remote_version;
if !expected.has_same_immutable_identity(next) || !revision_is_next || !state_is_next || !remote_version_is_monotonic {
return Err(Error::PreconditionFailed);
}
let (current, etag) = load_transition_transaction_record_with_etag(api.clone(), expected.transaction_id).await?;
if &current != expected {
return Err(Error::PreconditionFailed);
}
let data = next.encode().map_err(transition_transaction_store_error)?;
config_boundary::save_config_with_opts(
api.clone(),
&object,
data.clone(),
&ObjectOptions {
max_parity: true,
http_preconditions: Some(HTTPPreconditions {
if_match: Some(etag),
..Default::default()
}),
..Default::default()
},
)
.await?;
// Box::pin: see save_transition_transaction_record.
Box::pin(api.record_durable_ilm_decommission_progress(&object, &data)).await
}
pub(crate) async fn load_transition_transaction_record(
api: Arc<ECStore>,
transaction_id: Uuid,
) -> EcstoreResult<TransitionTransaction> {
load_transition_transaction_record_with_etag(api, transaction_id)
.await
.map(|(transaction, _)| transaction)
}
async fn load_transition_transaction_record_with_etag(
api: Arc<ECStore>,
transaction_id: Uuid,
) -> EcstoreResult<(TransitionTransaction, String)> {
let object = transition_transaction_record_object_name(transaction_id).map_err(transition_transaction_store_error)?;
let data = config_boundary::read_config(api, &object).await?;
TransitionTransaction::decode(transaction_id, &data).map_err(transition_transaction_store_error)
let (data, object_info) = config_boundary::read_config_with_metadata(api, &object, &ObjectOptions::default()).await?;
let etag = object_info
.etag
.filter(|etag| !etag.trim().is_empty())
.ok_or_else(|| Error::other("transition transaction record is missing an ETag"))?;
let transaction = TransitionTransaction::decode(transaction_id, &data).map_err(transition_transaction_store_error)?;
Ok((transaction, etag))
}
pub(crate) async fn delete_transition_transaction_record(
@@ -607,10 +699,18 @@ pub(crate) async fn delete_transition_transaction_record(
) -> EcstoreResult<()> {
let object =
transition_transaction_record_object_name(transaction.transaction_id).map_err(transition_transaction_store_error)?;
let data = transaction.encode().map_err(transition_transaction_store_error)?;
let (current, etag) = match load_transition_transaction_record_with_etag(api.clone(), transaction.transaction_id).await {
Ok(record) => record,
Err(Error::ConfigNotFound) => return Ok(()),
Err(err) => return Err(err),
};
if &current != transaction {
return Err(Error::PreconditionFailed);
}
let data = current.encode().map_err(transition_transaction_store_error)?;
// Box::pin: see save_transition_transaction_record.
Box::pin(api.record_durable_ilm_decommission_terminal(&object, &data)).await?;
match config_boundary::delete_config(api, &object).await {
match config_boundary::delete_config_if_match(api, &object, &etag).await {
Ok(()) | Err(Error::ConfigNotFound) => Ok(()),
Err(err) => Err(err),
}
@@ -637,6 +737,84 @@ pub enum TransitionTransactionRecoveryOutcome {
Retained,
}
#[cfg(test)]
#[derive(Default)]
struct TransitionRecoveryClaimBarrierState {
transaction_id: Uuid,
arrived: tokio::sync::Notify,
release: tokio::sync::Notify,
}
#[cfg(test)]
pub(crate) struct TransitionRecoveryClaimBarrier {
state: Arc<TransitionRecoveryClaimBarrierState>,
}
#[cfg(test)]
static TRANSITION_RECOVERY_CLAIM_BARRIER: std::sync::OnceLock<
std::sync::Mutex<Option<Arc<TransitionRecoveryClaimBarrierState>>>,
> = std::sync::OnceLock::new();
#[cfg(test)]
impl TransitionRecoveryClaimBarrier {
pub(crate) fn install(transaction_id: Uuid) -> Self {
let state = Arc::new(TransitionRecoveryClaimBarrierState {
transaction_id,
..Default::default()
});
let mut slot = TRANSITION_RECOVERY_CLAIM_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("transition recovery claim barrier mutex should not poison");
assert!(
slot.is_none(),
"transition recovery claim barrier must be installed by one test at a time"
);
*slot = Some(Arc::clone(&state));
drop(slot);
Self { state }
}
pub(crate) async fn wait_until_paused(&self) {
tokio::time::timeout(Duration::from_secs(30), self.state.arrived.notified())
.await
.expect("transition recovery should reach the cleanup claim CAS");
}
pub(crate) fn release(&self) {
self.state.release.notify_one();
}
}
#[cfg(test)]
impl Drop for TransitionRecoveryClaimBarrier {
fn drop(&mut self) {
self.state.release.notify_one();
let mut slot = TRANSITION_RECOVERY_CLAIM_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("transition recovery claim barrier mutex should not poison");
if slot.as_ref().is_some_and(|state| Arc::ptr_eq(state, &self.state)) {
*slot = None;
}
}
}
#[cfg(test)]
async fn pause_before_transition_recovery_claim(transaction_id: Uuid) {
let barrier = TRANSITION_RECOVERY_CLAIM_BARRIER
.get_or_init(|| std::sync::Mutex::new(None))
.lock()
.expect("transition recovery claim barrier mutex should not poison")
.as_ref()
.filter(|barrier| barrier.transaction_id == transaction_id)
.cloned();
if let Some(barrier) = barrier {
barrier.arrived.notify_one();
barrier.release.notified().await;
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TransitionOperatorProbe {
@@ -858,49 +1036,129 @@ pub async fn process_transition_transaction_record(
transaction: &TransitionTransaction,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
transaction.validate().map_err(transition_transaction_store_error)?;
match transaction.state {
// Box the expanded recovery state machine so callers on Tokio's default
// worker stack do not inline its full future into an already-deep scan.
Box::pin(process_transition_transaction_record_at(
api,
transaction,
time::OffsetDateTime::now_utc().unix_timestamp_nanos(),
))
.await
}
async fn process_transition_transaction_record_at(
api: Arc<ECStore>,
observed: &TransitionTransaction,
now_unix_nanos: i128,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
let record_name =
transition_transaction_record_object_name(observed.transaction_id).map_err(transition_transaction_store_error)?;
// The synthetic key avoids nesting the recovery lock with the config
// object's own I/O lock. Holding it across the bounded source proof and
// remote DELETE elects one destructive recovery worker across nodes.
let recovery_lock = api
.new_ns_lock(RUSTFS_META_BUCKET, &format!("{record_name}.recovery-lock"))
.await?;
let _recovery_guard = recovery_lock
.get_write_lock(crate::set_disk::get_lock_acquire_timeout())
.await?;
let current = match load_transition_transaction_record(api.clone(), observed.transaction_id).await {
Ok(current) => current,
Err(Error::ConfigNotFound) => return Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
Err(err) => return Err(err),
};
if &current != observed {
return Ok(TransitionTransactionRecoveryOutcome::Retained);
}
match current.state {
TransitionTransactionState::Uploaded => {
delete_transition_remote_candidate(api.clone(), transaction).await?;
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
TransitionTransactionState::CleanupPending => match local_commit_matches_transaction(api.clone(), transaction).await {
Ok(true) => {
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
if transition_transaction_ownership_is_active(&current, now_unix_nanos) {
return Ok(TransitionTransactionRecoveryOutcome::Retained);
}
Ok(false) => {
delete_transition_remote_candidate(api.clone(), transaction).await?;
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
Err(err) if transition_source_is_missing(&err) => {
delete_transition_remote_candidate(api.clone(), transaction).await?;
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
Err(err) => Err(err),
},
TransitionTransactionState::LocalCommitStarted => {
match local_commit_matches_transaction(api.clone(), transaction).await {
Ok(true) => {
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
let mut cleanup = current.clone();
cleanup
.mark_cleanup_pending(
current.fence(),
TransitionCleanupProof {
transaction_id: current.transaction_id,
write_id: current.write_id,
remote_object: current.remote_object.clone(),
remote_version: current.remote_version.clone(),
backend_fingerprint: current.backend_fingerprint,
decision: TransitionCleanupDecision::UploadAbortedBeforeLocalCommit,
},
)
.map_err(transition_transaction_store_error)?;
#[cfg(test)]
pause_before_transition_recovery_claim(current.transaction_id).await;
match save_transition_transaction_record_if_current(api.clone(), &current, &cleanup).await {
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) => Err(err),
}
}
TransitionTransactionState::CleanupPending => recover_cleanup_pending(api, &current).await,
TransitionTransactionState::LocalCommitStarted => match local_commit_matches_transaction(api.clone(), &current).await {
Ok(true) => {
delete_transition_transaction_record(api, &current).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
Ok(false) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) if transition_source_is_missing(&err) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) => Err(err),
},
TransitionTransactionState::AbortedNoRemote | TransitionTransactionState::Committed => {
delete_transition_transaction_record(api, &current).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
TransitionTransactionState::UploadOutcomeUnknown => {
if transition_transaction_ownership_is_active(&current, now_unix_nanos) {
Ok(TransitionTransactionRecoveryOutcome::Retained)
} else {
recover_unknown_upload_outcome(api, &current).await
}
}
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
}
}
fn transition_transaction_ownership_is_active(transaction: &TransitionTransaction, now_unix_nanos: i128) -> bool {
now_unix_nanos < i128::from(transaction.not_after_unix_nanos)
}
async fn recover_cleanup_pending(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
match local_commit_matches_transaction(api.clone(), transaction).await {
Ok(true) => {
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RecordDeleted)
}
TransitionTransactionState::UploadOutcomeUnknown => recover_unknown_upload_outcome(api, transaction).await,
TransitionTransactionState::UploadStarted => Ok(TransitionTransactionRecoveryOutcome::Retained),
Ok(false) => delete_unreferenced_transition_candidate(api, transaction).await,
Err(err) if transition_source_is_missing(&err) => delete_unreferenced_transition_candidate(api, transaction).await,
Err(err) => Err(err),
}
}
async fn delete_unreferenced_transition_candidate(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
) -> EcstoreResult<TransitionTransactionRecoveryOutcome> {
let current = match load_transition_transaction_record(api.clone(), transaction.transaction_id).await {
Ok(current) => current,
Err(Error::ConfigNotFound) => return Ok(TransitionTransactionRecoveryOutcome::RecordDeleted),
Err(err) => return Err(err),
};
if &current != transaction || current.state != TransitionTransactionState::CleanupPending {
return Ok(TransitionTransactionRecoveryOutcome::Retained);
}
delete_transition_remote_candidate(api.clone(), &current).await?;
delete_transition_transaction_record(api, &current).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
}
async fn recover_unknown_upload_outcome(
api: Arc<ECStore>,
transaction: &TransitionTransaction,
@@ -928,17 +1186,7 @@ async fn recover_unknown_upload_outcome(
TransitionCandidateProbe::VersionedPresent(version_id)
if Uuid::parse_str(&version_id).is_ok_and(|version_id| version_id.is_nil()) =>
{
delete_confirmed_transition_candidate_exact_with_manager_and_identity(
&transaction.remote_object,
&version_id,
&transaction.tier_name,
transaction.backend_fingerprint,
&api.tier_config_mgr(),
)
.await
.map_err(Error::other)?;
delete_transition_transaction_record(api, transaction).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
Ok(TransitionTransactionRecoveryOutcome::Retained)
}
TransitionCandidateProbe::VersionedPresent(version_id) => {
cleanup_recovered_unknown_upload_candidate(api, transaction, TransitionRemoteVersion::versioned(version_id)).await
@@ -968,10 +1216,11 @@ async fn cleanup_recovered_unknown_upload_candidate(
},
)
.map_err(transition_transaction_store_error)?;
save_transition_transaction_record(api.clone(), &cleanup).await?;
delete_transition_remote_candidate(api.clone(), &cleanup).await?;
delete_transition_transaction_record(api, &cleanup).await?;
Ok(TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted)
match save_transition_transaction_record_if_current(api.clone(), transaction, &cleanup).await {
Ok(()) => recover_cleanup_pending(api, &cleanup).await,
Err(Error::PreconditionFailed) | Err(Error::ConfigNotFound) => Ok(TransitionTransactionRecoveryOutcome::Retained),
Err(err) => Err(err),
}
}
fn transition_source_is_missing(err: &Error) -> bool {
@@ -986,13 +1235,7 @@ fn transition_source_is_missing(err: &Error) -> bool {
}
async fn local_commit_matches_transaction(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<bool> {
let opts = ObjectOptions {
version_id: transaction.source.version_id.map(|version_id| version_id.to_string()),
versioned: transaction.source.version_mode == TransitionSourceVersionMode::Versioned,
version_suspended: transaction.source.version_mode == TransitionSourceVersionMode::VersionSuspended,
metadata_cache_safe: false,
..Default::default()
};
let opts = transition_source_lookup_options(transaction);
let object = api
.get_object_info(&transaction.source.bucket, &transaction.source.object, &opts)
.await?;
@@ -1003,6 +1246,23 @@ async fn local_commit_matches_transaction(api: Arc<ECStore>, transaction: &Trans
&& transitioned.version_id == transaction.remote_version.tier_delete_version_id().unwrap_or_default())
}
fn transition_source_lookup_options(transaction: &TransitionTransaction) -> ObjectOptions {
ObjectOptions {
version_id: match transaction.source.version_mode {
TransitionSourceVersionMode::Versioned => transaction.source.version_id.map(|version_id| version_id.to_string()),
// Both modes identify the stored null version. Query it explicitly
// so a later versioning change cannot redirect the proof to a new latest version.
TransitionSourceVersionMode::Unversioned | TransitionSourceVersionMode::VersionSuspended => {
Some(Uuid::nil().to_string())
}
},
versioned: transaction.source.version_mode == TransitionSourceVersionMode::Versioned,
version_suspended: transaction.source.version_mode == TransitionSourceVersionMode::VersionSuspended,
metadata_cache_safe: false,
..Default::default()
}
}
async fn delete_transition_remote_candidate(api: Arc<ECStore>, transaction: &TransitionTransaction) -> EcstoreResult<()> {
let version_id = transaction.remote_version.tier_delete_version_id().unwrap_or_default();
let version_id_exact = transaction.remote_version.kind == TransitionRemoteVersionKind::Versioned;
@@ -1023,6 +1283,25 @@ pub async fn recover_transition_transaction_records(
api: Arc<ECStore>,
limit: usize,
marker: Option<String>,
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
recover_transition_transaction_records_with_now(api, limit, marker, None).await
}
#[cfg(any(test, feature = "test-util"))]
pub async fn recover_transition_transaction_records_at(
api: Arc<ECStore>,
limit: usize,
marker: Option<String>,
now_unix_nanos: i128,
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
recover_transition_transaction_records_with_now(api, limit, marker, Some(now_unix_nanos)).await
}
async fn recover_transition_transaction_records_with_now(
api: Arc<ECStore>,
limit: usize,
marker: Option<String>,
now_unix_nanos: Option<i128>,
) -> EcstoreResult<TransitionTransactionRecoveryStats> {
if limit == 0 {
return Err(Error::other("transition transaction recovery limit must be greater than zero"));
@@ -1087,7 +1366,13 @@ pub async fn recover_transition_transaction_records(
}
};
match process_transition_transaction_record(api.clone(), &transaction).await {
let recovery = match now_unix_nanos {
Some(now_unix_nanos) => {
Box::pin(process_transition_transaction_record_at(api.clone(), &transaction, now_unix_nanos)).await
}
None => process_transition_transaction_record(api.clone(), &transaction).await,
};
match recovery {
Ok(
TransitionTransactionRecoveryOutcome::RemoteCandidateDeleted
| TransitionTransactionRecoveryOutcome::RecordDeleted,
@@ -1332,6 +1617,34 @@ mod tests {
.expect("expired unknown upload outcome should be eligible");
}
#[test]
fn transition_ownership_window_expires_at_not_after() {
let transaction = new_transaction();
let deadline = i128::from(transaction.not_after_unix_nanos);
assert!(transition_transaction_ownership_is_active(&transaction, deadline - 1));
assert!(!transition_transaction_ownership_is_active(&transaction, deadline));
}
#[test]
fn null_transition_source_lookup_targets_the_exact_version_shape() {
for mode in [
TransitionSourceVersionMode::Unversioned,
TransitionSourceVersionMode::VersionSuspended,
] {
let mut transaction = new_transaction();
transaction.source.version_id = None;
transaction.source.version_mode = mode;
let opts = transition_source_lookup_options(&transaction);
assert_eq!(opts.version_id, Some(Uuid::nil().to_string()));
assert!(!opts.versioned);
assert_eq!(opts.version_suspended, mode == TransitionSourceVersionMode::VersionSuspended);
assert!(!opts.metadata_cache_safe);
}
}
fn cleanup_proof(transaction: &TransitionTransaction, decision: TransitionCleanupDecision) -> TransitionCleanupProof {
TransitionCleanupProof {
transaction_id: transaction.transaction_id,
@@ -1346,10 +1659,17 @@ mod tests {
#[test]
fn remote_version_distinguishes_unknown_unversioned_and_versioned() {
assert_eq!(TransitionRemoteVersion::known_from_put_response("").tier_delete_version_id(), None);
let nil_version = Uuid::nil().to_string();
let invalid_nil = TransitionRemoteVersion::known_from_put_response(nil_version.clone());
assert_eq!(
TransitionRemoteVersion::known_from_put_response(Uuid::nil().to_string()).tier_delete_version_id(),
None
invalid_nil.tier_delete_version_id(),
Some(nil_version.as_str()),
"a non-empty version must never be downgraded to an unversioned DELETE"
);
assert!(matches!(
invalid_nil.validate(),
Err(TransitionTransactionError::Corrupt("versioned remote version is nil uuid"))
));
let version_id = Uuid::new_v4().to_string();
assert_eq!(
@@ -1468,6 +1788,34 @@ mod tests {
})
.is_ok()
);
assert!(matches!(
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: Uuid::new_v4(),
transaction_id: Uuid::new_v4(),
owner_epoch: Uuid::new_v4(),
write_id: Uuid::new_v4(),
source: source_identity(TransitionSourceVersionMode::Unversioned),
tier_name: "warm-tier".to_string(),
backend_fingerprint: [0; 32],
not_after_unix_nanos: 1,
}),
Err(TransitionTransactionError::Corrupt("backend fingerprint is empty"))
));
assert!(matches!(
TransitionTransaction::new(TransitionTransactionInit {
deployment_id: Uuid::new_v4(),
transaction_id: Uuid::new_v4(),
owner_epoch: Uuid::new_v4(),
write_id: Uuid::new_v4(),
source: source_identity(TransitionSourceVersionMode::Unversioned),
tier_name: "warm-tier".to_string(),
backend_fingerprint: BACKEND_FINGERPRINT,
not_after_unix_nanos: 0,
}),
Err(TransitionTransactionError::Corrupt("ownership deadline is not positive"))
));
}
#[test]
+156 -2
View File
@@ -270,6 +270,7 @@ pub const BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG: &str = "public-access-block.xml";
pub const BUCKET_ACL_CONFIG: &str = "bucket-acl.json";
pub const BUCKET_TABLE_CONFIG: &str = "table-bucket.json";
pub const BUCKET_DURABILITY_CONFIG: &str = "durability.json";
pub const BUCKET_ON_DEMAND_MIGRATION_CONFIG: &str = "on-demand-migration.json";
pub const BUCKET_TABLE_RESERVED_PREFIX: &str = ".rustfs-table";
pub const BUCKET_TABLE_CATALOG_META_PREFIX: &str = "s3tables/catalog";
pub const BUCKET_TABLE_CATALOG_TABLE_BUCKETS_PREFIX: &str = "table-buckets";
@@ -321,6 +322,7 @@ pub struct BucketMetadata {
pub bucket_acl_config_json: Vec<u8>,
pub table_bucket_config_json: Vec<u8>,
pub durability_config_json: Vec<u8>,
pub on_demand_migration_config_json: Vec<u8>,
pub policy_config_updated_at: OffsetDateTime,
pub object_lock_config_updated_at: OffsetDateTime,
@@ -342,6 +344,7 @@ pub struct BucketMetadata {
pub bucket_acl_config_updated_at: OffsetDateTime,
pub table_bucket_config_updated_at: OffsetDateTime,
pub durability_config_updated_at: OffsetDateTime,
pub on_demand_migration_config_updated_at: OffsetDateTime,
pub new_field_updated_at: OffsetDateTime,
@@ -393,6 +396,7 @@ impl Default for BucketMetadata {
bucket_acl_config_json: Default::default(),
table_bucket_config_json: Default::default(),
durability_config_json: Default::default(),
on_demand_migration_config_json: Default::default(),
policy_config_updated_at: OffsetDateTime::UNIX_EPOCH,
object_lock_config_updated_at: OffsetDateTime::UNIX_EPOCH,
encryption_config_updated_at: OffsetDateTime::UNIX_EPOCH,
@@ -413,6 +417,7 @@ impl Default for BucketMetadata {
bucket_acl_config_updated_at: OffsetDateTime::UNIX_EPOCH,
table_bucket_config_updated_at: OffsetDateTime::UNIX_EPOCH,
durability_config_updated_at: OffsetDateTime::UNIX_EPOCH,
on_demand_migration_config_updated_at: OffsetDateTime::UNIX_EPOCH,
new_field_updated_at: OffsetDateTime::UNIX_EPOCH,
policy_config: Default::default(),
notification_config: Default::default(),
@@ -477,6 +482,23 @@ impl BucketMetadata {
/// Absent/empty/unparsable payloads all mean "no override" (the bucket
/// follows the global durability mode); a parse failure is logged so a
/// corrupted entry cannot silently change fsync behavior.
/// Parsed on-demand migration config, if one is stored.
///
/// `Ok(None)` means no config (absent or cleared). A stored payload that
/// does not parse is an error, never a default: the runtime must not
/// pull from a source it cannot describe.
pub fn on_demand_migration_config(
&self,
) -> std::result::Result<
Option<super::on_demand_migration::OnDemandMigrationConfig>,
super::on_demand_migration::OnDemandMigrationConfigError,
> {
if self.on_demand_migration_config_json.is_empty() {
return Ok(None);
}
super::on_demand_migration::OnDemandMigrationConfig::from_json(&self.on_demand_migration_config_json).map(Some)
}
pub fn durability_config(&self) -> Option<super::durability::BucketDurabilityConfig> {
if self.durability_config_json.is_empty() {
return None;
@@ -555,6 +577,9 @@ impl BucketMetadata {
"BucketAclConfigJSON" | "BucketAclConfigJson" => self.bucket_acl_config_json = read_msgp_bin(rd)?,
"TableBucketConfigJSON" | "TableBucketConfigJson" => self.table_bucket_config_json = read_msgp_bin(rd)?,
"DurabilityConfigJSON" | "DurabilityConfigJson" => self.durability_config_json = read_msgp_bin(rd)?,
"OnDemandMigrationConfigJSON" | "OnDemandMigrationConfigJson" => {
self.on_demand_migration_config_json = read_msgp_bin(rd)?
}
"CorsConfigUpdatedAt" => self.cors_config_updated_at = read_msgp_time_value(rd)?,
"LoggingConfigUpdatedAt" => self.logging_config_updated_at = read_msgp_time_value(rd)?,
"WebsiteConfigUpdatedAt" => self.website_config_updated_at = read_msgp_time_value(rd)?,
@@ -564,6 +589,7 @@ impl BucketMetadata {
"BucketAclConfigUpdatedAt" => self.bucket_acl_config_updated_at = read_msgp_time_value(rd)?,
"TableBucketConfigUpdatedAt" => self.table_bucket_config_updated_at = read_msgp_time_value(rd)?,
"DurabilityConfigUpdatedAt" => self.durability_config_updated_at = read_msgp_time_value(rd)?,
"OnDemandMigrationConfigUpdatedAt" => self.on_demand_migration_config_updated_at = read_msgp_time_value(rd)?,
other => {
tracing::debug!(field = %other, "BucketMetadata decode_from: skipping unknown field");
skip_msgp_value(rd)?;
@@ -576,8 +602,8 @@ impl BucketMetadata {
/// Encode to msgp bytes. Field order follows MinIO BucketMetadata for compatibility.
pub fn encode_to<W: Write>(&self, wr: &mut W) -> Result<()> {
// Map size: MinIO fields (25) + RustFS extensions (19)
let map_len: u32 = 44;
// Map size: MinIO fields (25) + RustFS extensions (21)
let map_len: u32 = 46;
rmp::encode::write_map_len(wr, map_len)?;
// MinIO field order (same as Go struct)
@@ -637,6 +663,7 @@ impl BucketMetadata {
write_bin_field(wr, "BucketAclConfigJSON", &self.bucket_acl_config_json)?;
write_bin_field(wr, "TableBucketConfigJSON", &self.table_bucket_config_json)?;
write_bin_field(wr, "DurabilityConfigJSON", &self.durability_config_json)?;
write_bin_field(wr, "OnDemandMigrationConfigJSON", &self.on_demand_migration_config_json)?;
rmp::encode::write_str(wr, "CorsConfigUpdatedAt")?;
write_msgp_time(wr, self.cors_config_updated_at)?;
rmp::encode::write_str(wr, "LoggingConfigUpdatedAt")?;
@@ -655,6 +682,8 @@ impl BucketMetadata {
write_msgp_time(wr, self.table_bucket_config_updated_at)?;
rmp::encode::write_str(wr, "DurabilityConfigUpdatedAt")?;
write_msgp_time(wr, self.durability_config_updated_at)?;
rmp::encode::write_str(wr, "OnDemandMigrationConfigUpdatedAt")?;
write_msgp_time(wr, self.on_demand_migration_config_updated_at)?;
Ok(())
}
@@ -756,6 +785,9 @@ impl BucketMetadata {
if self.durability_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.durability_config_updated_at = self.created
}
if self.on_demand_migration_config_updated_at == OffsetDateTime::UNIX_EPOCH {
self.on_demand_migration_config_updated_at = self.created
}
}
pub fn update_config(&mut self, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
@@ -871,6 +903,17 @@ impl BucketMetadata {
self.durability_config_json = data;
self.durability_config_updated_at = updated;
}
BUCKET_ON_DEMAND_MIGRATION_CONFIG => {
// Structural check only (shape, unknown fields); the
// deployment-relative rules run in the admin handler with a
// `ValidationContext`. A blob this build cannot read must not
// be persisted for every later reader to trip over.
if !data.is_empty() {
super::on_demand_migration::OnDemandMigrationConfig::from_json(&data).map_err(Error::other)?;
}
self.on_demand_migration_config_json = data;
self.on_demand_migration_config_updated_at = updated;
}
_ => return Err(Error::other(format!("config file not found : {config_file}"))),
}
@@ -1779,6 +1822,117 @@ mod test {
assert!(!bm.table_bucket_enabled());
}
const ODM_JSON: &[u8] = br#"{"version":1,"enabled":true,"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
/// rustfs/backlog#2148: the on-demand migration config is a RustFS
/// extension entry that round-trips through `update_config` and the
/// msgpack codec, clears on delete, and never parses corruption into a
/// default.
#[test]
fn on_demand_migration_config_round_trips_and_tracks_updates() {
use crate::bucket::on_demand_migration::{OnDemandMigrationConfig, OnDemandMigrationConfigError};
let mut bm = BucketMetadata::new("odm-bucket");
assert_eq!(bm.on_demand_migration_config(), Ok(None), "fresh metadata carries no config");
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.expect("valid config is accepted");
assert_ne!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
assert_eq!(bm.on_demand_migration_config(), Ok(Some(expected.clone())));
let back = BucketMetadata::unmarshal(&bm.marshal_msg().unwrap()).unwrap();
assert_eq!(back.on_demand_migration_config_json, bm.on_demand_migration_config_json);
assert_eq!(
back.on_demand_migration_config_updated_at.unix_timestamp(),
bm.on_demand_migration_config_updated_at.unix_timestamp()
);
assert_eq!(back.on_demand_migration_config(), Ok(Some(expected)));
// A blob this build cannot read is rejected at the write boundary
// rather than persisted for every reader to trip over.
let before = bm.on_demand_migration_config_json.clone();
assert!(
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec())
.is_err()
);
assert_eq!(bm.on_demand_migration_config_json, before, "a rejected update leaves the blob untouched");
// Delete clears the entry.
let stamped = bm.on_demand_migration_config_updated_at;
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, Vec::new()).unwrap();
assert!(bm.on_demand_migration_config_json.is_empty());
assert_eq!(bm.on_demand_migration_config(), Ok(None));
assert!(bm.on_demand_migration_config_updated_at >= stamped);
// Corruption that bypassed `update_config` (disk, another writer)
// is a typed error, never a default.
bm.on_demand_migration_config_json = b"not-json".to_vec();
assert!(matches!(bm.on_demand_migration_config(), Err(OnDemandMigrationConfigError::Malformed(_))));
}
/// rustfs/backlog#2148: a `.metadata.bin` written before the on-demand
/// migration keys existed decodes with an empty blob and an epoch
/// timestamp that `default_timestamps` back-fills from `created`.
#[test]
fn on_demand_migration_config_absent_in_legacy_blob_defaults_to_created() {
let blob = decode_hex(include_str!("../../tests/fixtures/minio/bucket_metadata.blob.hex"));
let mut bm = BucketMetadata::unmarshal(&blob[4..]).expect("unmarshal MinIO bucket metadata");
assert!(bm.on_demand_migration_config_json.is_empty());
assert_eq!(bm.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
assert_eq!(bm.on_demand_migration_config(), Ok(None));
bm.default_timestamps();
assert_ne!(bm.created, OffsetDateTime::UNIX_EPOCH, "fixture must carry a real creation time");
assert_eq!(bm.on_demand_migration_config_updated_at, bm.created);
// A metadata blob from this build with no config set stays
// indistinguishable from the legacy one for these fields.
let fresh = BucketMetadata::unmarshal(&BucketMetadata::new("fresh").marshal_msg().unwrap()).unwrap();
assert!(fresh.on_demand_migration_config_json.is_empty());
assert_eq!(fresh.on_demand_migration_config_updated_at, OffsetDateTime::UNIX_EPOCH);
}
/// rustfs/backlog#2148: a reader that predates the two on-demand
/// migration keys takes `decode_from`'s unknown-field branch, which is
/// `skip_msgp_value`. Walk the new-format blob with exactly that
/// primitive and prove both keys are skipped without desynchronising the
/// stream, so the fields that follow them still decode.
#[test]
fn old_decoder_skips_on_demand_migration_fields_without_desync() {
let mut bm = BucketMetadata::new("odm-skip");
bm.update_config(BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.unwrap();
bm.update_config(BUCKET_DURABILITY_CONFIG, br#"{"mode":"relaxed"}"#.to_vec())
.unwrap();
let buf = bm.marshal_msg().unwrap();
let mut rd = std::io::Cursor::new(buf.as_slice());
let fields = rmp::decode::read_map_len(&mut rd).unwrap();
let mut skipped = Vec::new();
let mut durability_json = Vec::new();
for _ in 0..fields {
let key_len = rmp::decode::read_str_len(&mut rd).unwrap();
let mut key = vec![0u8; key_len as usize];
rd.read_exact(&mut key).unwrap();
let key = String::from_utf8(key).unwrap();
match key.as_str() {
// The field an old reader knows that is encoded *after* the
// unknown JSON key and *before* the unknown timestamp key.
"DurabilityConfigJSON" => durability_json = read_msgp_bin(&mut rd).unwrap(),
other => {
if other.starts_with("OnDemandMigration") {
skipped.push(other.to_string());
}
skip_msgp_value(&mut rd).unwrap();
}
}
}
assert_eq!(skipped, ["OnDemandMigrationConfigJSON", "OnDemandMigrationConfigUpdatedAt"]);
assert_eq!(durability_json, br#"{"mode":"relaxed"}"#);
assert_eq!(rd.position() as usize, buf.len(), "old-style walk must consume the blob exactly");
}
/// HP-5b (rustfs/backlog#938): the durability override is a RustFS
/// extension entry and must survive an encode/decode round trip.
#[test]
+256 -46
View File
@@ -19,9 +19,10 @@ use super::quota::BucketQuota;
use super::target::BucketTargets;
use crate::bucket::bucket_target_sys::BucketTargetSys;
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
use crate::bucket::on_demand_migration::{ON_DEMAND_MIGRATION_CONFIG_HOOK, OnDemandMigrationConfig};
use crate::bucket::utils::is_meta_bucketname;
use crate::disk::RUSTFS_META_BUCKET;
use crate::error::{Error, Result, is_err_bucket_not_found};
use crate::error::{Error, Result, is_err_bucket_not_found, is_err_strict_volume_not_found};
use crate::runtime::sources as runtime_sources;
use crate::storage_api_contracts::heal::HealOperations as _;
use crate::storage_api_contracts::namespace::NamespaceLocking as _;
@@ -384,6 +385,42 @@ fn clear_bucket_durability(bucket: &str) {
crate::disk::local::bucket_durability::set(bucket, None);
}
/// Publish the bucket's on-demand migration config (or its absence) to the
/// runtime registered in `ON_DEMAND_MIGRATION_CONFIG_HOOK`.
///
/// Called from the same five cache-install paths as
/// [`sync_bucket_durability`]. A stored payload this build cannot parse is
/// published as `None`: the runtime must stop pulling for that bucket rather
/// than keep an older config or guess.
fn sync_on_demand_migration(bucket: &str, bm: &BucketMetadata) {
let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() else {
return;
};
match bm.on_demand_migration_config() {
Ok(config) => hook(bucket, config.as_ref()),
Err(err) => {
warn!(
event = "bucket_metadata_parse_failed",
component = "ecstore",
subsystem = "bucket_metadata",
bucket = %bucket,
config = "on_demand_migration",
error = %err,
"Failed to parse bucket metadata config"
);
hook(bucket, None);
}
}
}
/// Withdraw a bucket's on-demand migration config when its metadata leaves
/// the cache.
fn clear_on_demand_migration(bucket: &str) {
if let Some(hook) = ON_DEMAND_MIGRATION_CONFIG_HOOK.get() {
hook(bucket, None);
}
}
pub async fn get(bucket: &str) -> Result<Arc<BucketMetadata>> {
let sys = get_bucket_metadata_sys()?;
let lock = sys.read().await;
@@ -659,13 +696,12 @@ async fn acquire_config_write_guard_for_incarnation(
async {
match metadata_sys
.api
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
{
Ok(_) => Ok(()),
Err(crate::disk::error::Error::VolumeNotFound) => Err(Error::BucketNotFound(bucket.to_string())),
Err(err) => Err(err.into()),
Err(err) if is_err_strict_volume_not_found(&err) => Err(Error::BucketNotFound(bucket.to_string())),
Err(err) => Err(err),
}
},
),
@@ -971,6 +1007,16 @@ pub async fn get_durability_config(
Ok((bm.durability_config(), bm.durability_config_updated_at))
}
/// The bucket's on-demand migration config with its update time, or
/// `Ok(None)` when the bucket has none. A stored payload that does not parse
/// is a typed error (`OnDemandMigrationConfigError` inside `Error::Io`).
pub async fn get_on_demand_migration_config(bucket: &str) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
bucket_meta_sys.get_on_demand_migration_config(bucket).await
}
pub async fn get_quota_config(bucket: &str) -> Result<(BucketQuota, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -1115,6 +1161,16 @@ pub async fn get_replication_config(bucket: &str) -> Result<(ReplicationConfigur
bucket_meta_sys.get_replication_config(bucket).await
}
pub(crate) async fn get_replication_config_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<(ReplicationConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
bucket_meta_sys.get_replication_config(bucket).await
}
pub async fn get_notification_config(bucket: &str) -> Result<Option<NotificationConfiguration>> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -1274,7 +1330,6 @@ pub struct BucketMetadataSys {
/// name floods while avoiding repeated namespace and erasure reads.
missing_buckets: moka::future::Cache<String, ()>,
api: Arc<ECStore>,
initialized: Arc<RwLock<bool>>,
}
impl BucketMetadataSys {
@@ -1303,7 +1358,6 @@ impl BucketMetadataSys {
.time_to_live(MISSING_BUCKET_TTL)
.build(),
api,
initialized: Arc::new(RwLock::new(false)),
}
}
@@ -1364,13 +1418,12 @@ impl BucketMetadataSys {
await_bucket_namespace_operation(Some(namespace_guard), bucket, operation, async {
match self
.api
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
{
Ok(_) => Ok(true),
Err(crate::disk::error::Error::VolumeNotFound) => Ok(false),
Err(err) => Err(err.into()),
Err(Error::VolumeNotFound) => Ok(false),
Err(err) => Err(err),
}
})
.await
@@ -1380,9 +1433,15 @@ impl BucketMetadataSys {
let _ = self.init_internal(buckets).await;
}
async fn init_internal(&self, buckets: Vec<String>) -> Result<()> {
let count = runtime_sources::endpoint_erasure_set_count()
.map(|count| count * 10)
.ok_or_else(|| Error::other("endpoint pools not initialized"))?;
let count = self
.api
.pools
.iter()
.map(|pool| pool.disk_set.len())
.sum::<usize>()
.checked_mul(10)
.filter(|count| *count != 0)
.ok_or_else(|| Error::other("bucket metadata store has no erasure sets"))?;
let mut failed_buckets: HashSet<String> = HashSet::new();
let mut buckets = buckets.as_slice();
@@ -1400,9 +1459,6 @@ impl BucketMetadataSys {
buckets = &buckets[count..]
}
let mut initialized = self.initialized.write().await;
*initialized = true;
Ok(())
}
@@ -1479,14 +1535,6 @@ impl BucketMetadataSys {
expected: Option<&Arc<BucketMetadata>>,
namespace_guard: &rustfs_lock::NamespaceLockGuard,
) -> Result<()> {
await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
"bucket metadata heal",
self.api.heal_bucket(bucket, &HealOpts::default()),
)
.await?;
if !self
.bucket_exists(bucket, namespace_guard, "bucket metadata existence check")
.await?
@@ -1501,11 +1549,26 @@ impl BucketMetadataSys {
if removed {
BucketTargetSys::get().delete(bucket).await;
clear_bucket_durability(bucket);
clear_on_demand_migration(bucket);
}
}
return Ok(());
}
await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
"bucket metadata heal",
self.api.heal_bucket(
bucket,
&HealOpts {
recreate: true,
..Default::default()
},
),
)
.await?;
let (bm, persisted) = await_bucket_namespace_operation(
Some(namespace_guard),
bucket,
@@ -1524,6 +1587,7 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &bm).await;
sync_bucket_durability(bucket, &bm);
sync_on_demand_migration(bucket, &bm);
}
MetadataLoadMode::Initial => {
let _publish_guard = self
@@ -1570,6 +1634,7 @@ impl BucketMetadataSys {
if removed {
BucketTargetSys::get().delete(bucket).await;
clear_bucket_durability(bucket);
clear_on_demand_migration(bucket);
}
return Ok(());
}
@@ -1592,6 +1657,7 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &metadata).await;
sync_bucket_durability(bucket, &metadata);
sync_on_demand_migration(bucket, &metadata);
Ok(())
}
@@ -1619,6 +1685,7 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(&bucket).await;
sync_bucket_target_sys(&bucket, &bm).await;
sync_bucket_durability(&bucket, &bm);
sync_on_demand_migration(&bucket, &bm);
}
}
@@ -1639,6 +1706,7 @@ impl BucketMetadataSys {
if removed {
BucketTargetSys::get().delete(bucket).await;
clear_bucket_durability(bucket);
clear_on_demand_migration(bucket);
}
removed || removed_fabricated
}
@@ -1887,23 +1955,13 @@ impl BucketMetadataSys {
"lazy metadata IO must start while the bucket namespace read lock is held"
);
}
let (bm, persisted) = match await_bucket_namespace_operation(
let (bm, persisted) = await_bucket_namespace_operation(
Some(&guard),
bucket,
"lazy bucket metadata load",
Box::pin(load_bucket_metadata_parse_with_presence(self.api.clone(), bucket, true)),
)
.await
{
Ok(res) => res,
Err(err) => {
return if *self.initialized.read().await {
Err(Error::other("errBucketMetadataNotInitialized"))
} else {
Err(err)
};
}
};
.await?;
let bm = Arc::new(bm);
@@ -1914,11 +1972,9 @@ impl BucketMetadataSys {
"lazy bucket metadata existence check",
Box::pin(async {
self.api
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.map(|_| ())
.map_err(Into::into)
}),
)
.await?;
@@ -1940,6 +1996,7 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &bm).await;
sync_bucket_durability(bucket, &bm);
sync_on_demand_migration(bucket, &bm);
} else {
let exists = self
.bucket_exists(bucket, &guard, "lazy bucket metadata existence check")
@@ -2197,10 +2254,8 @@ impl BucketMetadataSys {
"legacy bucket metadata existence check",
async {
self.api
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.map_err(crate::error::StorageError::from)
},
)
.await
@@ -2280,6 +2335,7 @@ impl BucketMetadataSys {
self.missing_buckets.invalidate(bucket).await;
sync_bucket_target_sys(bucket, &metadata).await;
sync_bucket_durability(bucket, &metadata);
sync_on_demand_migration(bucket, &metadata);
Ok(BucketMetadataAuthority::Authoritative(metadata))
}
@@ -2302,10 +2358,8 @@ impl BucketMetadataSys {
"bucket metadata snapshot existence check",
async {
self.api
.peer_sys
.get_bucket_info(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.get_bucket_info_from_sets(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
.await
.map_err(crate::error::StorageError::from)
},
)
.await
@@ -2474,6 +2528,17 @@ impl BucketMetadataSys {
Err(Error::ConfigNotFound)
}
}
/// See [`get_on_demand_migration_config`].
pub async fn get_on_demand_migration_config(
&self,
bucket: &str,
) -> Result<Option<(OnDemandMigrationConfig, OffsetDateTime)>> {
let (bm, _) = self.get_config(bucket).await?;
let config = bm.on_demand_migration_config().map_err(Error::other)?;
Ok(config.map(|config| (config, bm.on_demand_migration_config_updated_at)))
}
}
/// Test-only fixture shared with sibling modules (e.g. the quota checker
@@ -4054,6 +4119,151 @@ mod tests {
assert_eq!(bucket_durability::lookup(bucket), None);
}
const ODM_JSON: &[u8] = br#"{"source":{"provider":"minio","endpoint":"https://legacy.example.com:9000","region":"auto","bucket":"legacy-bucket","credentials":{"access_key":"AK","secret_key":"SK"}}}"#;
/// Every `(bucket, config)` the recording hook has seen. Tests filter by
/// their own bucket name; the hook is process-wide and set once.
static ODM_HOOK_CALLS: std::sync::Mutex<Vec<(String, Option<OnDemandMigrationConfig>)>> = std::sync::Mutex::new(Vec::new());
fn install_recording_odm_hook() {
ON_DEMAND_MIGRATION_CONFIG_HOOK.get_or_init(|| {
Box::new(|bucket, config| {
ODM_HOOK_CALLS.lock().unwrap().push((bucket.to_string(), config.cloned()));
})
});
}
fn odm_hook_calls(bucket: &str) -> Vec<Option<OnDemandMigrationConfig>> {
ODM_HOOK_CALLS
.lock()
.unwrap()
.iter()
.filter(|(name, _)| name == bucket)
.map(|(_, config)| config.clone())
.collect()
}
/// rustfs/backlog#2148: the accessor reports absence as `Ok(None)` and a
/// stored payload it cannot parse as a typed error, never as a default
/// and never as `ConfigNotFound`.
#[tokio::test]
async fn get_on_demand_migration_config_distinguishes_absent_from_corrupt() {
use crate::bucket::on_demand_migration::OnDemandMigrationConfigError;
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
let sys = BucketMetadataSys::new(ecstore);
let bucket = "odm-accessor";
sys.set(bucket.to_string(), Arc::new(BucketMetadata::new(bucket))).await;
assert_eq!(sys.get_on_demand_migration_config(bucket).await.unwrap(), None);
let mut corrupt = BucketMetadata::new(bucket);
corrupt.on_demand_migration_config_json = br#"{"source":{"provider":"s3"},"bogus":1}"#.to_vec();
sys.set(bucket.to_string(), Arc::new(corrupt)).await;
let err = sys
.get_on_demand_migration_config(bucket)
.await
.expect_err("corrupt config must not read as a default");
assert_ne!(err, Error::ConfigNotFound, "corruption must not be reported as absence");
let typed = match &err {
Error::Io(io) => io
.get_ref()
.and_then(|source| source.downcast_ref::<OnDemandMigrationConfigError>()),
_ => None,
};
assert!(
matches!(typed, Some(OnDemandMigrationConfigError::Malformed(_))),
"typed parse error must survive the Result boundary, got: {err:?}"
);
let mut valid = BucketMetadata::new(bucket);
valid
.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.unwrap();
let stamped = valid.on_demand_migration_config_updated_at;
sys.set(bucket.to_string(), Arc::new(valid)).await;
let (config, updated_at) = sys
.get_on_demand_migration_config(bucket)
.await
.unwrap()
.expect("stored config is returned");
assert_eq!(config, OnDemandMigrationConfig::from_json(ODM_JSON).unwrap());
assert_eq!(updated_at, stamped);
}
/// rustfs/backlog#2148: the publish hook fires on every path that
/// installs bucket metadata into the cache (set, initial load, peer
/// reload, refresh loop, lazy load) and withdraws on removal, mirroring
/// `sync_bucket_durability`.
#[tokio::test]
async fn on_demand_migration_hook_fires_on_every_cache_install_path() {
install_recording_odm_hook();
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
let bucket = "odm-hook-paths";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).expect("physical bucket should exist");
}
let expected = OnDemandMigrationConfig::from_json(ODM_JSON).unwrap();
let expect_publish = |before: usize, label: &str| {
let calls = odm_hook_calls(bucket);
assert_eq!(calls.len(), before + 1, "{label} must publish exactly once");
assert_eq!(calls.last().unwrap().as_ref(), Some(&expected), "{label} must publish the stored config");
};
// set (via persist_new_and_set, which installs through `set`).
let mut bm = BucketMetadata::new(bucket);
bm.update_config(crate::bucket::metadata::BUCKET_ON_DEMAND_MIGRATION_CONFIG, ODM_JSON.to_vec())
.unwrap();
let writer = BucketMetadataSys::new(ecstore.clone());
let before = odm_hook_calls(bucket).len();
writer.persist_new_and_set(bm).await.expect("metadata should persist");
expect_publish(before, "set");
// init (initial load on a cold system).
let mut cold = BucketMetadataSys::new(ecstore.clone());
let before = odm_hook_calls(bucket).len();
cold.init(vec![bucket.to_string()]).await;
assert!(cold.get(bucket).await.is_ok(), "initial load must cache the bucket");
expect_publish(before, "init");
// peer reload.
let before = odm_hook_calls(bucket).len();
cold.reload_from_store(bucket).await.expect("peer reload should publish");
expect_publish(before, "peer reload");
// refresh loop.
let before = odm_hook_calls(bucket).len();
let mut failed = HashSet::new();
cold.concurrent_load(&[bucket.to_string()], &mut failed, MetadataLoadMode::Refresh)
.await;
assert!(failed.is_empty(), "refresh must succeed");
expect_publish(before, "refresh loop");
// lazy load on another cold system.
let lazy = BucketMetadataSys::new(ecstore);
let before = odm_hook_calls(bucket).len();
let (_, loaded) = lazy.get_config(bucket).await.expect("lazy load should publish");
assert!(loaded, "the lazy path must have gone to disk");
expect_publish(before, "lazy load");
// Removal withdraws the config.
let before = odm_hook_calls(bucket).len();
assert!(lazy.remove(bucket).await);
let calls = odm_hook_calls(bucket);
assert_eq!(calls.len(), before + 1, "remove must withdraw exactly once");
assert_eq!(calls.last().unwrap(), &None);
// A corrupt payload is withdrawn, never published as a config.
let mut corrupt = BucketMetadata::new(bucket);
corrupt.on_demand_migration_config_json = b"not-json".to_vec();
let before = odm_hook_calls(bucket).len();
lazy.set(bucket.to_string(), Arc::new(corrupt)).await;
let calls = odm_hook_calls(bucket);
assert_eq!(calls.len(), before + 1);
assert_eq!(calls.last().unwrap(), &None, "unreadable config must publish absence");
}
#[tokio::test]
async fn refresh_wait_exits_when_cancelled() {
let cancel_token = CancellationToken::new();
+3
View File
@@ -26,9 +26,12 @@ mod metadata_test;
pub mod migration;
mod msgp_decode;
pub mod object_lock;
pub mod on_demand_migration;
pub mod policy_sys;
pub mod quota;
pub mod remote_s3_client;
pub mod replication;
pub mod sealed_credentials;
pub mod tagging;
pub mod target;
pub mod utils;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,362 @@
// 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.
//! Per-bucket three-state circuit breaker protecting an on-demand migration
//! source (rustfs/backlog#2152).
//!
//! `Closed` lets every request through and counts consecutive failures
//! inside a sliding window; reaching the threshold opens the breaker. `Open`
//! rejects everything until the open duration elapses, then moves to
//! `HalfOpen`, which admits a single probe: success closes the breaker,
//! failure re-opens it. Timing uses `tokio::time::Instant` so tests can drive
//! it with `tokio::time::pause`.
//!
//! Only transport-level failures count (`Throttled`, `Timeout`, `Connect`,
//! `ServerError`). `NotFound` is a healthy answer and resets the failure
//! streak; `AccessDenied`, `Unsupported` and `Other` are configuration or
//! object problems that neither open nor close the breaker.
use super::source_client::SourceError;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::time::Instant;
/// Consecutive counted failures that open the breaker.
pub const BREAKER_FAILURE_THRESHOLD: u32 = 5;
/// Failures further apart than this do not accumulate.
pub const BREAKER_FAILURE_WINDOW: Duration = Duration::from_secs(30);
/// How long an open breaker rejects before admitting a probe.
pub const BREAKER_OPEN_DURATION: Duration = Duration::from_secs(30);
/// Probes admitted while half-open.
pub const BREAKER_HALF_OPEN_MAX_PROBES: u32 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BreakerState {
Closed,
Open,
HalfOpen,
}
impl BreakerState {
pub fn as_str(self) -> &'static str {
match self {
BreakerState::Closed => "closed",
BreakerState::Open => "open",
BreakerState::HalfOpen => "half_open",
}
}
}
/// A state change the caller may want to log.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct BreakerTransition {
pub from: BreakerState,
pub to: BreakerState,
}
/// How a source result is scored by the breaker.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BreakerVerdict {
/// Resets the failure streak; closes a half-open breaker.
Success,
/// Counts toward the threshold; re-opens a half-open breaker.
Failure,
/// Leaves the breaker untouched.
Neutral,
}
impl BreakerVerdict {
/// `None` is a successful source call.
pub fn for_result(error: Option<&SourceError>) -> Self {
match error {
None | Some(SourceError::NotFound) => BreakerVerdict::Success,
Some(SourceError::Throttled | SourceError::Timeout | SourceError::Connect(_) | SourceError::ServerError(_)) => {
BreakerVerdict::Failure
}
Some(SourceError::AccessDenied | SourceError::Unsupported(_) | SourceError::Other(_)) => BreakerVerdict::Neutral,
}
}
}
#[derive(Debug)]
struct Inner {
state: BreakerState,
consecutive_failures: u32,
last_failure_at: Option<Instant>,
opened_at: Option<Instant>,
half_open_probes: u32,
}
#[derive(Debug)]
pub struct Breaker {
inner: Mutex<Inner>,
}
impl Default for Breaker {
fn default() -> Self {
Self::new()
}
}
impl Breaker {
pub fn new() -> Self {
Self {
inner: Mutex::new(Inner {
state: BreakerState::Closed,
consecutive_failures: 0,
last_failure_at: None,
opened_at: None,
half_open_probes: 0,
}),
}
}
/// Current state after applying the open-duration timeout.
pub fn state(&self) -> BreakerState {
let mut inner = self.inner.lock();
Self::advance(&mut inner, Instant::now());
inner.state
}
/// Whether a request may reach the source right now. Consumes the
/// half-open probe budget when it grants one.
pub fn allow_request(&self) -> bool {
let mut inner = self.inner.lock();
Self::advance(&mut inner, Instant::now());
match inner.state {
BreakerState::Closed => true,
BreakerState::Open => false,
BreakerState::HalfOpen => {
if inner.half_open_probes < BREAKER_HALF_OPEN_MAX_PROBES {
inner.half_open_probes += 1;
true
} else {
false
}
}
}
}
/// Scores a source result; returns the transition it caused, if any.
pub fn record(&self, verdict: BreakerVerdict) -> Option<BreakerTransition> {
match verdict {
BreakerVerdict::Success => self.record_success(),
BreakerVerdict::Failure => self.record_failure(),
BreakerVerdict::Neutral => None,
}
}
pub fn record_success(&self) -> Option<BreakerTransition> {
let mut inner = self.inner.lock();
let now = Instant::now();
Self::advance(&mut inner, now);
inner.consecutive_failures = 0;
inner.last_failure_at = None;
match inner.state {
BreakerState::Closed => None,
// A success while open can only come from a request admitted
// before the breaker opened; it says nothing about recovery.
BreakerState::Open => None,
BreakerState::HalfOpen => Some(Self::transition(&mut inner, BreakerState::Closed, now)),
}
}
pub fn record_failure(&self) -> Option<BreakerTransition> {
let mut inner = self.inner.lock();
let now = Instant::now();
Self::advance(&mut inner, now);
match inner.state {
BreakerState::Closed => {
let within_window = inner
.last_failure_at
.is_some_and(|last| now.saturating_duration_since(last) <= BREAKER_FAILURE_WINDOW);
inner.consecutive_failures = if within_window { inner.consecutive_failures + 1 } else { 1 };
inner.last_failure_at = Some(now);
if inner.consecutive_failures >= BREAKER_FAILURE_THRESHOLD {
Some(Self::transition(&mut inner, BreakerState::Open, now))
} else {
None
}
}
BreakerState::Open => None,
BreakerState::HalfOpen => Some(Self::transition(&mut inner, BreakerState::Open, now)),
}
}
fn advance(inner: &mut Inner, now: Instant) {
if inner.state == BreakerState::Open
&& inner
.opened_at
.is_some_and(|opened| now.saturating_duration_since(opened) >= BREAKER_OPEN_DURATION)
{
Self::transition(inner, BreakerState::HalfOpen, now);
}
}
fn transition(inner: &mut Inner, to: BreakerState, now: Instant) -> BreakerTransition {
let from = inner.state;
inner.state = to;
match to {
BreakerState::Open => {
inner.opened_at = Some(now);
inner.half_open_probes = 0;
}
BreakerState::HalfOpen => {
inner.half_open_probes = 0;
}
BreakerState::Closed => {
inner.opened_at = None;
inner.half_open_probes = 0;
inner.consecutive_failures = 0;
inner.last_failure_at = None;
}
}
BreakerTransition { from, to }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn server_error() -> SourceError {
SourceError::ServerError(503)
}
#[tokio::test(start_paused = true)]
async fn five_failures_open_then_half_open_after_timeout() {
let breaker = Breaker::new();
for i in 0..BREAKER_FAILURE_THRESHOLD - 1 {
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&server_error()))), None, "failure {i}");
assert_eq!(breaker.state(), BreakerState::Closed);
}
assert_eq!(
breaker.record(BreakerVerdict::for_result(Some(&server_error()))),
Some(BreakerTransition {
from: BreakerState::Closed,
to: BreakerState::Open
})
);
assert_eq!(breaker.state(), BreakerState::Open);
assert!(!breaker.allow_request());
tokio::time::advance(BREAKER_OPEN_DURATION - Duration::from_secs(1)).await;
assert!(!breaker.allow_request());
assert_eq!(breaker.state(), BreakerState::Open);
tokio::time::advance(Duration::from_secs(1)).await;
assert_eq!(breaker.state(), BreakerState::HalfOpen);
assert!(breaker.allow_request(), "one probe is admitted");
assert!(!breaker.allow_request(), "second probe is rejected");
}
#[tokio::test(start_paused = true)]
async fn half_open_probe_success_closes_and_failure_reopens() {
let breaker = Breaker::new();
for _ in 0..BREAKER_FAILURE_THRESHOLD {
breaker.record_failure();
}
tokio::time::advance(BREAKER_OPEN_DURATION).await;
assert!(breaker.allow_request());
assert_eq!(
breaker.record_failure(),
Some(BreakerTransition {
from: BreakerState::HalfOpen,
to: BreakerState::Open
})
);
assert!(!breaker.allow_request());
tokio::time::advance(BREAKER_OPEN_DURATION).await;
assert!(breaker.allow_request());
assert_eq!(
breaker.record_success(),
Some(BreakerTransition {
from: BreakerState::HalfOpen,
to: BreakerState::Closed
})
);
assert_eq!(breaker.state(), BreakerState::Closed);
assert!(breaker.allow_request());
// The streak restarts from zero after closing.
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
assert_eq!(breaker.record_failure(), None);
}
assert_eq!(breaker.state(), BreakerState::Closed);
}
#[tokio::test(start_paused = true)]
async fn failures_outside_window_do_not_accumulate() {
let breaker = Breaker::new();
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
breaker.record_failure();
}
tokio::time::advance(BREAKER_FAILURE_WINDOW + Duration::from_secs(1)).await;
assert_eq!(breaker.record_failure(), None, "stale streak restarts at one");
assert_eq!(breaker.state(), BreakerState::Closed);
}
#[test]
fn not_found_and_access_denied_do_not_count() {
let breaker = Breaker::new();
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
breaker.record(BreakerVerdict::for_result(Some(&server_error())));
}
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::AccessDenied))), None);
assert_eq!(breaker.state(), BreakerState::Closed);
// AccessDenied is neutral: the streak is still one short of opening.
assert_eq!(
breaker.record(BreakerVerdict::for_result(Some(&SourceError::Unsupported("sse-c".into())))),
None
);
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::Other("x".into())))), None);
// NotFound is a healthy answer and resets the streak entirely.
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::NotFound))), None);
for _ in 0..BREAKER_FAILURE_THRESHOLD - 1 {
assert_eq!(breaker.record(BreakerVerdict::for_result(Some(&SourceError::Timeout))), None);
}
assert_eq!(breaker.state(), BreakerState::Closed);
}
#[test]
fn verdicts_cover_every_source_error_class() {
assert_eq!(BreakerVerdict::for_result(None), BreakerVerdict::Success);
assert_eq!(BreakerVerdict::for_result(Some(&SourceError::NotFound)), BreakerVerdict::Success);
for failure in [
SourceError::Throttled,
SourceError::Timeout,
SourceError::Connect("refused".into()),
SourceError::ServerError(500),
] {
assert_eq!(BreakerVerdict::for_result(Some(&failure)), BreakerVerdict::Failure, "{failure:?}");
}
for neutral in [
SourceError::AccessDenied,
SourceError::Unsupported("sse-c".into()),
SourceError::Other("x".into()),
] {
assert_eq!(BreakerVerdict::for_result(Some(&neutral)), BreakerVerdict::Neutral, "{neutral:?}");
}
}
#[test]
fn state_labels_are_stable() {
assert_eq!(BreakerState::Closed.as_str(), "closed");
assert_eq!(BreakerState::Open.as_str(), "open");
assert_eq!(BreakerState::HalfOpen.as_str(), "half_open");
assert_eq!(serde_json::to_string(&BreakerState::HalfOpen).unwrap(), "\"half_open\"");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,824 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Optional `ListObjectsV2` list-through (`policy.list_through`,
//! rustfs/backlog#2164): the local listing and the source listing are merged
//! into one ordered page so clients see the whole namespace while a bucket is
//! migrating.
//!
//! Everything here is pure. The handler owns the I/O and the payloads; this
//! module owns the ordering, the page boundary, and the opaque continuation
//! token that carries both cursors.
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
/// The only continuation-token envelope version this build reads and writes.
pub const LIST_THROUGH_TOKEN_VERSION: u32 = 1;
/// Envelope marker. A bucket that is *not* merging hands out the local
/// listing's own marker, so the decoder needs a positive signal before it
/// treats an opaque token as a merged one.
const LIST_THROUGH_TOKEN_TAG: &str = "odm-list";
/// Pages fetched per side per request: the first page, plus at most one refill
/// when the first one was mostly consumed by the previous page. Two pages of
/// `max_keys` always cover a full merged page, so this is a bound, not a
/// heuristic.
pub const MAX_LIST_FETCHES_PER_SIDE: usize = 2;
/// Per-bucket ceiling on source `ListObjectsV2` calls, in calls per second.
pub const SOURCE_LIST_RATE_PER_SEC: u32 = 10;
/// How long a listing may wait for a source rate-limit slot before it gives up
/// and answers from local state alone.
pub const SOURCE_LIST_MAX_RATE_WAIT: Duration = Duration::from_secs(1);
/// One listing entry as the merge orders it: an object key, or — under a
/// delimiter — a rolled-up common prefix. Both sort by `name` alone, which is
/// how S3 interleaves `Contents` and `CommonPrefixes` on the wire.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ListEntryKey {
pub name: String,
pub is_prefix: bool,
}
impl ListEntryKey {
pub fn object(name: impl Into<String>) -> Self {
Self {
name: name.into(),
is_prefix: false,
}
}
pub fn prefix(name: impl Into<String>) -> Self {
Self {
name: name.into(),
is_prefix: true,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MergeSide {
Local,
Source,
}
/// One entry of the merged page: the side it came from and its index in that
/// side's buffer, in push order. The caller keeps the payloads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MergePick {
pub side: MergeSide,
pub index: usize,
}
/// The continuation-token envelope. Opaque to clients: it is serialized as
/// JSON and then base64-encoded by the same helper that encodes a plain local
/// marker, so the wire shape is `base64(json)`.
///
/// A `null` cursor with `done = false` means "list that side from the start";
/// `done = true` means the side is finished and must not be listed again.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListThroughToken {
/// Envelope marker, always [`LIST_THROUGH_TOKEN_TAG`].
pub t: String,
pub v: u32,
#[serde(default)]
pub local: Option<String>,
#[serde(default)]
pub local_done: bool,
#[serde(default)]
pub source: Option<String>,
#[serde(default)]
pub source_done: bool,
/// Last entry the previous page consumed. A side whose page was only
/// partially consumed is re-listed from the same cursor and everything at
/// or below this key is dropped, which is delimiter-safe: a rolled-up
/// common prefix compares as itself, never as its members.
#[serde(default)]
pub last_key: Option<String>,
}
impl ListThroughToken {
fn new(local: SideCursor, source: SideCursor, last_key: Option<String>) -> Self {
Self {
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: local.token,
local_done: local.done,
source: source.token,
source_done: source.done,
last_key,
}
}
pub fn encode(&self) -> String {
// The envelope is built here from owned strings, so serialization
// cannot fail; the fallback keeps the signature infallible.
serde_json::to_string(self).unwrap_or_default()
}
}
/// What a decoded (base64-stripped) continuation token turned out to be.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ListThroughCursor {
/// A plain local listing marker: the bucket was not merging when the token
/// was issued, or the client is paginating a non-merged listing.
Local(String),
Merged(Box<ListThroughToken>),
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum ListThroughTokenError {
#[error("continuation token version {0} is not supported")]
UnsupportedVersion(u32),
/// The message never echoes the token: it is client-controlled input.
#[error("continuation token is malformed")]
Malformed,
}
/// Classifies an already base64-decoded continuation token.
///
/// Only a JSON object carrying the envelope marker is read as a merged token;
/// anything else is a local marker, so a bucket that turns `list_through` off
/// keeps paginating with the tokens it handed out. A token that *is* an
/// envelope but was tampered with (unknown version, unknown field, truncated
/// JSON) is an error, never a silent fallback.
pub fn decode_continuation_token(decoded: &str) -> Result<ListThroughCursor, ListThroughTokenError> {
if !decoded.starts_with('{') {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(decoded) else {
// Not JSON at all: an object key may legitimately start with '{'.
return Ok(ListThroughCursor::Local(decoded.to_string()));
};
if value.get("t").and_then(serde_json::Value::as_str) != Some(LIST_THROUGH_TOKEN_TAG) {
return Ok(ListThroughCursor::Local(decoded.to_string()));
}
match value.get("v").and_then(serde_json::Value::as_u64) {
Some(version) if version == u64::from(LIST_THROUGH_TOKEN_VERSION) => {}
Some(version) => return Err(ListThroughTokenError::UnsupportedVersion(version.min(u64::from(u32::MAX)) as u32)),
None => return Err(ListThroughTokenError::Malformed),
}
serde_json::from_value::<ListThroughToken>(value)
.map(|token| ListThroughCursor::Merged(Box::new(token)))
.map_err(|_| ListThroughTokenError::Malformed)
}
/// How the source must be listed for a request, given `filter.prefix`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SourceListPlan {
/// The request prefix and `filter.prefix` are disjoint: the source holds
/// nothing this listing could show.
Skip,
/// Ordinary paged listing under `prefix`, rolled up with the request's
/// delimiter — the source's own roll-up boundary matches the request's.
Page { prefix: String },
/// `filter.prefix` reaches past a delimiter, so every key the source could
/// contribute rolls into this one common prefix. One bounded probe listing
/// decides whether it exists; there is nothing to paginate.
Folded { probe_prefix: String, common_prefix: String },
}
/// Intersects the request prefix with `filter.prefix` and decides how (or
/// whether) the source is listed.
pub fn source_list_plan(request_prefix: &str, filter_prefix: Option<&str>, delimiter: Option<&str>) -> SourceListPlan {
let filter = filter_prefix.unwrap_or_default();
let source_prefix = if filter.starts_with(request_prefix) {
filter
} else if request_prefix.starts_with(filter) {
request_prefix
} else {
return SourceListPlan::Skip;
};
let Some(delimiter) = delimiter.filter(|delimiter| !delimiter.is_empty()) else {
return SourceListPlan::Page {
prefix: source_prefix.to_string(),
};
};
// `source_prefix` always starts with `request_prefix`, so this slice is on
// a character boundary.
let extra = &source_prefix[request_prefix.len()..];
match extra.find(delimiter) {
Some(at) => SourceListPlan::Folded {
probe_prefix: source_prefix.to_string(),
common_prefix: format!("{request_prefix}{}", &extra[..at + delimiter.len()]),
},
None => SourceListPlan::Page {
prefix: source_prefix.to_string(),
},
}
}
/// Where one side resumes.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SideCursor {
pub token: Option<String>,
pub done: bool,
}
/// One page a side actually fetched this round.
#[derive(Clone, Debug, PartialEq, Eq)]
struct FetchedPage {
/// Token it was fetched with; `None` means from the start of the listing.
token: Option<String>,
/// Entries it contributed to the buffer, after the `last_key` filter.
count: usize,
/// Cursor for the page after it, `None` when it was the last one.
next_token: Option<String>,
}
/// Where a side resumes after `consumed` of its buffered entries were taken.
///
/// A fully consumed page advances to its successor; a partially consumed one
/// is re-listed from the same cursor next time and re-filtered by `last_key`.
fn advance_cursor(pages: &[FetchedPage], consumed: usize) -> SideCursor {
let mut remaining = consumed;
let mut cursor = SideCursor { token: None, done: true };
for page in pages {
if remaining >= page.count {
remaining -= page.count;
cursor = match &page.next_token {
Some(next) => SideCursor {
token: Some(next.clone()),
done: false,
},
None => SideCursor { token: None, done: true },
};
} else {
cursor = SideCursor {
token: page.token.clone(),
done: false,
};
break;
}
}
cursor
}
/// A page the merge driver still needs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FetchRequest {
pub side: MergeSide,
pub token: Option<String>,
}
#[derive(Debug, Default)]
struct SideState {
start: SideCursor,
pages: Vec<FetchedPage>,
entries: Vec<ListEntryKey>,
more: bool,
disabled: bool,
}
impl SideState {
fn from_cursor(token: Option<String>, done: bool) -> Self {
Self {
start: SideCursor { token, done },
..Default::default()
}
}
fn needs_page(&self, max_keys: usize) -> Option<Option<String>> {
if self.disabled || self.start.done {
return None;
}
match self.pages.last() {
None => Some(self.start.token.clone()),
Some(last) => {
let room = self.entries.len() < max_keys;
let capped = self.pages.len() >= MAX_LIST_FETCHES_PER_SIDE;
(self.more && room && !capped).then(|| last.next_token.clone())
}
}
}
}
/// The merged page, once both sides have handed over everything they will.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MergeOutcome {
/// Entries of the merged page, in wire order; indices point into each
/// side's buffer in push order.
pub picks: Vec<MergePick>,
pub is_truncated: bool,
/// `Some` exactly when `is_truncated`.
pub next_token: Option<ListThroughToken>,
}
/// Drives one merged page: the caller asks [`Self::next_fetch`] what to list,
/// hands the page back with [`Self::push_page`], and finishes with
/// [`Self::finish`]. Nothing here does I/O, so the same driver is exercised by
/// the property test and by the handler.
#[derive(Debug)]
pub struct ListThroughMerger {
max_keys: usize,
last_key: Option<String>,
local: SideState,
source: SideState,
}
impl ListThroughMerger {
/// `token` is the envelope from the client's continuation token, absent on
/// the first page of a listing.
pub fn new(max_keys: usize, token: Option<&ListThroughToken>) -> Self {
let (local, source, last_key) = match token {
Some(token) => (
SideState::from_cursor(token.local.clone(), token.local_done),
SideState::from_cursor(token.source.clone(), token.source_done),
token.last_key.clone(),
),
None => (SideState::default(), SideState::default(), None),
};
Self {
max_keys,
last_key,
local,
source,
}
}
/// Whether an entry the listing returned still belongs to this page: a
/// re-listed page repeats what the previous page already consumed.
pub fn accepts(&self, name: &str) -> bool {
self.last_key.as_deref().is_none_or(|bound| name > bound)
}
/// The source contributes nothing to this page: it failed, is rate-limited,
/// or `filter.prefix` excludes it.
pub fn disable_source(&mut self) {
self.source.disabled = true;
}
pub fn next_fetch(&self) -> Option<FetchRequest> {
for (side, state) in [(MergeSide::Local, &self.local), (MergeSide::Source, &self.source)] {
if let Some(token) = state.needs_page(self.max_keys) {
return Some(FetchRequest { side, token });
}
}
None
}
/// Records one fetched page. `entries` must be sorted by `name` and already
/// filtered with [`Self::accepts`]; the caller keeps the matching payloads
/// in the same order.
pub fn push_page(&mut self, side: MergeSide, entries: Vec<ListEntryKey>, is_truncated: bool, next_token: Option<String>) {
let state = match side {
MergeSide::Local => &mut self.local,
MergeSide::Source => &mut self.source,
};
let token = match state.pages.last() {
Some(last) => last.next_token.clone(),
None => state.start.token.clone(),
};
// A truncated page without a cursor cannot be continued; treating the
// side as finished is the only alternative to looping on it forever.
state.more = is_truncated && next_token.is_some();
state.pages.push(FetchedPage {
token,
count: entries.len(),
next_token: is_truncated.then_some(next_token).flatten(),
});
state.entries.extend(entries);
}
pub fn finish(self) -> MergeOutcome {
let Self {
max_keys,
last_key,
local,
source,
} = self;
// A side with more pages behind it can only be trusted up to the last
// key it handed over: past that horizon the other side's entries could
// still be deduplicated by one we have not seen, which is what keeps
// "local wins on equal keys" true across page boundaries.
let horizon = [
local
.more
.then(|| local.entries.last().map_or("", |entry| entry.name.as_str())),
source
.more
.then(|| source.entries.last().map_or("", |entry| entry.name.as_str())),
]
.into_iter()
.flatten()
.min();
let mut picks = Vec::with_capacity(max_keys.min(local.entries.len() + source.entries.len()));
let mut consumed_local = 0usize;
let mut consumed_source = 0usize;
let mut consumed_key: Option<String> = None;
while picks.len() < max_keys {
let next_local = local.entries.get(consumed_local).map(|entry| entry.name.as_str());
let next_source = source.entries.get(consumed_source).map(|entry| entry.name.as_str());
let name = match (next_local, next_source) {
(None, None) => break,
(Some(name), None) | (None, Some(name)) => name,
(Some(left), Some(right)) => left.min(right),
};
if horizon.is_some_and(|horizon| name > horizon) {
break;
}
let take_local = next_local == Some(name);
let take_source = next_source == Some(name);
consumed_key = Some(name.to_string());
if take_local {
picks.push(MergePick {
side: MergeSide::Local,
index: consumed_local,
});
consumed_local += 1;
} else {
picks.push(MergePick {
side: MergeSide::Source,
index: consumed_source,
});
}
if take_source {
consumed_source += 1;
}
}
let local_cursor = advance_cursor(&local.pages, consumed_local);
let source_cursor = if source.disabled {
// Keep the source where it was so a recovered source resumes there;
// this page is answered from local state alone.
source.start.clone()
} else {
advance_cursor(&source.pages, consumed_source)
};
let local_left = !local_cursor.done || consumed_local < local.entries.len();
let source_left = !source.disabled && (!source_cursor.done || consumed_source < source.entries.len());
let is_truncated = local_left || source_left;
let last_key = consumed_key.or(last_key);
MergeOutcome {
picks,
is_truncated,
next_token: is_truncated.then(|| ListThroughToken::new(local_cursor, source_cursor, last_key)),
}
}
}
/// Token bucket capping source `ListObjectsV2` calls for one bucket.
///
/// A caller that cannot be served inside its budget is refused rather than
/// queued: a listing degrades to local state instead of holding the request
/// open behind other tenants' listings.
#[derive(Debug)]
pub struct SourceListRateLimiter {
rate_per_sec: f64,
burst: f64,
state: Mutex<RateLimiterState>,
}
#[derive(Debug)]
struct RateLimiterState {
tokens: f64,
updated_at: Instant,
}
impl SourceListRateLimiter {
pub fn new(rate_per_sec: u32) -> Self {
let rate_per_sec = f64::from(rate_per_sec.max(1));
Self {
rate_per_sec,
burst: rate_per_sec,
state: Mutex::new(RateLimiterState {
tokens: rate_per_sec,
updated_at: Instant::now(),
}),
}
}
/// Reserves one call, returning how long the caller must wait before making
/// it, or `None` when that wait would exceed `max_wait` (nothing is
/// reserved then).
pub fn reserve(&self, max_wait: Duration) -> Option<Duration> {
self.reserve_at(Instant::now(), max_wait)
}
pub fn reserve_at(&self, now: Instant, max_wait: Duration) -> Option<Duration> {
let mut state = self.state.lock();
let elapsed = now.saturating_duration_since(state.updated_at).as_secs_f64();
state.tokens = (state.tokens + elapsed * self.rate_per_sec).min(self.burst);
state.updated_at = now;
if state.tokens >= 1.0 {
state.tokens -= 1.0;
return Some(Duration::ZERO);
}
let wait = Duration::from_secs_f64((1.0 - state.tokens) / self.rate_per_sec);
if wait > max_wait {
return None;
}
state.tokens -= 1.0;
Some(wait)
}
}
impl Default for SourceListRateLimiter {
fn default() -> Self {
Self::new(SOURCE_LIST_RATE_PER_SEC)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::collections::BTreeSet;
/// One `ListObjectsV2` page over a sorted key set, with the S3 rules the
/// merge relies on: delimiter roll-up, `max_keys`, and a continuation
/// token that resumes after the last entry the page returned.
fn reference_page(
keys: &[String],
prefix: &str,
delimiter: Option<&str>,
after: Option<&str>,
max_keys: usize,
) -> (Vec<ListEntryKey>, bool, Option<String>) {
let mut entries: Vec<ListEntryKey> = Vec::new();
for key in keys.iter().filter(|key| key.starts_with(prefix)) {
let entry = match delimiter.and_then(|delimiter| key[prefix.len()..].find(delimiter).map(|at| (delimiter, at))) {
Some((delimiter, at)) => ListEntryKey::prefix(&key[..prefix.len() + at + delimiter.len()]),
None => ListEntryKey::object(key.clone()),
};
if entries.last().is_none_or(|last| last.name != entry.name) {
entries.push(entry);
}
}
if let Some(after) = after {
entries.retain(|entry| entry.name.as_str() > after);
}
let truncated = entries.len() > max_keys;
entries.truncate(max_keys);
let next = truncated.then(|| entries.last().map(|entry| entry.name.clone())).flatten();
(entries, truncated && next.is_some(), next)
}
/// Full pagination through the merger, returning every entry it emitted and
/// the page sizes it produced.
fn walk(
local: &[String],
source: &[String],
prefix: &str,
delimiter: Option<&str>,
max_keys: usize,
) -> (Vec<(ListEntryKey, MergeSide)>, Vec<usize>) {
let mut emitted = Vec::new();
let mut page_sizes = Vec::new();
let mut token: Option<ListThroughToken> = None;
for _ in 0..10_000 {
let mut merger = ListThroughMerger::new(max_keys, token.as_ref());
let mut buffers = [Vec::<ListEntryKey>::new(), Vec::<ListEntryKey>::new()];
while let Some(fetch) = merger.next_fetch() {
let keys = match fetch.side {
MergeSide::Local => local,
MergeSide::Source => source,
};
let (entries, truncated, next) = reference_page(keys, prefix, delimiter, fetch.token.as_deref(), max_keys);
let kept: Vec<ListEntryKey> = entries.into_iter().filter(|entry| merger.accepts(&entry.name)).collect();
buffers[usize::from(fetch.side == MergeSide::Source)].extend(kept.iter().cloned());
merger.push_page(fetch.side, kept, truncated, next);
}
let outcome = merger.finish();
page_sizes.push(outcome.picks.len());
for pick in &outcome.picks {
let entry = buffers[usize::from(pick.side == MergeSide::Source)][pick.index].clone();
emitted.push((entry, pick.side));
}
if !outcome.is_truncated {
return (emitted, page_sizes);
}
token = outcome.next_token;
}
panic!("merged pagination did not terminate");
}
fn expected(local: &[String], source: &[String], prefix: &str, delimiter: Option<&str>) -> Vec<ListEntryKey> {
let mut all: Vec<String> = local.iter().chain(source.iter()).cloned().collect();
all.sort();
all.dedup();
let (entries, _, _) = reference_page(&all, prefix, delimiter, None, usize::MAX);
entries
}
#[test]
fn reference_page_rolls_up_and_paginates() {
let keys = vec!["a/1".to_string(), "a/2".to_string(), "b".to_string(), "c/1".to_string()];
let (entries, truncated, next) = reference_page(&keys, "", Some("/"), None, 2);
assert_eq!(entries, vec![ListEntryKey::prefix("a/"), ListEntryKey::object("b")]);
assert!(truncated);
assert_eq!(next.as_deref(), Some("b"));
}
#[test]
fn merged_pages_are_ordered_and_local_wins_on_equal_keys() {
let local = vec!["a".to_string(), "c".to_string()];
let source = vec!["b".to_string(), "c".to_string(), "d".to_string()];
let (emitted, sizes) = walk(&local, &source, "", None, 2);
let names: Vec<&str> = emitted.iter().map(|(entry, _)| entry.name.as_str()).collect();
assert_eq!(names, vec!["a", "b", "c", "d"]);
assert_eq!(emitted[2].1, MergeSide::Local, "the shared key must come from local");
assert!(sizes.iter().all(|size| *size <= 2), "{sizes:?}");
}
#[test]
fn source_only_listing_paginates_without_a_local_side() {
let source: Vec<String> = (0..7).map(|index| format!("k{index}")).collect();
let (emitted, _) = walk(&[], &source, "", None, 3);
assert_eq!(emitted.len(), 7);
assert!(emitted.iter().all(|(_, side)| *side == MergeSide::Source));
}
#[test]
fn a_disabled_source_answers_from_local_alone() {
let mut merger = ListThroughMerger::new(10, None);
merger.disable_source();
assert_eq!(
merger.next_fetch(),
Some(FetchRequest {
side: MergeSide::Local,
token: None
})
);
merger.push_page(MergeSide::Local, vec![ListEntryKey::object("a")], false, None);
assert_eq!(merger.next_fetch(), None);
let outcome = merger.finish();
assert_eq!(outcome.picks.len(), 1);
assert!(!outcome.is_truncated);
assert!(outcome.next_token.is_none());
}
#[test]
fn a_degraded_page_keeps_the_source_cursor_for_the_next_one() {
let resume = ListThroughToken {
t: LIST_THROUGH_TOKEN_TAG.to_string(),
v: LIST_THROUGH_TOKEN_VERSION,
local: Some("local-1".to_string()),
local_done: false,
source: Some("source-1".to_string()),
source_done: false,
last_key: Some("a".to_string()),
};
let mut merger = ListThroughMerger::new(1, Some(&resume));
merger.disable_source();
merger.push_page(
MergeSide::Local,
vec![ListEntryKey::object("b"), ListEntryKey::object("c")],
true,
Some("local-2".to_string()),
);
let outcome = merger.finish();
assert!(outcome.is_truncated);
let token = outcome.next_token.expect("truncated page carries a token");
assert_eq!(token.source.as_deref(), Some("source-1"), "the source cursor must not move");
assert!(!token.source_done);
assert_eq!(token.last_key.as_deref(), Some("b"));
assert_eq!(token.local.as_deref(), Some("local-1"), "a partly read page is re-listed");
}
#[test]
fn token_round_trips_and_rejects_tampering() {
let token = ListThroughToken::new(
SideCursor {
token: Some("l".to_string()),
done: false,
},
SideCursor { token: None, done: true },
Some("k".to_string()),
);
let encoded = token.encode();
assert_eq!(decode_continuation_token(&encoded), Ok(ListThroughCursor::Merged(Box::new(token))));
let bumped = encoded.replace("\"v\":1", "\"v\":2");
assert_eq!(decode_continuation_token(&bumped), Err(ListThroughTokenError::UnsupportedVersion(2)));
let extra = encoded.replace("{", "{\"x\":1,");
assert_eq!(decode_continuation_token(&extra), Err(ListThroughTokenError::Malformed));
let truncated = &encoded[..encoded.len() - 3];
assert_eq!(decode_continuation_token(truncated), Ok(ListThroughCursor::Local(truncated.to_string())));
let no_version = "{\"t\":\"odm-list\"}";
assert_eq!(decode_continuation_token(no_version), Err(ListThroughTokenError::Malformed));
}
#[test]
fn a_plain_local_marker_stays_local() {
assert_eq!(
decode_continuation_token("photos/2024/01.jpg"),
Ok(ListThroughCursor::Local("photos/2024/01.jpg".to_string()))
);
assert_eq!(
decode_continuation_token("{not json"),
Ok(ListThroughCursor::Local("{not json".to_string()))
);
assert_eq!(
decode_continuation_token("{\"t\":\"other\"}"),
Ok(ListThroughCursor::Local("{\"t\":\"other\"}".to_string()))
);
}
#[test]
fn source_list_plan_intersects_the_filter_prefix() {
assert_eq!(source_list_plan("", None, None), SourceListPlan::Page { prefix: String::new() });
assert_eq!(
source_list_plan("photos/2024/", Some("photos/"), None),
SourceListPlan::Page {
prefix: "photos/2024/".to_string()
}
);
assert_eq!(
source_list_plan("photos/", Some("photos/2024/"), None),
SourceListPlan::Page {
prefix: "photos/2024/".to_string()
}
);
assert_eq!(source_list_plan("videos/", Some("photos/"), None), SourceListPlan::Skip);
assert_eq!(
source_list_plan("", Some("photos/2024/"), Some("/")),
SourceListPlan::Folded {
probe_prefix: "photos/2024/".to_string(),
common_prefix: "photos/".to_string(),
}
);
assert_eq!(
source_list_plan("pho", Some("photos"), Some("/")),
SourceListPlan::Page {
prefix: "photos".to_string()
},
"a filter prefix that adds no delimiter keeps the source's own roll-up"
);
}
#[test]
fn rate_limiter_spends_its_burst_then_paces_and_refuses() {
let limiter = SourceListRateLimiter::new(10);
let start = Instant::now();
for _ in 0..10 {
assert_eq!(limiter.reserve_at(start, Duration::from_secs(1)), Some(Duration::ZERO));
}
let paced = limiter.reserve_at(start, Duration::from_secs(1)).expect("within the budget");
assert!(paced > Duration::ZERO && paced <= Duration::from_millis(101), "{paced:?}");
assert_eq!(limiter.reserve_at(start, Duration::ZERO), None, "a zero budget refuses");
// A full second of refill restores the whole burst.
assert_eq!(limiter.reserve_at(start + Duration::from_secs(5), Duration::ZERO), Some(Duration::ZERO));
}
fn key_set() -> impl Strategy<Value = Vec<String>> {
proptest::collection::btree_set(
proptest::sample::select(vec!["a", "a/", "a/1", "a/2", "a/b/1", "b", "b/1", "c", "c/1", "c/2", "d", "d/e/f"])
.prop_map(str::to_string),
0..=12,
)
.prop_map(|set: BTreeSet<String>| set.into_iter().collect())
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// Full pagination of a merged listing equals the sorted, deduplicated
/// union of both sides, with every shared key served by local, and no
/// page longer than `max_keys`.
#[test]
fn merged_pagination_equals_the_deduplicated_union(
local in key_set(),
source in key_set(),
max_keys in 1usize..=5,
with_delimiter in any::<bool>(),
prefix in proptest::sample::select(vec!["", "a", "a/", "c/"]),
) {
let delimiter = with_delimiter.then_some("/");
let (emitted, sizes) = walk(&local, &source, prefix, delimiter, max_keys);
let got: Vec<ListEntryKey> = emitted.iter().map(|(entry, _)| entry.clone()).collect();
prop_assert_eq!(got, expected(&local, &source, prefix, delimiter));
prop_assert!(sizes.iter().all(|size| *size <= max_keys), "{:?}", sizes);
for (entry, side) in &emitted {
if !entry.is_prefix && local.iter().any(|key| key == &entry.name) {
prop_assert_eq!(*side, MergeSide::Local, "local must win for {}", entry.name);
}
}
}
}
}
@@ -0,0 +1,60 @@
// 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.
//! On-Demand Migration (ODM): a bucket can name an external S3-compatible
//! source bucket; GET misses are served from that source and backfilled
//! locally. This module owns the bucket-level configuration model
//! (`on-demand-migration.json` in the bucket metadata file), the source
//! client, and the per-node runtime (`sys`) that turns configs into live
//! clients guarded by a breaker, a negative cache, singleflight and a pull
//! concurrency limit (rustfs/backlog#2147).
pub mod backfill;
pub mod breaker;
pub mod config;
pub mod list_through;
pub mod negative_cache;
pub mod pull;
pub mod source_client;
pub mod stats;
pub mod sys;
pub use breaker::{
BREAKER_FAILURE_THRESHOLD, BREAKER_FAILURE_WINDOW, BREAKER_HALF_OPEN_MAX_PROBES, BREAKER_OPEN_DURATION, Breaker,
BreakerState, BreakerTransition, BreakerVerdict,
};
pub use config::{
ConfigPublishHook, FilterConfig, HeadPolicy, ON_DEMAND_MIGRATION_CONFIG_HOOK, ON_DEMAND_MIGRATION_CONFIG_VERSION,
OnDemandMigrationConfig, OnDemandMigrationConfigError, PathStyle, PolicyConfig, Provider, RangeGetPolicy, SourceConfig,
SourceCredentials, SourceErrorPolicy, SourceTimeout, TlsConfig, ValidationContext,
};
pub use list_through::{
FetchRequest, LIST_THROUGH_TOKEN_VERSION, ListEntryKey, ListThroughCursor, ListThroughMerger, ListThroughToken,
ListThroughTokenError, MAX_LIST_FETCHES_PER_SIDE, MergeOutcome, MergePick, MergeSide, SOURCE_LIST_MAX_RATE_WAIT,
SOURCE_LIST_RATE_PER_SEC, SourceListPlan, SourceListRateLimiter, decode_continuation_token, source_list_plan,
};
pub use negative_cache::{NEGATIVE_CACHE_MAX_ENTRIES, NegativeCache};
pub use pull::{
EnqueueOutcome, LocalObject, MAX_MULTIPART_PARTS, OdmWriteBack, PULL_MAX_RETRIES, PULL_RETRY_BASE_DELAYS, PullCompletion,
PullQueue, PullReason, PullSource, QueuedPullOutcome, SourceBody, SourceIdleGuard, WriteBackBody, WriteBackError,
WriteBackOutcome, WriteBackPart, WriteBackRequest, commit_inline, commit_inline_with, idle_guarded_body,
};
pub use stats::{
GaugeGuard, LastSourceError, LatencyBucketSnapshot, OdmOp, OdmOutcome, OdmStats, OdmStatsSnapshot, PullFailureReason,
PullPath, SOURCE_LATENCY_BUCKET_BOUNDS_MS, SourceLatencySnapshot,
};
pub use sys::{
ApplyOutcome, BucketOdmState, GLOBAL_ON_DEMAND_MIGRATION_SYS, OdmBucketSnapshot, OdmLookup, OdmStateError,
OnDemandMigrationSys, PullError, PullFollower, PullLeader, PullOutcome, PullResult, PullSlot, source_client_spec,
};
@@ -0,0 +1,130 @@
// 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.
//! Per-bucket cache of keys the source answered 404 for
//! (rustfs/backlog#2152). A hit short-circuits the source lookup for
//! `policy.negative_cache_ttl_secs`; a TTL of zero disables the cache.
//!
//! Entries are never invalidated on a local PUT: once the object exists
//! locally the handler never consults ODM for it, so a stale negative entry
//! is harmless.
use std::time::Duration;
/// Upper bound on remembered keys per bucket; LRU eviction beyond it.
pub const NEGATIVE_CACHE_MAX_ENTRIES: u64 = 100_000;
#[derive(Debug)]
pub struct NegativeCache {
cache: Option<moka::sync::Cache<String, ()>>,
ttl: Duration,
}
impl NegativeCache {
/// `ttl == 0` builds a disabled cache that never records anything.
pub fn new(ttl: Duration) -> Self {
Self::with_capacity(ttl, NEGATIVE_CACHE_MAX_ENTRIES)
}
pub fn with_capacity(ttl: Duration, max_entries: u64) -> Self {
let cache = (!ttl.is_zero()).then(|| {
moka::sync::Cache::builder()
.max_capacity(max_entries)
.time_to_live(ttl)
.build()
});
Self { cache, ttl }
}
pub fn is_enabled(&self) -> bool {
self.cache.is_some()
}
pub fn ttl(&self) -> Duration {
self.ttl
}
/// Whether `key` is currently remembered as absent on the source.
pub fn contains(&self, key: &str) -> bool {
self.cache.as_ref().is_some_and(|cache| cache.get(key).is_some())
}
/// Remembers `key` as absent; no-op when disabled.
pub fn insert(&self, key: &str) {
if let Some(cache) = &self.cache {
cache.insert(key.to_string(), ());
}
}
/// Forgets `key` (e.g. after an admin-triggered backfill found it).
pub fn remove(&self, key: &str) {
if let Some(cache) = &self.cache {
cache.invalidate(key);
}
}
/// Approximate live entry count, for status snapshots only.
pub fn len(&self) -> u64 {
self.cache.as_ref().map_or(0, |cache| {
cache.run_pending_tasks();
cache.entry_count()
})
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entry_expires_after_ttl() {
let cache = NegativeCache::new(Duration::from_millis(80));
assert!(cache.is_enabled());
cache.insert("a/x");
assert!(cache.contains("a/x"));
assert!(!cache.contains("a/y"));
std::thread::sleep(Duration::from_millis(160));
assert!(!cache.contains("a/x"), "entry must expire after the TTL");
}
#[test]
fn zero_ttl_disables_the_cache() {
let cache = NegativeCache::new(Duration::ZERO);
assert!(!cache.is_enabled());
cache.insert("a/x");
assert!(!cache.contains("a/x"));
assert!(cache.is_empty());
}
#[test]
fn remove_forgets_a_key() {
let cache = NegativeCache::new(Duration::from_secs(30));
cache.insert("a/x");
cache.remove("a/x");
assert!(!cache.contains("a/x"));
}
#[test]
fn capacity_bounds_entries() {
let cache = NegativeCache::with_capacity(Duration::from_secs(30), 4);
for i in 0..64 {
cache.insert(&format!("k{i}"));
}
assert!(cache.len() <= 4, "len {} exceeds capacity", cache.len());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,531 @@
// 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.
//! Per-bucket on-demand migration counters (rustfs/backlog#2152).
//!
//! `OdmStats` is lock-free and survives config rebuilds; `snapshot()` turns
//! it into the serializable `OdmStatsSnapshot` that the metrics collector
//! and the admin status route (ODM-10/14/15) consume. Field names and label
//! values are a wire contract: the golden JSON test below pins them.
use super::breaker::BreakerState;
use super::source_client::SourceError;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use time::OffsetDateTime;
/// Request operations that can enter ODM.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OdmOp {
Get,
Head,
}
impl OdmOp {
pub const ALL: [OdmOp; 2] = [OdmOp::Get, OdmOp::Head];
pub fn as_str(self) -> &'static str {
match self {
OdmOp::Get => "get",
OdmOp::Head => "head",
}
}
}
/// How a request that entered ODM ended. `local_hit` is deliberately absent:
/// requests served locally never reach the runtime.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OdmOutcome {
SourceHit,
SourceMiss,
SourceError,
BreakerOpen,
NegativeCached,
Filtered,
Unsupported,
}
impl OdmOutcome {
pub const ALL: [OdmOutcome; 7] = [
OdmOutcome::SourceHit,
OdmOutcome::SourceMiss,
OdmOutcome::SourceError,
OdmOutcome::BreakerOpen,
OdmOutcome::NegativeCached,
OdmOutcome::Filtered,
OdmOutcome::Unsupported,
];
pub fn as_str(self) -> &'static str {
match self {
OdmOutcome::SourceHit => "source_hit",
OdmOutcome::SourceMiss => "source_miss",
OdmOutcome::SourceError => "source_error",
OdmOutcome::BreakerOpen => "breaker_open",
OdmOutcome::NegativeCached => "negative_cached",
OdmOutcome::Filtered => "filtered",
OdmOutcome::Unsupported => "unsupported",
}
}
}
/// Which pipeline stored a pulled object locally.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PullPath {
/// Streamed to the client and written locally in one pass.
Inline,
/// Pulled by a background task after a partial/large read.
Background,
/// Pulled by the backfill job.
Backfill,
}
impl PullPath {
pub const ALL: [PullPath; 3] = [PullPath::Inline, PullPath::Background, PullPath::Backfill];
pub fn as_str(self) -> &'static str {
match self {
PullPath::Inline => "inline",
PullPath::Background => "background",
PullPath::Backfill => "backfill",
}
}
}
/// Why a pull did not produce a local object.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PullFailureReason {
SourceNotFound,
SourceAccessDenied,
SourceThrottled,
SourceTimeout,
SourceConnect,
SourceServerError,
SourceUnsupported,
SourceOther,
/// Source bytes did not match the ETag advertised by HEAD/GET.
EtagMismatch,
/// The local write (internal PUT) failed.
LocalWrite,
/// The bucket quota rejected the write-back.
Quota,
/// The bucket state was removed or the process is shutting down.
Canceled,
/// The background pull queue was full.
QueueFull,
}
impl PullFailureReason {
pub const ALL: [PullFailureReason; 13] = [
PullFailureReason::SourceNotFound,
PullFailureReason::SourceAccessDenied,
PullFailureReason::SourceThrottled,
PullFailureReason::SourceTimeout,
PullFailureReason::SourceConnect,
PullFailureReason::SourceServerError,
PullFailureReason::SourceUnsupported,
PullFailureReason::SourceOther,
PullFailureReason::EtagMismatch,
PullFailureReason::LocalWrite,
PullFailureReason::Quota,
PullFailureReason::Canceled,
PullFailureReason::QueueFull,
];
pub fn as_str(self) -> &'static str {
match self {
PullFailureReason::SourceNotFound => "source_not_found",
PullFailureReason::SourceAccessDenied => "source_access_denied",
PullFailureReason::SourceThrottled => "source_throttled",
PullFailureReason::SourceTimeout => "source_timeout",
PullFailureReason::SourceConnect => "source_connect",
PullFailureReason::SourceServerError => "source_server_error",
PullFailureReason::SourceUnsupported => "source_unsupported",
PullFailureReason::SourceOther => "source_other",
PullFailureReason::EtagMismatch => "etag_mismatch",
PullFailureReason::LocalWrite => "local_write",
PullFailureReason::Quota => "quota",
PullFailureReason::Canceled => "canceled",
PullFailureReason::QueueFull => "queue_full",
}
}
}
impl From<&SourceError> for PullFailureReason {
fn from(err: &SourceError) -> Self {
match err {
SourceError::NotFound => PullFailureReason::SourceNotFound,
SourceError::AccessDenied => PullFailureReason::SourceAccessDenied,
SourceError::Throttled => PullFailureReason::SourceThrottled,
SourceError::Timeout => PullFailureReason::SourceTimeout,
SourceError::Connect(_) => PullFailureReason::SourceConnect,
SourceError::ServerError(_) => PullFailureReason::SourceServerError,
SourceError::Unsupported(_) => PullFailureReason::SourceUnsupported,
SourceError::Other(_) => PullFailureReason::SourceOther,
}
}
}
/// Upper bounds (milliseconds) of the source latency histogram buckets; the
/// implicit last bucket is unbounded. Roughly logarithmic from 5 ms to 60 s.
pub const SOURCE_LATENCY_BUCKET_BOUNDS_MS: [u64; 14] = [
5, 10, 20, 50, 100, 200, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 60_000,
];
#[derive(Debug, Default)]
struct LatencyHistogram {
/// One counter per bound plus one for the overflow bucket.
buckets: [AtomicU64; SOURCE_LATENCY_BUCKET_BOUNDS_MS.len() + 1],
count: AtomicU64,
sum_ms: AtomicU64,
}
impl LatencyHistogram {
fn observe(&self, latency: Duration) {
let ms = u64::try_from(latency.as_millis()).unwrap_or(u64::MAX);
let index = SOURCE_LATENCY_BUCKET_BOUNDS_MS
.iter()
.position(|bound| ms <= *bound)
.unwrap_or(SOURCE_LATENCY_BUCKET_BOUNDS_MS.len());
self.buckets[index].fetch_add(1, Ordering::Relaxed);
self.count.fetch_add(1, Ordering::Relaxed);
self.sum_ms.fetch_add(ms, Ordering::Relaxed);
}
fn snapshot(&self) -> SourceLatencySnapshot {
let mut cumulative = 0;
let buckets = SOURCE_LATENCY_BUCKET_BOUNDS_MS
.iter()
.zip(self.buckets.iter())
.map(|(bound, counter)| {
cumulative += counter.load(Ordering::Relaxed);
LatencyBucketSnapshot {
le_ms: *bound,
count: cumulative,
}
})
.collect();
SourceLatencySnapshot {
buckets,
count: self.count.load(Ordering::Relaxed),
sum_ms: self.sum_ms.load(Ordering::Relaxed),
}
}
}
/// The most recent source failure, kept for operators: class only, never the
/// key or the message (which may echo attacker-controlled input).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LastSourceError {
pub class: String,
#[serde(with = "time::serde::rfc3339")]
pub at: OffsetDateTime,
}
#[derive(Debug, Default)]
pub struct OdmStats {
requests_total: [[AtomicU64; OdmOutcome::ALL.len()]; OdmOp::ALL.len()],
pulled_bytes_total: AtomicU64,
pulled_objects_total: [AtomicU64; PullPath::ALL.len()],
pull_failures_total: [AtomicU64; PullFailureReason::ALL.len()],
inflight_pulls: AtomicU64,
queue_depth: AtomicU64,
source_latency: LatencyHistogram,
last_source_error: Mutex<Option<LastSourceError>>,
}
impl OdmStats {
pub fn new() -> Self {
Self::default()
}
pub fn record_request(&self, op: OdmOp, outcome: OdmOutcome) {
self.requests_total[op as usize][outcome as usize].fetch_add(1, Ordering::Relaxed);
}
pub fn record_pulled_bytes(&self, bytes: u64) {
self.pulled_bytes_total.fetch_add(bytes, Ordering::Relaxed);
}
pub fn record_pulled_object(&self, path: PullPath) {
self.pulled_objects_total[path as usize].fetch_add(1, Ordering::Relaxed);
}
pub fn record_pull_failure(&self, reason: PullFailureReason) {
self.pull_failures_total[reason as usize].fetch_add(1, Ordering::Relaxed);
}
pub fn record_source_latency(&self, latency: Duration) {
self.source_latency.observe(latency);
}
pub fn record_source_error(&self, err: &SourceError) {
self.record_source_error_at(err, OffsetDateTime::now_utc());
}
pub fn record_source_error_at(&self, err: &SourceError, at: OffsetDateTime) {
*self.last_source_error.lock() = Some(LastSourceError {
class: err.class_label().to_string(),
at,
});
}
pub fn last_source_error(&self) -> Option<LastSourceError> {
self.last_source_error.lock().clone()
}
pub fn inflight_pulls(&self) -> u64 {
self.inflight_pulls.load(Ordering::Relaxed)
}
pub fn queue_depth(&self) -> u64 {
self.queue_depth.load(Ordering::Relaxed)
}
/// RAII increment of `inflight_pulls`.
pub fn inflight_guard(self: &Arc<Self>) -> GaugeGuard {
GaugeGuard::new(Arc::clone(self), OdmGauge::InflightPulls)
}
/// RAII increment of `queue_depth`.
pub fn queue_guard(self: &Arc<Self>) -> GaugeGuard {
GaugeGuard::new(Arc::clone(self), OdmGauge::QueueDepth)
}
fn gauge(&self, gauge: OdmGauge) -> &AtomicU64 {
match gauge {
OdmGauge::InflightPulls => &self.inflight_pulls,
OdmGauge::QueueDepth => &self.queue_depth,
}
}
/// Read-only, side-effect-free copy of every counter. The breaker lives
/// next to the stats in the bucket state; its state is passed in so the
/// snapshot stays a single document.
pub fn snapshot(&self, breaker_state: BreakerState) -> OdmStatsSnapshot {
let mut requests_total = BTreeMap::new();
for op in OdmOp::ALL {
let mut by_outcome = BTreeMap::new();
for outcome in OdmOutcome::ALL {
by_outcome.insert(
outcome.as_str().to_string(),
self.requests_total[op as usize][outcome as usize].load(Ordering::Relaxed),
);
}
requests_total.insert(op.as_str().to_string(), by_outcome);
}
let pulled_objects_total = PullPath::ALL
.iter()
.map(|path| {
(
path.as_str().to_string(),
self.pulled_objects_total[*path as usize].load(Ordering::Relaxed),
)
})
.collect();
let pull_failures_total = PullFailureReason::ALL
.iter()
.map(|reason| {
(
reason.as_str().to_string(),
self.pull_failures_total[*reason as usize].load(Ordering::Relaxed),
)
})
.collect();
OdmStatsSnapshot {
requests_total,
pulled_bytes_total: self.pulled_bytes_total.load(Ordering::Relaxed),
pulled_objects_total,
pull_failures_total,
inflight_pulls: self.inflight_pulls(),
queue_depth: self.queue_depth(),
source_latency: self.source_latency.snapshot(),
last_source_error: self.last_source_error(),
breaker_state,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OdmGauge {
InflightPulls,
QueueDepth,
}
/// Increments a gauge on creation and decrements it on drop. Owns its
/// `OdmStats` so it can live inside the pull slot handed to callers.
#[derive(Debug)]
pub struct GaugeGuard {
stats: Arc<OdmStats>,
gauge: OdmGauge,
}
impl GaugeGuard {
fn new(stats: Arc<OdmStats>, gauge: OdmGauge) -> Self {
stats.gauge(gauge).fetch_add(1, Ordering::Relaxed);
Self { stats, gauge }
}
}
impl Drop for GaugeGuard {
fn drop(&mut self) {
self.stats.gauge(self.gauge).fetch_sub(1, Ordering::Relaxed);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LatencyBucketSnapshot {
/// Upper bound of the bucket in milliseconds.
pub le_ms: u64,
/// Cumulative observations at or below `le_ms`.
pub count: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceLatencySnapshot {
pub buckets: Vec<LatencyBucketSnapshot>,
/// Total observations, including those above the last bound.
pub count: u64,
pub sum_ms: u64,
}
/// Serializable copy of [`OdmStats`]. Every key is snake_case and every
/// label set is fixed, so consumers can rely on the document shape.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct OdmStatsSnapshot {
/// `op -> outcome -> count`.
pub requests_total: BTreeMap<String, BTreeMap<String, u64>>,
pub pulled_bytes_total: u64,
/// `path -> count`.
pub pulled_objects_total: BTreeMap<String, u64>,
/// `reason -> count`.
pub pull_failures_total: BTreeMap<String, u64>,
pub inflight_pulls: u64,
pub queue_depth: u64,
pub source_latency: SourceLatencySnapshot,
pub last_source_error: Option<LastSourceError>,
pub breaker_state: BreakerState,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use time::macros::datetime;
#[test]
fn snapshot_matches_golden_json() {
let stats = Arc::new(OdmStats::new());
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
stats.record_request(OdmOp::Get, OdmOutcome::SourceHit);
stats.record_request(OdmOp::Head, OdmOutcome::NegativeCached);
stats.record_pulled_bytes(4096);
stats.record_pulled_object(PullPath::Inline);
stats.record_pull_failure(PullFailureReason::from(&SourceError::Timeout));
stats.record_source_latency(Duration::from_millis(3));
stats.record_source_latency(Duration::from_millis(750));
stats.record_source_latency(Duration::from_secs(90));
stats.record_source_error_at(&SourceError::ServerError(502), datetime!(2026-09-02 10:00:00 UTC));
let _inflight = stats.inflight_guard();
let _queued = stats.queue_guard();
let snapshot = stats.snapshot(BreakerState::HalfOpen);
let actual = serde_json::to_value(&snapshot).unwrap();
let expected = json!({
"requests_total": {
"get": {
"breaker_open": 0, "filtered": 0, "negative_cached": 0, "source_error": 0,
"source_hit": 2, "source_miss": 0, "unsupported": 0
},
"head": {
"breaker_open": 0, "filtered": 0, "negative_cached": 1, "source_error": 0,
"source_hit": 0, "source_miss": 0, "unsupported": 0
}
},
"pulled_bytes_total": 4096,
"pulled_objects_total": { "backfill": 0, "background": 0, "inline": 1 },
"pull_failures_total": {
"canceled": 0, "etag_mismatch": 0, "local_write": 0, "queue_full": 0, "quota": 0,
"source_access_denied": 0, "source_connect": 0, "source_not_found": 0, "source_other": 0,
"source_server_error": 0, "source_throttled": 0, "source_timeout": 1, "source_unsupported": 0
},
"inflight_pulls": 1,
"queue_depth": 1,
"source_latency": {
"buckets": [
{ "le_ms": 5, "count": 1 }, { "le_ms": 10, "count": 1 }, { "le_ms": 20, "count": 1 },
{ "le_ms": 50, "count": 1 }, { "le_ms": 100, "count": 1 }, { "le_ms": 200, "count": 1 },
{ "le_ms": 500, "count": 1 }, { "le_ms": 1000, "count": 2 }, { "le_ms": 2000, "count": 2 },
{ "le_ms": 5000, "count": 2 }, { "le_ms": 10000, "count": 2 }, { "le_ms": 20000, "count": 2 },
{ "le_ms": 30000, "count": 2 }, { "le_ms": 60000, "count": 2 }
],
"count": 3,
"sum_ms": 90753
},
"last_source_error": { "class": "server_error", "at": "2026-09-02T10:00:00Z" },
"breaker_state": "half_open"
});
assert_eq!(actual, expected);
let round_trip: OdmStatsSnapshot = serde_json::from_value(actual).unwrap();
assert_eq!(round_trip, snapshot);
}
#[test]
fn gauges_return_to_zero_when_guards_drop() {
let stats = Arc::new(OdmStats::new());
{
let _a = stats.inflight_guard();
let _b = stats.inflight_guard();
let _c = stats.queue_guard();
assert_eq!(stats.inflight_pulls(), 2);
assert_eq!(stats.queue_depth(), 1);
}
assert_eq!(stats.inflight_pulls(), 0);
assert_eq!(stats.queue_depth(), 0);
}
#[test]
fn pull_failure_reason_covers_every_source_error_class() {
let cases = [
(SourceError::NotFound, PullFailureReason::SourceNotFound),
(SourceError::AccessDenied, PullFailureReason::SourceAccessDenied),
(SourceError::Throttled, PullFailureReason::SourceThrottled),
(SourceError::Timeout, PullFailureReason::SourceTimeout),
(SourceError::Connect("x".into()), PullFailureReason::SourceConnect),
(SourceError::ServerError(500), PullFailureReason::SourceServerError),
(SourceError::Unsupported("x".into()), PullFailureReason::SourceUnsupported),
(SourceError::Other("x".into()), PullFailureReason::SourceOther),
];
for (err, reason) in cases {
assert_eq!(PullFailureReason::from(&err), reason, "{err:?}");
assert_eq!(serde_json::to_string(&reason).unwrap(), format!("\"{}\"", reason.as_str()));
}
}
#[test]
fn label_lists_are_exhaustive_and_unique() {
let outcomes: std::collections::BTreeSet<_> = OdmOutcome::ALL.iter().map(|o| o.as_str()).collect();
assert_eq!(outcomes.len(), OdmOutcome::ALL.len());
let reasons: std::collections::BTreeSet<_> = PullFailureReason::ALL.iter().map(|r| r.as_str()).collect();
assert_eq!(reasons.len(), PullFailureReason::ALL.len());
let paths: std::collections::BTreeSet<_> = PullPath::ALL.iter().map(|p| p.as_str()).collect();
assert_eq!(paths.len(), PullPath::ALL.len());
}
}
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -16,7 +16,7 @@ use super::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
use crate::bucket::metadata_sys::{BucketMetadataSys, update, update_if_incarnation};
use crate::data_usage::get_bucket_usage_memory;
use rustfs_config::QUOTA_CONFIG_FILE;
use rustfs_scanner_contracts::metrics::Metric;
use rustfs_scanner_metrics::metrics::Metric;
use std::sync::Arc;
use std::time::Instant;
use time::OffsetDateTime;
@@ -120,9 +120,9 @@ impl QuotaChecker {
let duration = start_time.elapsed();
// inc_time is now a plain fn (not async) — no .await needed.
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaCheck, duration);
if !allowed {
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaViolation, duration);
}
Ok(result)
@@ -185,7 +185,7 @@ impl QuotaChecker {
.await
.map_err(QuotaError::StorageError)?;
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(updated_at)
}
@@ -206,7 +206,7 @@ impl QuotaChecker {
}
.map_err(QuotaError::StorageError)?;
rustfs_scanner_contracts::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
rustfs_scanner_metrics::metrics::Metrics::inc_time(Metric::QuotaSync, start_time.elapsed());
Ok(updated_at)
}
@@ -0,0 +1,887 @@
// 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.
//! Shared builder for outbound `aws_sdk_s3::Client`s.
//!
//! Replication targets (`bucket_target_sys`) and the on-demand migration
//! source client build their remote clients from one neutral
//! [`RemoteS3EndpointSpec`]: endpoint assembly, credential handling, path-style
//! selection, custom CA / skip-TLS transports, the SDK retry policy and the
//! outbound SSRF gate all live here so both callers share exactly one policy.
//! The retry policy is the one knob the two consumers deliberately disagree
//! on, so [`RemoteS3EndpointSpec::retry`] is a required field rather than an
//! inherited SDK default. The gate keeps the
//! relaxed replication semantics documented in
//! `docs/operations/outbound-connection-policy.md`: private addresses are
//! always allowed, loopback only behind `RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET`.
use aws_credential_types::Credentials as SdkCredentials;
use aws_credential_types::provider::{ProvideCredentials, error::CredentialsError, future};
use aws_sdk_s3::config::Region as SdkRegion;
use aws_sdk_s3::config::RequestChecksumCalculation;
use aws_sdk_s3::config::SharedCredentialsProvider;
use aws_sdk_s3::config::SharedHttpClient;
use aws_sdk_s3::config::retry::RetryConfig;
use aws_sdk_s3::{Client as S3Client, Config as S3Config};
use aws_smithy_http_client::{Builder as SmithyHttpClientBuilder, tls as smithy_tls};
use aws_smithy_runtime_api::box_error::BoxError;
use aws_smithy_runtime_api::client::http::{
HttpConnector as SmithyHttpConnector, HttpConnectorFuture, SharedHttpConnector, http_client_fn,
};
use aws_smithy_runtime_api::client::interceptors::Intercept;
use aws_smithy_runtime_api::client::interceptors::context::BeforeTransmitInterceptorContextMut;
use aws_smithy_runtime_api::client::orchestrator::{HttpRequest, HttpResponse};
use aws_smithy_runtime_api::client::result::ConnectorError;
use aws_smithy_runtime_api::client::runtime_components::RuntimeComponents;
use aws_smithy_types::body::SdkBody;
use aws_smithy_types::config_bag::ConfigBag;
use aws_smithy_types::timeout::TimeoutConfig;
use http::Uri;
use hyper_util::client::legacy::Client as HyperClient;
use hyper_util::rt::{TokioExecutor, TokioTimer};
use rustfs_config::{DEFAULT_TRUST_LEAF_CERT_AS_CA, ENV_TRUST_LEAF_CERT_AS_CA, RUSTFS_CA_CERT, RUSTFS_TLS_CERT};
use rustfs_utils::egress::{OutboundUrlError, validate_outbound_url};
use rustls_pki_types::pem::PemObject;
use std::fmt;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use tower::Service;
use tracing::warn;
use url::Url;
const REDACTED_CREDENTIAL: &str = "<redacted>";
pub(crate) const EXPIRED_REMOTE_TARGET_CREDENTIALS: &str = "remote target credentials have expired";
/// Request addressing style for a remote S3-compatible endpoint.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PathStyle {
/// Caller did not choose; the builder defaults to path-style because that
/// is what custom S3-compatible endpoints accept most reliably.
Auto,
/// `https://endpoint/bucket/key`.
Path,
/// `https://bucket.endpoint/key`.
VirtualHost,
}
impl PathStyle {
/// Resolves the style to the SDK `force_path_style` flag. `Auto` keeps
/// the historical replication default (path-style).
pub fn force_path_style(self) -> bool {
!matches!(self, PathStyle::VirtualHost)
}
}
/// SDK-level retry policy for a remote client. Retries are invisible to the
/// caller — one logical call becomes several wire requests — so every consumer
/// states its own instead of inheriting the SDK default: a caller that already
/// owns a retry budget would otherwise multiply it against an endpoint that is
/// by definition already failing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RemoteS3RetryPolicy {
/// One logical call is exactly one wire request; the caller owns the
/// retry budget.
Disabled,
/// Smithy's standard strategy, capped at `max_attempts` attempts in total
/// (the initial request included). Values below 1 are clamped to 1.
Standard { max_attempts: u32 },
}
/// The SDK default replication targets have always run with, written out so a
/// change to it is a change to this line rather than to a dependency default.
pub const REPLICATION_TARGET_RETRY_POLICY: RemoteS3RetryPolicy = RemoteS3RetryPolicy::Standard { max_attempts: 3 };
impl RemoteS3RetryPolicy {
fn retry_config(self) -> RetryConfig {
match self {
RemoteS3RetryPolicy::Disabled => RetryConfig::disabled(),
RemoteS3RetryPolicy::Standard { max_attempts } => RetryConfig::standard().with_max_attempts(max_attempts.max(1)),
}
}
}
/// Static or temporary credentials for a remote endpoint. `expiration` without
/// a `session_token` is rejected at build time: only STS-style temporary
/// credentials expire, so that combination is a corrupted configuration
/// rather than a static key.
#[derive(Clone)]
pub struct RemoteCredentials {
pub access_key: String,
pub secret_key: String,
pub session_token: Option<String>,
pub expiration: Option<SystemTime>,
/// SDK credential `account_id`; replication targets pass their reset id.
pub account_id: String,
}
impl fmt::Debug for RemoteCredentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RemoteCredentials")
.field("access_key", &self.access_key)
.field("secret_key", &REDACTED_CREDENTIAL)
.field("session_token", &self.session_token.as_ref().map(|_| REDACTED_CREDENTIAL))
.field("expiration", &self.expiration)
.field("account_id", &self.account_id)
.finish()
}
}
/// Neutral description of a remote S3 endpoint from which an
/// `aws_sdk_s3::Client` is built.
#[derive(Clone, Debug)]
pub struct RemoteS3EndpointSpec {
/// `host[:port]` without a scheme; `secure` selects `https` or `http`.
pub endpoint: String,
pub secure: bool,
pub region: String,
pub path_style: PathStyle,
pub credentials: Option<RemoteCredentials>,
/// Accept any server certificate. Takes priority over `ca_cert_pem`.
pub skip_tls_verify: bool,
/// Extra PEM bundle trusted alongside the platform roots and the
/// `RUSTFS_TLS_PATH` bundle. `None` and whitespace-only mean "not set".
pub ca_cert_pem: Option<String>,
pub connect_timeout: Option<Duration>,
pub read_timeout: Option<Duration>,
/// How many wire requests one logical call may cost. Every consumer
/// declares it; see [`RemoteS3RetryPolicy`].
pub retry: RemoteS3RetryPolicy,
/// Appended to the SDK `User-Agent` (space separated) so the remote side
/// can identify the caller; empty means no suffix.
pub user_agent_suffix: &'static str,
}
impl RemoteS3EndpointSpec {
/// Full endpoint URL (`scheme://host[:port]`) as handed to the SDK.
pub fn endpoint_url(&self) -> String {
if self.secure {
format!("https://{}", self.endpoint)
} else {
format!("http://{}", self.endpoint)
}
}
fn custom_ca_pem(&self) -> Option<&str> {
self.ca_cert_pem.as_deref().filter(|pem| !pem.trim().is_empty())
}
}
#[derive(Debug, thiserror::Error)]
pub enum RemoteS3ClientError {
#[error("remote endpoint requires credentials")]
MissingCredentials,
#[error("{0}")]
Credentials(&'static str),
#[error("invalid target endpoint: {0}")]
InvalidEndpoint(String),
#[error("target endpoint is not allowed: {0}")]
EndpointNotAllowed(#[source] OutboundUrlError),
#[error("invalid target CA PEM: {0}")]
InvalidCaPem(String),
}
#[derive(Clone)]
pub(crate) struct RemoteTargetCredentialsProvider {
pub(crate) credentials: SdkCredentials,
}
impl RemoteTargetCredentialsProvider {
pub(crate) fn resolve_at(&self, now: SystemTime) -> aws_credential_types::provider::Result {
if self.credentials.expiry().is_some_and(|expiration| expiration <= now) {
return Err(CredentialsError::provider_error(std::io::Error::other(EXPIRED_REMOTE_TARGET_CREDENTIALS)));
}
Ok(self.credentials.clone())
}
}
impl fmt::Debug for RemoteTargetCredentialsProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RemoteTargetCredentialsProvider")
.field("temporary", &self.credentials.session_token().is_some())
.field("expiration", &self.credentials.expiry())
.finish()
}
}
impl ProvideCredentials for RemoteTargetCredentialsProvider {
fn provide_credentials<'a>(&'a self) -> future::ProvideCredentials<'a>
where
Self: 'a,
{
future::ProvideCredentials::ready(self.resolve_at(SystemTime::now()))
}
fn fallback_on_interrupt(&self) -> Option<SdkCredentials> {
self.resolve_at(SystemTime::now()).ok()
}
}
pub(crate) fn remote_sdk_credentials(credentials: &RemoteCredentials, now: SystemTime) -> Result<SdkCredentials, &'static str> {
if credentials.expiration.is_some() && credentials.session_token.is_none() {
return Err("remote target credential expiration requires a session token");
}
if credentials.expiration.is_some_and(|expiration| expiration <= now) {
return Err(EXPIRED_REMOTE_TARGET_CREDENTIALS);
}
let mut builder = SdkCredentials::builder()
.access_key_id(credentials.access_key.clone())
.secret_access_key(credentials.secret_key.clone())
.account_id(credentials.account_id.clone())
.provider_name("bucket_target_sys");
if let Some(session_token) = &credentials.session_token {
builder = builder.session_token(session_token.clone());
}
if let Some(expiration) = credentials.expiration {
builder = builder.expiry(expiration);
}
Ok(builder.build())
}
/// Appends a caller-identifying token to the SDK `User-Agent`. Runs after
/// signing: SigV4 excludes `user-agent` from the canonical request, so the
/// signature stays valid.
#[derive(Debug)]
struct UserAgentSuffixInterceptor {
suffix: &'static str,
}
impl Intercept for UserAgentSuffixInterceptor {
fn name(&self) -> &'static str {
"RustfsUserAgentSuffix"
}
fn modify_before_transmit(
&self,
context: &mut BeforeTransmitInterceptorContextMut<'_>,
_runtime_components: &RuntimeComponents,
_cfg: &mut ConfigBag,
) -> Result<(), BoxError> {
let headers = context.request_mut().headers_mut();
let user_agent = match headers.get(http::header::USER_AGENT.as_str()) {
Some(existing) => format!("{existing} {}", self.suffix),
None => self.suffix.to_string(),
};
headers.try_insert(http::header::USER_AGENT.as_str(), user_agent)?;
Ok(())
}
}
/// Builds the SDK config for `spec` without finalizing it, so callers can add
/// interceptors or (in tests) swap the HTTP client before `build()`.
pub(crate) async fn build_remote_s3_config(
spec: &RemoteS3EndpointSpec,
) -> Result<aws_sdk_s3::config::Builder, RemoteS3ClientError> {
let Some(credentials) = &spec.credentials else {
return Err(RemoteS3ClientError::MissingCredentials);
};
let creds = remote_sdk_credentials(credentials, SystemTime::now()).map_err(RemoteS3ClientError::Credentials)?;
let endpoint = spec.endpoint_url();
let parsed_endpoint = Url::parse(&endpoint).map_err(|err| RemoteS3ClientError::InvalidEndpoint(err.to_string()))?;
validate_remote_endpoint(&parsed_endpoint).map_err(RemoteS3ClientError::EndpointNotAllowed)?;
let mut config_builder = S3Config::builder()
.endpoint_url(endpoint)
.credentials_provider(SharedCredentialsProvider::new(RemoteTargetCredentialsProvider { credentials: creds }))
.region(SdkRegion::new(spec.region.clone()))
.behavior_version(aws_sdk_s3::config::BehaviorVersion::latest())
.request_checksum_calculation(replication_request_checksum_calculation())
.retry_config(spec.retry.retry_config());
if spec.path_style.force_path_style() {
config_builder = config_builder.force_path_style(true);
}
if let Some(http_client) = build_aws_s3_http_client_for_spec(spec).await? {
config_builder = config_builder.http_client(http_client);
}
if spec.connect_timeout.is_some() || spec.read_timeout.is_some() {
let mut timeouts = TimeoutConfig::builder();
if let Some(connect_timeout) = spec.connect_timeout {
timeouts = timeouts.connect_timeout(connect_timeout);
}
if let Some(read_timeout) = spec.read_timeout {
timeouts = timeouts.read_timeout(read_timeout);
}
config_builder = config_builder.timeout_config(timeouts.build());
}
if !spec.user_agent_suffix.is_empty() {
config_builder = config_builder.interceptor(UserAgentSuffixInterceptor {
suffix: spec.user_agent_suffix,
});
}
Ok(config_builder)
}
/// Builds an `aws_sdk_s3::Client` for `spec`, applying the outbound endpoint
/// gate, credential validation and the TLS transport selection.
pub async fn build_remote_s3_client(spec: &RemoteS3EndpointSpec) -> Result<S3Client, RemoteS3ClientError> {
Ok(S3Client::from_conf(build_remote_s3_config(spec).await?.build()))
}
#[derive(Debug)]
struct AcceptAnyServerCertVerifier;
impl rustls::client::danger::ServerCertVerifier for AcceptAnyServerCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls_pki_types::CertificateDer<'_>,
_intermediates: &[rustls_pki_types::CertificateDer<'_>],
_server_name: &rustls_pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls_pki_types::UnixTime,
) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
Ok(rustls::client::danger::ServerCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn verify_tls13_signature(
&self,
_message: &[u8],
_cert: &rustls_pki_types::CertificateDer<'_>,
_dss: &rustls::DigitallySignedStruct,
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
rustls::crypto::aws_lc_rs::default_provider()
.signature_verification_algorithms
.supported_schemes()
}
}
#[derive(Clone)]
struct TargetHyperHttpConnector<C> {
client: HyperClient<C, SdkBody>,
}
impl<C> fmt::Debug for TargetHyperHttpConnector<C> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TargetHyperHttpConnector")
.field("client", &"** hyper client **")
.finish()
}
}
impl<C> SmithyHttpConnector for TargetHyperHttpConnector<C>
where
C: Clone + Send + Sync + 'static,
C: Service<Uri>,
C::Response:
hyper::rt::Read + hyper::rt::Write + hyper_util::client::legacy::connect::Connection + Send + Sync + Unpin + 'static,
C::Future: Unpin + Send + 'static,
C::Error: Into<BoxError>,
{
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
let request = match request.try_into_http1x() {
Ok(request) => request,
Err(err) => return HttpConnectorFuture::ready(Err(ConnectorError::user(err.into()))),
};
let mut client = self.client.clone();
let fut = client.call(request);
HttpConnectorFuture::new(async move {
let response = fut
.await
.map_err(|err| ConnectorError::io(err.into()))?
.map(SdkBody::from_body_1_x);
HttpResponse::try_from(response).map_err(|err| ConnectorError::other(err.into(), None))
})
}
}
pub(crate) fn ensure_rustls_crypto_provider() {
if rustls::crypto::CryptoProvider::get_default().is_none() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
}
}
/// Env opt-in that re-enables loopback replication targets. Loopback (`127.0.0.1`,
/// `::1`, `localhost`) is a classic SSRF vector and stays rejected by default, but
/// single-host multi-instance dev setups and the e2e harness legitimately replicate
/// over loopback. Never set this in production.
const ALLOW_LOOPBACK_REPLICATION_TARGET_ENV: &str = "RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET";
fn loopback_replication_targets_allowed() -> bool {
std::env::var(ALLOW_LOOPBACK_REPLICATION_TARGET_ENV)
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
}
const REPLICATION_STREAMING_CHECKSUMS_ENV: &str = "RUSTFS_REPLICATION_STREAMING_CHECKSUMS";
/// Streaming trailer checksums make the SDK frame request bodies as
/// `aws-chunked`; a target that does not decode that framing stores the frames
/// verbatim, silently corrupting every replica while the transfer itself
/// succeeds (#6853). Plain signed payloads are the compatible default; the env
/// knob restores trailer checksums for fleets whose targets are all known to
/// decode them.
pub(crate) fn replication_request_checksum_calculation() -> RequestChecksumCalculation {
if std::env::var(REPLICATION_STREAMING_CHECKSUMS_ENV)
.map(|v| v.eq_ignore_ascii_case("true") || v == "1")
.unwrap_or(false)
{
RequestChecksumCalculation::WhenSupported
} else {
RequestChecksumCalculation::WhenRequired
}
}
/// Outbound gate for operator-configured remote endpoints (replication
/// targets, on-demand migration sources). See
/// `docs/operations/outbound-connection-policy.md`.
pub fn validate_remote_endpoint(url: &Url) -> Result<(), OutboundUrlError> {
validate_remote_endpoint_inner(url, loopback_replication_targets_allowed())
}
pub(crate) fn validate_remote_endpoint_inner(url: &Url, allow_loopback: bool) -> Result<(), OutboundUrlError> {
match validate_outbound_url(url) {
Ok(()) => Ok(()),
// Replication targets are trusted infrastructure the operator configures, and
// legitimately live on private networks, so private addresses are always allowed.
Err(OutboundUrlError::ForbiddenHost {
reason: "private address",
..
}) => Ok(()),
// Loopback is far higher SSRF risk, so it is allowed only under the explicit,
// off-by-default opt-in above (single-host multi-instance / the e2e harness).
Err(OutboundUrlError::ForbiddenHost {
reason: "loopback address" | "loopback host",
..
}) if allow_loopback => Ok(()),
Err(err) => Err(err),
}
}
pub(crate) fn build_insecure_aws_s3_http_client() -> SharedHttpClient {
ensure_rustls_crypto_provider();
let tls_config = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier))
.with_no_client_auth();
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls_config)
.https_or_http()
.enable_http1()
.enable_http2()
.build();
let mut client_builder = HyperClient::builder(TokioExecutor::new());
client_builder.pool_timer(TokioTimer::new());
let client = client_builder.build(https);
let connector = SharedHttpConnector::new(TargetHyperHttpConnector { client });
http_client_fn(move |_settings, _components| connector.clone())
}
fn validate_ca_pem_bundle(ca_cert_pem: &[u8]) -> Result<(), String> {
let certs = rustls_pki_types::CertificateDer::pem_slice_iter(ca_cert_pem)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| format!("invalid PEM encoding: {err}"))?;
if certs.is_empty() {
return Err("no certificates found".to_string());
}
// Smithy's rustls adapter defers parsing custom certificates and assumes
// they are valid when the HTTPS connector is built. Validate every DER
// certificate first so malformed configuration is reported rather than
// reaching an `expect` in the dependency.
let mut validation_store = rustls::RootCertStore::empty();
for cert in certs {
validation_store
.add(cert)
.map_err(|err| format!("invalid X.509 certificate: {err}"))?;
}
Ok(())
}
pub(crate) fn validate_target_ca_pem(ca_cert_pem: &str) -> Result<(), RemoteS3ClientError> {
validate_ca_pem_bundle(ca_cert_pem.as_bytes()).map_err(RemoteS3ClientError::InvalidCaPem)
}
pub(crate) fn compose_replication_trust_store(
certificate_bundles: impl IntoIterator<Item = Vec<u8>>,
) -> (smithy_tls::TrustStore, usize) {
// `TrustStore::default()` keeps the platform-native roots enabled. Target
// and RUSTFS_TLS_PATH certificates extend that baseline instead of
// replacing it with a target-specific trust island.
let mut trust_store = smithy_tls::TrustStore::default();
let mut custom_bundle_count = 0;
for pem in certificate_bundles {
trust_store.add_pem_certificate(pem);
custom_bundle_count += 1;
}
(trust_store, custom_bundle_count)
}
pub(crate) fn build_aws_s3_http_client_with_trust_store(
trust_store: smithy_tls::TrustStore,
) -> Result<SharedHttpClient, RemoteS3ClientError> {
let tls_context = smithy_tls::TlsContext::builder()
.with_trust_store(trust_store)
.build()
.map_err(|err| RemoteS3ClientError::InvalidCaPem(err.to_string()))?;
Ok(SmithyHttpClientBuilder::new()
.tls_provider(smithy_tls::Provider::rustls(smithy_tls::rustls_provider::CryptoMode::AwsLc))
.tls_context(tls_context)
.build_https())
}
pub(crate) async fn load_tls_path_ca_bundles(tls_dir: &Path, trust_leaf_cert_as_ca: bool) -> Vec<Vec<u8>> {
let mut certificate_bundles = Vec::new();
let ca_path = tls_dir.join(RUSTFS_CA_CERT);
match tokio::fs::read(&ca_path).await {
Ok(pem) => match validate_ca_pem_bundle(&pem) {
Ok(()) => certificate_bundles.push(pem),
Err(err) => warn!("ignoring invalid custom CA bundle {:?} for replication client: {}", ca_path, err),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!("failed to read custom CA bundle {:?} for replication client: {}", ca_path, e),
}
if trust_leaf_cert_as_ca {
let leaf_cert_path = tls_dir.join(RUSTFS_TLS_CERT);
match tokio::fs::read(&leaf_cert_path).await {
Ok(pem) => match validate_ca_pem_bundle(&pem) {
Ok(()) => certificate_bundles.push(pem),
Err(err) => warn!(
"ignoring invalid leaf certificate {:?} for replication client trust store: {}",
leaf_cert_path, err
),
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => warn!("failed to read leaf cert {:?} for replication client trust store: {}", leaf_cert_path, e),
}
}
certificate_bundles
}
async fn load_configured_tls_ca_bundles() -> Vec<Vec<u8>> {
let tls_path = rustfs_utils::get_env_str(rustfs_config::ENV_RUSTFS_TLS_PATH, rustfs_config::DEFAULT_RUSTFS_TLS_PATH);
if tls_path.is_empty() {
return Vec::new();
}
load_tls_path_ca_bundles(
Path::new(&tls_path),
rustfs_utils::get_env_bool(ENV_TRUST_LEAF_CERT_AS_CA, DEFAULT_TRUST_LEAF_CERT_AS_CA),
)
.await
}
pub(crate) async fn build_aws_s3_http_client_from_target_ca_pem(
ca_cert_pem: &str,
) -> Result<SharedHttpClient, RemoteS3ClientError> {
validate_target_ca_pem(ca_cert_pem)?;
let mut certificate_bundles = load_configured_tls_ca_bundles().await;
certificate_bundles.push(ca_cert_pem.as_bytes().to_vec());
let (trust_store, _) = compose_replication_trust_store(certificate_bundles);
build_aws_s3_http_client_with_trust_store(trust_store)
}
/// Selects the HTTP client for `spec`: `None` keeps the SDK default (plain
/// HTTP, or HTTPS with platform roots when no custom trust is configured).
pub(crate) async fn build_aws_s3_http_client_for_spec(
spec: &RemoteS3EndpointSpec,
) -> Result<Option<SharedHttpClient>, RemoteS3ClientError> {
if !spec.secure {
return Ok(None);
}
if spec.skip_tls_verify {
return Ok(Some(build_insecure_aws_s3_http_client()));
}
if let Some(ca_cert_pem) = spec.custom_ca_pem() {
return build_aws_s3_http_client_from_target_ca_pem(ca_cert_pem).await.map(Some);
}
Ok(build_aws_s3_http_client_from_tls_path().await)
}
async fn build_aws_s3_http_client_from_tls_path() -> Option<SharedHttpClient> {
let certificate_bundles = load_configured_tls_ca_bundles().await;
if certificate_bundles.is_empty() {
return None;
}
let (trust_store, _) = compose_replication_trust_store(certificate_bundles);
match build_aws_s3_http_client_with_trust_store(trust_store) {
Ok(client) => Some(client),
Err(e) => {
warn!("failed to build AWS SDK TLS context for replication client: {}", e);
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use aws_smithy_runtime_api::http::StatusCode as SmithyStatusCode;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
fn spec(endpoint: &str, secure: bool) -> RemoteS3EndpointSpec {
RemoteS3EndpointSpec {
endpoint: endpoint.to_string(),
secure,
region: "us-east-1".to_string(),
path_style: PathStyle::Auto,
credentials: Some(RemoteCredentials {
access_key: "access".to_string(),
secret_key: "secret".to_string(),
session_token: None,
expiration: None,
account_id: String::new(),
}),
skip_tls_verify: false,
ca_cert_pem: None,
connect_timeout: None,
read_timeout: None,
retry: RemoteS3RetryPolicy::Disabled,
user_agent_suffix: "",
}
}
type RecordedHeaders = Arc<Mutex<Vec<Vec<(String, String)>>>>;
#[derive(Clone, Debug)]
struct RecordingHeaderConnector {
request_headers: RecordedHeaders,
}
impl SmithyHttpConnector for RecordingHeaderConnector {
fn call(&self, request: HttpRequest) -> HttpConnectorFuture {
self.request_headers
.lock()
.expect("recorded header lock should not be poisoned")
.push(
request
.headers()
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
);
HttpConnectorFuture::ready(Ok(HttpResponse::new(
SmithyStatusCode::try_from(200_u16).expect("200 should be a valid response status"),
SdkBody::empty(),
)))
}
}
#[tokio::test]
async fn build_remote_s3_client_rejects_loopback_and_metadata_endpoints() {
// Default (no loopback opt-in): loopback in IPv4, IPv6 and hostname
// forms plus the metadata endpoint all return the typed gate error.
for endpoint in ["127.0.0.1:9000", "[::1]:9000", "localhost:9000", "169.254.169.254"] {
let err = build_remote_s3_client(&spec(endpoint, false))
.await
.err()
.unwrap_or_else(|| panic!("{endpoint} must be rejected by the outbound gate"));
assert!(
matches!(err, RemoteS3ClientError::EndpointNotAllowed(OutboundUrlError::ForbiddenHost { .. })),
"{endpoint}: unexpected error {err:?}"
);
assert!(err.to_string().contains("not allowed"), "{endpoint}: {err}");
}
}
#[tokio::test]
async fn build_remote_s3_client_allows_private_and_public_endpoints() {
for endpoint in ["10.0.0.1:9000", "192.168.1.20", "s3.example.com"] {
build_remote_s3_client(&spec(endpoint, false))
.await
.unwrap_or_else(|err| panic!("{endpoint} should be allowed: {err}"));
}
}
#[tokio::test]
async fn build_remote_s3_client_requires_credentials() {
let mut spec = spec("s3.example.com", true);
spec.credentials = None;
let err = build_remote_s3_client(&spec)
.await
.expect_err("missing credentials must be a typed error");
assert!(matches!(err, RemoteS3ClientError::MissingCredentials));
}
#[tokio::test]
async fn build_remote_s3_client_rejects_expiration_without_session_token() {
let mut spec = spec("s3.example.com", true);
spec.credentials
.as_mut()
.expect("spec fixture carries credentials")
.expiration = Some(SystemTime::now() + Duration::from_secs(3_600));
let err = build_remote_s3_client(&spec)
.await
.expect_err("expiration without session token must be rejected");
assert_eq!(err.to_string(), "remote target credential expiration requires a session token");
}
#[tokio::test]
async fn build_remote_s3_client_rejects_invalid_custom_ca_pem() {
let mut spec = spec("192.168.1.10:9000", true);
spec.ca_cert_pem = Some("not a pem".to_string());
let err = build_remote_s3_client(&spec)
.await
.expect_err("invalid custom CA PEM must be rejected");
assert!(matches!(err, RemoteS3ClientError::InvalidCaPem(_)));
assert!(err.to_string().contains("invalid target CA PEM"));
}
/// Answers every request with a retryable 503 and counts the wire
/// requests one logical call produced.
#[derive(Clone, Debug)]
struct CountingUnavailableConnector {
wire_requests: Arc<AtomicUsize>,
}
impl SmithyHttpConnector for CountingUnavailableConnector {
fn call(&self, _request: HttpRequest) -> HttpConnectorFuture {
self.wire_requests.fetch_add(1, Ordering::SeqCst);
HttpConnectorFuture::ready(Ok(HttpResponse::new(
SmithyStatusCode::try_from(503_u16).expect("503 should be a valid response status"),
SdkBody::empty(),
)))
}
}
async fn wire_requests_for_one_failed_call(retry: RemoteS3RetryPolicy) -> usize {
let wire_requests = Arc::new(AtomicUsize::new(0));
let connector = SharedHttpConnector::new(CountingUnavailableConnector {
wire_requests: Arc::clone(&wire_requests),
});
let http_client = http_client_fn(move |_settings, _components| connector.clone());
let mut spec = spec("s3.example.com", true);
spec.retry = retry;
let config = build_remote_s3_config(&spec)
.await
.expect("spec should build")
.http_client(http_client)
.build();
S3Client::from_conf(config)
.head_bucket()
.bucket("bucket")
.send()
.await
.expect_err("a 503 must fail the call");
wire_requests.load(Ordering::SeqCst)
}
#[tokio::test(start_paused = true)]
async fn retry_policy_decides_how_many_wire_requests_one_call_costs() {
assert_eq!(
wire_requests_for_one_failed_call(RemoteS3RetryPolicy::Disabled).await,
1,
"a disabled policy must not amplify one logical call"
);
assert_eq!(
wire_requests_for_one_failed_call(REPLICATION_TARGET_RETRY_POLICY).await,
3,
"replication targets keep the three-attempt SDK default"
);
assert_eq!(
wire_requests_for_one_failed_call(RemoteS3RetryPolicy::Standard { max_attempts: 0 }).await,
1,
"a zero attempt budget is clamped to the initial request"
);
}
#[test]
fn path_style_auto_and_path_force_path_style() {
assert!(PathStyle::Auto.force_path_style());
assert!(PathStyle::Path.force_path_style());
assert!(!PathStyle::VirtualHost.force_path_style());
}
#[test]
fn remote_credentials_debug_redacts_secrets() {
let credentials = RemoteCredentials {
access_key: "access".to_string(),
secret_key: "very-secret".to_string(),
session_token: Some("session-token".to_string()),
expiration: None,
account_id: String::new(),
};
let rendered = format!("{credentials:?}");
assert!(rendered.contains("access"));
assert!(!rendered.contains("very-secret"));
assert!(!rendered.contains("session-token"));
}
#[tokio::test]
async fn user_agent_suffix_is_appended_after_signing() {
let request_headers: RecordedHeaders = Arc::new(Mutex::new(Vec::new()));
let connector = SharedHttpConnector::new(RecordingHeaderConnector {
request_headers: Arc::clone(&request_headers),
});
let http_client = http_client_fn(move |_settings, _components| connector.clone());
let mut spec = spec("s3.example.com", true);
spec.user_agent_suffix = "RustFS-Test/0.0";
spec.connect_timeout = Some(Duration::from_secs(5));
spec.read_timeout = Some(Duration::from_secs(5));
let config = build_remote_s3_config(&spec)
.await
.expect("spec should build")
.http_client(http_client)
.build();
S3Client::from_conf(config)
.head_bucket()
.bucket("bucket")
.send()
.await
.expect("recording connector should accept the request");
let recorded = request_headers.lock().expect("recorded header lock should not be poisoned");
let headers = &recorded[0];
let user_agent = headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("user-agent"))
.map(|(_, v)| v.as_str())
.expect("SDK request must carry a user-agent");
assert!(user_agent.ends_with(" RustFS-Test/0.0"), "user-agent was {user_agent}");
assert!(user_agent.starts_with("aws-sdk-rust/"), "SDK identity must be preserved: {user_agent}");
assert!(
headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
"request must still be signed"
);
}
}
+2 -1
View File
@@ -53,12 +53,12 @@ pub use replication_config_boundary::{
replication_target_arns, should_remove_replication_target, site_replication_rule_deployment_id,
unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns,
};
pub(crate) use replication_filemeta_boundary::version_purge_statuses_map;
pub use replication_filemeta_boundary::{
MrfOpKind, MrfReplicateEntry, REPLICATE_INCOMING_DELETE, ReplicateDecision, ReplicateObjectInfo, ReplicationState,
ReplicationStatusType, ReplicationType, VersionPurgeStatusType, replication_state_to_filemeta,
replication_status_to_filemeta, replication_statuses_map, version_purge_status_to_filemeta,
};
pub(crate) use replication_filemeta_boundary::{ReplicationGenerationSnapshot, version_purge_statuses_map};
pub(crate) use replication_filemeta_boundary::{
replication_state_from_filemeta, replication_status_from_filemeta, version_purge_status_from_filemeta,
};
@@ -88,4 +88,5 @@ pub use replication_state::{ReplicationStats, RuntimeReplicationTargetBacklog};
pub use replication_stats_boundary::{BucketReplicationStat, BucketReplicationStats, BucketStats, InQueueMetric, XferStats};
pub use replication_storage_boundary::{ReplicationObjectIO, ReplicationStorage};
pub use replication_target_boundary::SsecPassthroughCapability;
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
@@ -15,7 +15,7 @@
pub use rustfs_replication::{MrfOpKind, MrfReplicateEntry};
pub(crate) use rustfs_replication::{
REPLICATE_EXISTING, REPLICATE_HEAL_DELETE, ReplicateTargetDecision, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction,
ReplicationWorkerOperation, ResyncDecision, get_replication_state, parse_replicate_decision,
ReplicationGenerationSnapshot, ReplicationWorkerOperation, ResyncDecision, get_replication_state, parse_replicate_decision,
replicate_decision_for_admitted_targets, target_reset_header, version_purge_statuses_map,
};
pub use rustfs_replication::{
@@ -575,7 +575,7 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
let mut sopts = opts.clone();
sopts.target_arn = arn.clone();
let replicate = cfg.replicate(&sopts) && mopts.metadata_target_is_eligible(&arn);
let replicate = metadata_target_should_replicate(&cfg, &sopts, &mopts, &arn);
let synchronous = if let Some(cli) = cli { cli.replicate_sync } else { false };
dsc.set(ReplicateTargetDecision::new(arn, replicate, synchronous));
@@ -584,6 +584,15 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
dsc
}
fn metadata_target_should_replicate(
cfg: &ReplicationConfiguration,
opts: &ObjectOpts,
mopts: &MustReplicateOptions,
arn: &str,
) -> bool {
cfg.replicate(opts) && mopts.metadata_target_is_eligible(arn)
}
#[cfg(test)]
mod tests {
use s3s::dto::{
@@ -613,6 +622,46 @@ mod tests {
}
}
#[test]
fn metadata_replication_requires_both_current_rule_match_and_historical_admission() {
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
let mut rule = replication_rule();
rule.destination.bucket = arn.to_string();
rule.filter = Some(ReplicationRuleFilter {
prefix: Some("admitted/".to_string()),
..Default::default()
});
let cfg = ReplicationConfiguration {
role: String::new(),
rules: vec![rule],
};
let mut metadata = HashMap::new();
rustfs_utils::http::insert_str(&mut metadata, rustfs_utils::http::SUFFIX_REPLICATION_STATUS, format!("{arn}=PENDING;"));
let admitted = MustReplicateOptions::new(&metadata, String::new(), ReplicationType::Metadata, false);
let matching = ObjectOpts {
name: "admitted/object".to_string(),
target_arn: arn.to_string(),
..Default::default()
};
assert!(metadata_target_should_replicate(&cfg, &matching, &admitted, arn));
let rule_mismatch = ObjectOpts {
name: "outside/object".to_string(),
target_arn: arn.to_string(),
..Default::default()
};
assert!(
!metadata_target_should_replicate(&cfg, &rule_mismatch, &admitted, arn),
"historical admission must not bypass the current replication rule"
);
let never_admitted = MustReplicateOptions::new(&HashMap::new(), String::new(), ReplicationType::Metadata, false);
assert!(
!metadata_target_should_replicate(&cfg, &matching, &never_admitted, arn),
"a current rule match must not create historical admission"
);
}
#[test]
fn replication_config_empty_and_replicate_follow_config() {
let empty = ReplicationConfig::default();
@@ -22,6 +22,7 @@ pub(crate) use rustfs_replication::{
delete_marker_purge_version_id, delete_replication_creates_marker, delete_replication_missing_source_decision,
delete_replication_object_opts, heal_uses_delete_replication_path, is_object_lock_denied_delete,
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match,
replication_multipart_complete_actual_size, replication_multipart_part_plan, resync_existing_delete_replication_info,
resync_target_for_object, should_retry_delete_marker_purge, single_part_replica_etag_mismatch, target_delete_version_id,
replication_multipart_complete_actual_size, replication_multipart_part_plan, replication_single_put_size_error,
resync_existing_delete_replication_info, resync_target_for_object, should_retry_delete_marker_purge,
single_part_replica_etag_mismatch, target_delete_version_id,
};
@@ -52,7 +52,6 @@ use super::runtime_boundary as runtime_sources;
use futures_util::stream::{self, StreamExt};
use metrics::{counter, histogram};
use rustfs_utils::hash::HashAlgorithm;
use rustfs_utils::http::{SUFFIX_REPLICATION_TIMESTAMP, get_str};
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::sync::Arc;
@@ -939,7 +938,11 @@ async fn replay_mrf_object_entry<S: ReplicationStorage>(
Some(queue_replication_heal(&entry.bucket, oi, entry.retry_count.max(0) as u32).await)
} else {
let roi = admitted_mrf_replicate_object(oi, entry, entry.op.replication_type());
if replicate_object_with_outcome(roi, storage.clone()).await.1 {
if replicate_object_with_outcome(roi, storage.clone())
.await
.1
.consumes_mrf_entry()
{
Some(ReplicationQueueAdmission::Queued)
} else {
Some(ReplicationQueueAdmission::Missed)
@@ -978,7 +981,11 @@ async fn replay_mrf_metadata_entry<S: ReplicationStorage>(
Some(queue_replication_metadata(&entry.bucket, oi, entry.retry_count.max(0) as u32).await)
} else {
let roi = admitted_mrf_replicate_object(oi, entry, ReplicationType::Metadata);
if replicate_object_with_outcome(roi, storage.clone()).await.1 {
if replicate_object_with_outcome(roi, storage.clone())
.await
.1
.consumes_mrf_entry()
{
Some(ReplicationQueueAdmission::Queued)
} else {
Some(ReplicationQueueAdmission::Missed)
@@ -2978,8 +2985,11 @@ fn replicate_object_info_from_object_info(
) -> ReplicateObjectInfo {
let tgt_statuses = replication_statuses_map(&oi.replication_status_internal.clone().unwrap_or_default());
let purge_statuses = version_purge_statuses_map(&oi.version_purge_status_internal.clone().unwrap_or_default());
let tm = get_str(&oi.user_defined, SUFFIX_REPLICATION_TIMESTAMP)
.map(|v| OffsetDateTime::parse(&v, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH));
let replication_generation = oi.replication_generation_snapshot();
let tm = replication_generation
.timestamp
.as_deref()
.map(|value| OffsetDateTime::parse(value, &Rfc3339).unwrap_or(OffsetDateTime::UNIX_EPOCH));
let mut rstate = oi.replication_state();
rstate.replicate_decision_str = dsc.to_string();
let asz = oi.get_actual_size_or_physical();
@@ -3006,6 +3016,7 @@ fn replicate_object_info_from_object_info(
target_statuses: tgt_statuses,
target_purge_statuses: purge_statuses,
replication_timestamp: tm,
replication_generation,
user_tags: (*oi.user_tags).clone(),
checksum,
retry_count: 0,
@@ -17,6 +17,8 @@ use super::replication_config_boundary::{ObjectOpts, ReplicationConfigurationExt
use super::replication_config_store::ReplicationConfigStore;
use super::replication_error_boundary::{Error, Result, is_err_object_not_found, is_err_version_not_found};
use super::replication_event_sink::{EventArgs, send_event, send_local_event};
#[cfg(test)]
use super::replication_filemeta_boundary::ReplicationGenerationSnapshot;
use super::replication_filemeta_boundary::{
REPLICATE_EXISTING, ReplicateDecision, ReplicateObjectInfo, ReplicatedInfos, ReplicatedTargetInfo, ReplicationAction,
ReplicationState, ReplicationStatusType, ReplicationType, VersionPurgeStatusType, get_replication_state,
@@ -32,8 +34,9 @@ use super::replication_object_decision_boundary::{
MustReplicateOptions, ReplicationMultipartPartInput, delete_marker_purge_mrf_entry, delete_marker_purge_version_id,
delete_replication_creates_marker, heal_uses_delete_replication_path, is_object_lock_denied_delete,
is_retryable_delete_replication_head_error, is_version_delete_replication, replicate_delete_outcome, replication_etags_match,
replication_multipart_complete_actual_size, replication_multipart_part_plan, resync_existing_delete_replication_info,
should_retry_delete_marker_purge, single_part_replica_etag_mismatch, target_delete_version_id,
replication_multipart_complete_actual_size, replication_multipart_part_plan, replication_single_put_size_error,
resync_existing_delete_replication_info, should_retry_delete_marker_purge, single_part_replica_etag_mismatch,
target_delete_version_id,
};
use super::replication_queue_boundary::{DeletedObjectReplicationInfo, ReplicationQueueAdmission};
use super::replication_resync_boundary::ResyncStatusType;
@@ -46,12 +49,13 @@ use super::replication_resync_boundary::{
};
#[cfg(test)]
use super::replication_resync_boundary::{RESYNC_META_FORMAT, RESYNC_META_VERSION, WIRE_ZERO_TIME_UNIX};
#[cfg(test)]
use super::replication_storage_boundary::ReplicationDeletedObject;
use super::replication_storage_boundary::{
AdvancedGetOptions, EcstoreObjectOperations, GetObjectReader, HTTPPreconditions, HTTPRangeSpec, ObjectInfo, ObjectOptions,
ObjectToDelete, ReplicationObjectIO, ReplicationStorage, StatObjectOptions, StorageObjectInfoOrErr, WalkOptions,
ObjectToDelete, ReplicationObjectIO, ReplicationStatusWritebackCondition, ReplicationStatusWritebackMode, ReplicationStorage,
StatObjectOptions, StorageObjectInfoOrErr, WalkOptions,
};
#[cfg(test)]
use super::replication_storage_boundary::{NamespaceLockFence, NamespaceLockSignalTestFence, ReplicationDeletedObject};
use super::replication_target_boundary::{
ERR_REPLICATION_SSEC_PASSTHROUGH_UNSUPPORTED, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions,
RemotePutObjectResponse, ReplicationTargetStore, S3ClientError, SsecPassthroughCapability, SsecPassthroughGate, TargetClient,
@@ -88,6 +92,7 @@ use std::collections::{HashMap, HashSet};
use std::fmt::Display;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::time::Instant;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tokio::io::AsyncRead;
@@ -118,6 +123,7 @@ const EVENT_DELETE_MARKER_PURGE_FAILED: &str = "replication_delete_marker_purge_
const EVENT_DELETE_MARKER_PURGE_MRF: &str = "replication_delete_marker_purge_mrf";
const METRIC_DELETE_MARKER_PURGE_TOTAL: &str = "rustfs_replication_delete_marker_purge_total";
const EVENT_REPLICATION_VERSION_IDENTITY_DRIFT: &str = "replication_version_identity_drift";
const EVENT_REPLICATION_OBJECT_FAILED: &str = "replication_object_failed";
const EVENT_REPLICATION_PURGE_OBJECT_LOCK_DENIED: &str = "replication_purge_object_lock_denied";
#[allow(
@@ -190,11 +196,19 @@ fn metadata_requires_existing_target(op_type: ReplicationType, object_info: &Obj
const METRIC_VERSION_IDENTITY_DRIFT_TOTAL: &str = "rustfs_replication_version_identity_drift_total";
/// Targets that already produced a version-identity-drift warning this
/// process lifetime, by ARN. Deduping is advisory only (the metric still
/// counts every drifting PUT), so a reconfigured target re-warning only
/// after a restart is acceptable.
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashSet<String>>> = LazyLock::new(|| StdMutex::new(HashSet::new()));
/// How long a target stays quiet after reporting version-identity drift.
///
/// This used to be a plain "once per ARN per process": one line ever, which on
/// a long-lived server meant the single most important diagnostic for a
/// non-converging generic S3 target scrolled away hours before anyone looked
/// (rustfs#6822). Re-arming on an interval keeps the log bounded while leaving
/// the condition discoverable in any recent window.
const VERSION_IDENTITY_DRIFT_LOG_INTERVAL: TokioDuration = TokioDuration::from_secs(600);
/// When each target last reported version-identity drift, by ARN. Throttling is
/// advisory only — the metric still counts every drifting PUT.
static VERSION_IDENTITY_WARNED_ARNS: LazyLock<StdMutex<HashMap<String, Instant>>> =
LazyLock::new(|| StdMutex::new(HashMap::new()));
/// Version purges the peer denied under object lock (#6850). A RustFS peer
/// with the replicated-purge GOVERNANCE exemption
@@ -322,20 +336,39 @@ fn audit_target_version_identity(tgt_client: &TargetClient, source_version_id: &
return;
}
counter!(METRIC_VERSION_IDENTITY_DRIFT_TOTAL).increment(1);
if !version_identity_drift_log_due(&tgt_client.arn, Instant::now()) {
return;
}
// `error`, not `warn`: the target silently refuses the addressing scheme
// every version-addressed delete and heal on it depends on, so replication
// to it can never converge. At `warn` this sat below `DEFAULT_LOG_LEVEL`
// and no default deployment ever saw the one line that explains why a
// purged version is still on the target (rustfs#6822).
error!(
event = EVENT_REPLICATION_VERSION_IDENTITY_DRIFT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
arn = %tgt_client.arn,
endpoint = %tgt_client.endpoint,
sent_version_id = %source_version_id,
assigned_version_id = assigned_version_id.unwrap_or("<none>"),
"Replication target does not adopt source version ids; version-addressed replication cannot converge (run ?replication-check for details)"
);
}
/// Whether this ARN's version-identity drift is due to be logged again at
/// `now`, re-arming the throttle when it is. Split out from the audit so the
/// interval policy is testable without a target client.
fn version_identity_drift_log_due(arn: &str, now: Instant) -> bool {
let mut warned = VERSION_IDENTITY_WARNED_ARNS
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if warned.insert(tgt_client.arn.clone()) {
warn!(
event = EVENT_REPLICATION_VERSION_IDENTITY_DRIFT,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
arn = %tgt_client.arn,
endpoint = %tgt_client.endpoint,
sent_version_id = %source_version_id,
assigned_version_id = assigned_version_id.unwrap_or("<none>"),
"Replication target does not adopt source version ids; version-addressed replication cannot converge (run ?replication-check for details)"
);
match warned.get(arn) {
Some(last) if now.duration_since(*last) < VERSION_IDENTITY_DRIFT_LOG_INTERVAL => false,
_ => {
warned.insert(arn.to_string(), now);
true
}
}
}
@@ -1595,6 +1628,7 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
let mut replication_state = oi.replication_state();
replication_state.replicate_decision_str = dsc.to_string();
let actual_size = oi.get_actual_size_or_physical();
let replication_generation = oi.replication_generation_snapshot();
Ok(ReplicateObjectInfo {
name: oi.name.clone(),
@@ -1617,6 +1651,7 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
target_statuses,
target_purge_statuses,
replication_timestamp: None,
replication_generation,
ssec: replication_object_is_ssec_encrypted(&user_defined),
user_tags: (*oi.user_tags).clone(),
checksum: oi.checksum.clone(),
@@ -2050,10 +2085,13 @@ pub(crate) async fn replicate_delete_with_outcome<S: ReplicationStorage>(
}
}
let delete_version_id = dobj.delete_object.version_id.map(|v| v.to_string());
note_replication_terminal_failure(&bucket, &dobj.delete_object.object_name, delete_version_id.as_deref(), &rinfos);
let mut drs = get_replication_state(
&rinfos,
&dobj.delete_object.replication_state.clone().unwrap_or_default(),
dobj.delete_object.version_id.map(|v| v.to_string()),
delete_version_id,
);
if replication_status != prev_status {
drs.replication_timestamp = Some(OffsetDateTime::now_utc());
@@ -2898,10 +2936,125 @@ pub async fn replicate_object<S: ReplicationStorage>(roi: ReplicateObjectInfo, s
replicate_object_with_outcome(roi, storage).await.0
}
enum ReplicationStatePersistOutcome {
Updated,
Superseded,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ReplicationAttemptDisposition {
Persisted,
Superseded,
Retry,
}
impl ReplicationAttemptDisposition {
pub(crate) fn consumes_mrf_entry(self) -> bool {
matches!(self, Self::Persisted | Self::Superseded)
}
}
fn replication_attempt_preflight(roi: &ReplicateObjectInfo) -> Option<(ReplicationState, ReplicationAttemptDisposition)> {
roi.replication_generation
.invalid
.then(|| (roi.replication_state.clone().unwrap_or_default(), ReplicationAttemptDisposition::Retry))
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ReplicationTerminalPublication {
emit_terminal_failure: bool,
emit_event: bool,
update_transition_stats: bool,
update_same_state_failure_stats: bool,
}
fn replication_terminal_publication(
suppress_terminal_publication: bool,
state_update_needed: bool,
attempt_status: ReplicationStatusType,
previous_internal_matches_attempt: bool,
) -> ReplicationTerminalPublication {
if suppress_terminal_publication {
return ReplicationTerminalPublication::default();
}
ReplicationTerminalPublication {
emit_terminal_failure: true,
emit_event: true,
update_transition_stats: state_update_needed,
update_same_state_failure_stats: attempt_status != ReplicationStatusType::Completed && previous_internal_matches_attempt,
}
}
fn replication_status_writeback_mode(state_update_needed: bool) -> ReplicationStatusWritebackMode {
if state_update_needed {
ReplicationStatusWritebackMode::Update
} else {
ReplicationStatusWritebackMode::ValidateOnly
}
}
/// Publish a worker's status with a storage-enforced compare-and-set token.
/// `put_object_metadata` checks the token while it owns the object write lock,
/// avoiding both a read/write race and any need to hold a hot object lock
/// across network I/O.
fn replication_status_writeback_options(
roi: &ReplicateObjectInfo,
replication_lock_guard: &rustfs_lock::NamespaceLockGuard,
new_replication_internal: Option<&String>,
mode: ReplicationStatusWritebackMode,
) -> ObjectOptions {
let mut eval_metadata = HashMap::new();
if let Some(status) = new_replication_internal {
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_STATUS, status.clone());
}
let mut write_opts = ObjectOptions {
version_id: roi.version_id.map(|version_id| version_id.to_string()),
eval_metadata: Some(eval_metadata),
replication_status_writeback: Some(Box::new(ReplicationStatusWritebackCondition {
expected_generation: roi.replication_generation.clone(),
mode,
})),
..Default::default()
};
// The remote transfer runs under a renewable replication namespace lock.
// Carry that guard's loss signal into the storage commit so a worker whose
// lease expired while it was doing remote I/O cannot publish over a newer
// worker for the same generation.
write_opts.add_namespace_lock_guard(replication_lock_guard);
write_opts
}
async fn persist_replication_state_if_current<S: ReplicationStorage>(
roi: &ReplicateObjectInfo,
storage: &Arc<S>,
replication_lock_guard: &rustfs_lock::NamespaceLockGuard,
new_replication_internal: Option<&String>,
mode: ReplicationStatusWritebackMode,
object_info: &mut ObjectInfo,
) -> Result<ReplicationStatePersistOutcome> {
let write_opts = replication_status_writeback_options(roi, replication_lock_guard, new_replication_internal, mode);
match storage.put_object_metadata(&roi.bucket, &roi.name, &write_opts).await {
Ok(updated) => {
*object_info = updated;
Ok(ReplicationStatePersistOutcome::Updated)
}
Err(Error::PreconditionFailed) => Ok(ReplicationStatePersistOutcome::Superseded),
Err(error) => Err(error),
}
}
pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
roi: ReplicateObjectInfo,
storage: Arc<S>,
) -> (ReplicationState, bool) {
) -> (ReplicationState, ReplicationAttemptDisposition) {
// Conflicting compatibility aliases, empty opaque timestamps, and invalid
// mutation UUIDs are corruption, not evidence that another generation
// superseded this task. Fail before any target I/O and keep the MRF entry
// retryable instead of repeatedly transmitting and then acknowledging it.
if let Some(outcome) = replication_attempt_preflight(&roi) {
return outcome;
}
let bucket = roi.bucket.clone();
let object = roi.name.clone();
@@ -2930,10 +3083,10 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return (roi.replication_state.unwrap_or_default(), false);
return (roi.replication_state.unwrap_or_default(), ReplicationAttemptDisposition::Retry);
}
};
let _obj_lock_guard = match obj_ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
let obj_lock_guard = match obj_ns_lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
Ok(g) => g,
Err(e) => {
debug!(
@@ -2953,7 +3106,7 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return (roi.replication_state.unwrap_or_default(), false);
return (roi.replication_state.unwrap_or_default(), ReplicationAttemptDisposition::Retry);
}
};
@@ -3023,70 +3176,99 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
}
}
let version_id = roi.version_id.map(|v| v.to_string());
let previous_state = roi.replication_state.clone().unwrap_or_default();
let merged_state = get_replication_state(&rinfos, &previous_state, roi.version_id.map(|v| v.to_string()));
let replication_status = merged_state.composite_replication_status();
let mut merged_state = get_replication_state(&rinfos, &previous_state, version_id.clone());
let mut replication_status = merged_state.composite_replication_status();
let new_replication_internal = merged_state.replication_status_internal.clone();
let mut object_info = roi.to_object_info();
let mut state_persisted = true;
let mut disposition = ReplicationAttemptDisposition::Persisted;
let mut suppress_terminal_publication = false;
let state_update_needed = roi.replication_status_internal != new_replication_internal || rinfos.replication_resynced();
let writeback_mode = replication_status_writeback_mode(state_update_needed);
if roi.replication_status_internal != new_replication_internal || rinfos.replication_resynced() {
let mut eval_metadata = HashMap::new();
if let Some(ref s) = new_replication_internal {
insert_str(&mut eval_metadata, SUFFIX_REPLICATION_STATUS, s.clone());
}
let popts = ObjectOptions {
version_id: roi.version_id.map(|v| v.to_string()),
eval_metadata: Some(eval_metadata),
..Default::default()
};
match storage.put_object_metadata(&bucket, &object, &popts).await {
Ok(u) => object_info = u,
Err(e) => {
state_persisted = false;
// Persisting the resynced replication status failed. Don't swallow
// it silently — the object's on-disk status now disagrees with the
// resync result and needs operator visibility (backlog#799 B23).
warn!(
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
error = %e,
"Failed to persist resynced replication status metadata"
);
}
}
if let Some(stats) = runtime_sources::replication_stats() {
for tgt in &rinfos.targets {
if tgt.replication_status != tgt.prev_replication_status {
stats
.update(&bucket, tgt, tgt.replication_status.clone(), tgt.prev_replication_status.clone())
.await;
match persist_replication_state_if_current(
&roi,
&storage,
&obj_lock_guard,
new_replication_internal.as_ref(),
writeback_mode,
&mut object_info,
)
.await
{
Ok(ReplicationStatePersistOutcome::Updated) => {}
Ok(ReplicationStatePersistOutcome::Superseded) => {
// A tag/retention/legal-hold mutation committed while this
// worker was in flight. Its PENDING state is authoritative and
// must remain discoverable by the queue/MRF/scanner after a
// crash or missed admission. Return that newer state to sync
// callers and leave its worker to publish the terminal status.
suppress_terminal_publication = true;
disposition = ReplicationAttemptDisposition::Superseded;
let read_opts = ObjectOptions {
version_id: roi.version_id.map(|version_id| version_id.to_string()),
..Default::default()
};
match storage.get_object_info(&bucket, &object, &read_opts).await {
Ok(current) => {
object_info = current;
merged_state = object_info.replication_state();
replication_status = merged_state.composite_replication_status();
}
Err(error) => {
// The CAS result is authoritative: a best-effort refetch
// failure must not turn a superseded task back into a
// retry that can publish the stale generation later.
debug!(
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
error = %error,
reason = "source_snapshot_refetch_failed_after_superseded",
"Could not refresh source state after skipping stale replication status update"
);
}
}
debug!(
event = EVENT_RESYNC_STATUS_UPDATE_SKIPPED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
reason = "source_replication_snapshot_superseded",
"Skipped stale replication status update"
);
}
Err(e) => {
disposition = ReplicationAttemptDisposition::Retry;
suppress_terminal_publication = true;
// Persisting the resynced replication status failed. Don't swallow
// it silently — the object's on-disk status now disagrees with the
// resync result and needs operator visibility (backlog#799 B23).
warn!(
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
error = %e,
"Failed to persist resynced replication status metadata"
);
}
}
let event_name = if replication_status == ReplicationStatusType::Completed {
EventName::ObjectReplicationComplete.to_string()
} else {
EventName::ObjectReplicationFailed.to_string()
};
let publication = replication_terminal_publication(
suppress_terminal_publication,
state_update_needed,
rinfos.replication_status(),
roi.replication_status_internal == rinfos.replication_status_internal(),
);
send_local_event(EventArgs {
event_name,
bucket_name: bucket.clone(),
object: object_info,
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
if rinfos.replication_status() != ReplicationStatusType::Completed
&& roi.replication_status_internal == rinfos.replication_status_internal()
if publication.update_transition_stats
&& let Some(stats) = runtime_sources::replication_stats()
{
for tgt in &rinfos.targets {
@@ -3098,7 +3280,94 @@ pub(crate) async fn replicate_object_with_outcome<S: ReplicationStorage>(
}
}
(merged_state, state_persisted)
if publication.emit_terminal_failure {
note_replication_terminal_failure(&bucket, &object, version_id.as_deref(), &rinfos);
}
let event_name = if replication_status == ReplicationStatusType::Completed {
EventName::ObjectReplicationComplete.to_string()
} else {
EventName::ObjectReplicationFailed.to_string()
};
if publication.emit_event {
send_local_event(EventArgs {
event_name,
bucket_name: bucket.clone(),
object: object_info,
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
}
if publication.update_same_state_failure_stats
&& let Some(stats) = runtime_sources::replication_stats()
{
for tgt in &rinfos.targets {
if tgt.replication_status != tgt.prev_replication_status {
stats
.update(&bucket, tgt, tgt.replication_status.clone(), tgt.prev_replication_status.clone())
.await;
}
}
}
(merged_state, disposition)
}
/// Emit the operator-visible record of a replication attempt that ended FAILED.
///
/// Every per-branch failure log in this module is deliberately quieter than
/// `error`: most of them sit on the replication hot path and fire once per
/// object *per ARN*, so a target that stays unreachable would flood the log
/// from inside the transfer loop. That left a hole customers fell into
/// (rustfs#6825): `DEFAULT_LOG_LEVEL` is `error`, so on a stock deployment a
/// failed object produced no line at all, and an operator staring at a replica
/// that never arrived had nothing to correlate — the same trap already
/// documented for the GET path in
/// `crates/e2e_test/src/get_stream_failure_observability_test.rs`.
///
/// This is the one place that knows an object reached a *terminal* FAILED state
/// for a target, so this is where the guaranteed-visible line belongs. It is
/// bounded by the number of objects that actually fail rather than by attempts
/// inside a transfer, and it carries the target's own error so a remote
/// rejection is diagnosable without the operator first having to lower the
/// global log level and reproduce.
fn note_replication_terminal_failure(bucket: &str, object: &str, version_id: Option<&str>, rinfos: &ReplicatedInfos) {
for target in rinfos.targets.iter() {
if target.is_empty() {
continue;
}
let replication_failed = target.replication_status == ReplicationStatusType::Failed;
let purge_failed = target.version_purge_status == VersionPurgeStatusType::Failed;
if !replication_failed && !purge_failed {
continue;
}
error!(
event = EVENT_REPLICATION_OBJECT_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
object = %object,
version_id = version_id.unwrap_or("-"),
arn = %target.arn,
endpoint = %target.endpoint,
op_type = %target.op_type,
size = target.size,
replication_status = %target.replication_status.as_str(),
version_purge_status = %target.version_purge_status.as_str(),
// The target's error can carry a signed URL or an echoed auth
// header, so it goes through the same redaction as the persisted
// resync detail rather than straight into the log.
error = %target
.error
.as_deref()
.and_then(sanitize_resync_error_detail)
.unwrap_or_else(|| "<none>".to_string()),
"Replication failed for object"
);
}
}
fn unavailable_object_target_info(roi: &ReplicateObjectInfo, arn: &str) -> ReplicatedTargetInfo {
@@ -3400,6 +3669,33 @@ impl ReplicateObjectInfoExt for ReplicateObjectInfo {
}
};
if let Some(reason) = replication_single_put_size_error(is_multipart, transfer_size, object_info.etag.as_deref()) {
drop(gr);
rinfo.replication_status = ReplicationStatusType::Failed;
rinfo.error = Some(reason.clone());
warn!(
event = EVENT_RESYNC_TARGET_OPERATION_FAILED,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
bucket = %bucket,
target_bucket = %tgt_client.bucket,
arn = %tgt_client.arn,
object = %object,
operation = "put_object",
transfer_size = transfer_size,
error = %reason,
"Replication target operation failed"
);
send_local_event(EventArgs {
event_name: EventName::ObjectReplicationNotTracked.to_string(),
bucket_name: bucket.clone(),
object: object_info,
user_agent: "Internal: [Replication]".to_string(),
..Default::default()
});
return rinfo;
}
if let Some(err) = if is_multipart {
drop(gr);
let result = replicate_object_with_multipart(MultipartReplicationContext {
@@ -4068,6 +4364,15 @@ async fn replicate_all_payload_to_target<S: ReplicationObjectIO>(
ctx: ReplicateAllPayloadContext<'_, S>,
mut gr: GetObjectReader,
) -> Option<std::io::Error> {
// Fail before streaming a body the target is required to reject: an S3
// PutObject caps at 5 GiB, and this route is chosen by the source object's
// storage shape rather than its size (rustfs#6825).
if let Some(reason) = replication_single_put_size_error(ctx.is_multipart, ctx.transfer_size, ctx.object_info.etag.as_deref())
{
drop(gr);
return Some(std::io::Error::other(reason));
}
if ctx.is_multipart {
drop(gr);
let result = replicate_object_with_multipart(MultipartReplicationContext {
@@ -4440,6 +4745,105 @@ async fn replicate_multipart_parts_and_complete<S: ReplicationObjectIO>(
mod tests {
use super::super::replication_filemeta_boundary::ReplicateTargetDecision;
#[test]
fn same_state_terminal_retry_uses_validate_only() {
assert_eq!(replication_status_writeback_mode(false), ReplicationStatusWritebackMode::ValidateOnly);
assert_eq!(replication_status_writeback_mode(true), ReplicationStatusWritebackMode::Update);
}
#[test]
fn superseded_attempt_has_no_terminal_publication_side_effects() {
assert!(ReplicationAttemptDisposition::Persisted.consumes_mrf_entry());
assert!(ReplicationAttemptDisposition::Superseded.consumes_mrf_entry());
assert!(!ReplicationAttemptDisposition::Retry.consumes_mrf_entry());
assert_eq!(
replication_terminal_publication(true, false, ReplicationStatusType::Failed, true),
ReplicationTerminalPublication::default()
);
assert_eq!(
replication_terminal_publication(true, true, ReplicationStatusType::Completed, false),
ReplicationTerminalPublication::default()
);
let current = replication_terminal_publication(false, false, ReplicationStatusType::Failed, true);
assert!(current.emit_terminal_failure);
assert!(current.emit_event);
assert!(!current.update_transition_stats);
assert!(current.update_same_state_failure_stats);
assert_eq!(
replication_terminal_publication(true, true, ReplicationStatusType::Failed, false),
ReplicationTerminalPublication::default(),
"a retryable status-persistence failure must not publish a terminal result"
);
}
#[test]
fn invalid_generation_retries_before_remote_replication() {
let mut preserved_state = ReplicationState::default();
preserved_state
.targets
.insert("arn:target".to_string(), ReplicationStatusType::Pending);
let invalid = ReplicateObjectInfo {
replication_generation: ReplicationGenerationSnapshot {
invalid: true,
..Default::default()
},
replication_state: Some(preserved_state.clone()),
..Default::default()
};
assert_eq!(
replication_attempt_preflight(&invalid),
Some((preserved_state, ReplicationAttemptDisposition::Retry))
);
assert!(replication_attempt_preflight(&ReplicateObjectInfo::default()).is_none());
}
#[tokio::test]
async fn terminal_writeback_carries_replication_lock_loss_fence() {
let lock = rustfs_lock::NamespaceLock::new(
"replication-status-writeback-fence".to_string(),
Arc::new(rustfs_lock::LocalClient::new()),
);
let guard = lock
.get_write_lock(
rustfs_lock::ObjectKey::new("bucket", "/[replicate]/object"),
"worker-a",
std::time::Duration::from_secs(2),
)
.await
.expect("replication lock should be acquired");
let signal = guard
.lock_lost_signal()
.expect("distributed guard must expose its loss signal");
let forced_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
let _test_fence = NamespaceLockSignalTestFence::install_with_loss_handle(&signal, Arc::clone(&forced_lost));
// Build the writeback after remote work has acquired the guard, then
// lose the lease before storage reaches its commit fence.
let opts = replication_status_writeback_options(
&ReplicateObjectInfo::default(),
&guard,
None,
ReplicationStatusWritebackMode::ValidateOnly,
);
forced_lost.store(true, std::sync::atomic::Ordering::Release);
assert!(
opts.namespace_lock_fence
.as_ref()
.is_some_and(NamespaceLockFence::is_lock_lost),
"terminal CAS must observe a replication lease lost after remote I/O"
);
assert!(!ReplicationAttemptDisposition::Retry.consumes_mrf_entry());
assert_eq!(
replication_terminal_publication(true, true, ReplicationStatusType::Failed, false),
ReplicationTerminalPublication::default(),
"a fenced writeback retry must not publish terminal events or statistics"
);
}
#[test]
fn unavailable_object_target_is_persisted_as_failed() {
let arn = "arn:object-target";
@@ -5716,4 +6120,207 @@ mod tests {
assert!(!retry_scheduled.load(Ordering::SeqCst));
assert_eq!(result.unwrap_err().to_string(), "transfer failed");
}
/// A replication target's terminal outcome, as the operator sees it.
fn failed_target(arn: &str, error: &str) -> ReplicatedTargetInfo {
ReplicatedTargetInfo {
arn: arn.to_string(),
size: 6 * 1024 * 1024 * 1024,
op_type: ReplicationType::Object,
replication_status: ReplicationStatusType::Failed,
endpoint: "s3.wasabisys.com".to_string(),
error: Some(error.to_string()),
..Default::default()
}
}
/// Capture the log this module writes, filtered exactly the way a stock
/// deployment filters it.
fn logs_at_default_level(emit: impl FnOnce()) -> String {
use std::sync::{Arc, Mutex};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::MakeWriter;
use tracing_subscriber::layer::SubscriberExt;
#[derive(Clone, Default)]
struct CapturedLogs {
buffer: Arc<Mutex<Vec<u8>>>,
}
struct CapturedLogWriter {
buffer: Arc<Mutex<Vec<u8>>>,
}
impl std::io::Write for CapturedLogWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.buffer
.lock()
.expect("captured logs mutex should not be poisoned")
.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> MakeWriter<'a> for CapturedLogs {
type Writer = CapturedLogWriter;
fn make_writer(&'a self) -> Self::Writer {
CapturedLogWriter {
buffer: Arc::clone(&self.buffer),
}
}
}
let logs = CapturedLogs::default();
let subscriber = tracing_subscriber::registry()
// Not a hand-picked level: this is the filter an operator who has
// changed nothing is actually running.
.with(EnvFilter::new(rustfs_config::DEFAULT_LOG_LEVEL))
.with(
tracing_subscriber::fmt::layer()
.with_writer(logs.clone())
.with_ansi(false)
.without_time(),
);
let _guard = tracing::subscriber::set_default(subscriber);
let _callsite_pin = crate::test_tracing::pin_callsite_interest_for_test();
emit();
let buffer = logs
.buffer
.lock()
.expect("captured logs mutex should not be poisoned")
.clone();
String::from_utf8(buffer).expect("captured logs should be valid UTF-8")
}
/// rustfs#6825: a 6 GiB object never reached the target and the server said
/// nothing an operator could act on, because every failure line in this
/// module sat below `DEFAULT_LOG_LEVEL`. The object key, the target, and
/// the target's own error have to survive the default filter.
#[test]
fn failed_replication_names_the_object_at_the_default_log_level() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![failed_target("arn:replication::wasabi", "put_object failed: EntityTooLarge")],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/vm-image.qcow2", Some("v-9"), &rinfos);
});
assert!(logs.contains("backups/vm-image.qcow2"), "the failed object must be named: {logs}");
assert!(logs.contains("arn:replication::wasabi"), "the target must be named: {logs}");
assert!(logs.contains("EntityTooLarge"), "the target's own error must survive: {logs}");
assert!(logs.contains("v-9"), "the version must be named: {logs}");
assert!(logs.contains(EVENT_REPLICATION_OBJECT_FAILED), "the event must be structured: {logs}");
}
#[test]
fn successful_replication_stays_quiet_at_the_default_log_level() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![ReplicatedTargetInfo {
arn: "arn:replication::wasabi".to_string(),
replication_status: ReplicationStatusType::Completed,
..Default::default()
}],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/ok.bin", None, &rinfos);
});
assert!(logs.is_empty(), "a completed replication must not log an error: {logs}");
}
/// A failed version purge is the 6822 symptom (the version stays on the
/// target); it must be as visible as a failed transfer even though the
/// replication status itself is not FAILED.
#[test]
fn failed_version_purge_is_reported_at_the_default_log_level() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![ReplicatedTargetInfo {
arn: "arn:replication::wasabi".to_string(),
op_type: ReplicationType::Delete,
replication_status: ReplicationStatusType::Empty,
version_purge_status: VersionPurgeStatusType::Failed,
error: Some("remove_object failed: NoSuchVersion".to_string()),
..Default::default()
}],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/purged.bin", Some("v-1"), &rinfos);
});
assert!(logs.contains("backups/purged.bin"), "the purged object must be named: {logs}");
assert!(logs.contains("NoSuchVersion"), "the target's own error must survive: {logs}");
}
/// The target's error is echoed remote text and can carry a signed URL or
/// an auth header, so it goes through the persisted-detail redaction rather
/// than straight into the log.
#[test]
fn failed_replication_redacts_a_sensitive_target_error() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![failed_target(
"arn:replication::wasabi",
"put_object failed: rejected Authorization: Bearer super-secret",
)],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/vm-image.qcow2", None, &rinfos);
});
assert!(logs.contains("backups/vm-image.qcow2"), "the object must still be named: {logs}");
assert!(!logs.contains("super-secret"), "the credential must not reach the log: {logs}");
}
/// An empty target slot carries no outcome; reporting it would invent a
/// failure for a target that was never attempted.
#[test]
fn empty_target_slots_are_not_reported_as_failures() {
let rinfos = ReplicatedInfos {
replication_timestamp: Some(OffsetDateTime::now_utc()),
targets: vec![ReplicatedTargetInfo::default()],
};
let logs = logs_at_default_level(|| {
note_replication_terminal_failure("photos", "backups/unattempted.bin", None, &rinfos);
});
assert!(logs.is_empty(), "an empty target slot must not be reported: {logs}");
}
#[test]
fn version_identity_drift_re_arms_after_the_throttle_interval() {
let arn = "arn:replication::drift-throttle-test";
let start = Instant::now();
assert!(version_identity_drift_log_due(arn, start), "first drift must be reported");
assert!(
!version_identity_drift_log_due(arn, start + VERSION_IDENTITY_DRIFT_LOG_INTERVAL / 2),
"a second drift inside the interval must stay throttled"
);
assert!(
version_identity_drift_log_due(arn, start + VERSION_IDENTITY_DRIFT_LOG_INTERVAL),
"drift must become visible again once the interval elapses, instead of \
going silent for the rest of the process lifetime"
);
}
#[test]
fn version_identity_drift_throttles_each_target_independently() {
let now = Instant::now();
assert!(version_identity_drift_log_due("arn:replication::drift-a", now));
assert!(
version_identity_drift_log_due("arn:replication::drift-b", now),
"one target's report must not silence another's"
);
}
}
@@ -18,7 +18,11 @@ use tokio_util::sync::CancellationToken;
use super::replication_error_boundary::Error;
use super::replication_filemeta_boundary::{replication_state_from_filemeta, version_purge_status_from_filemeta};
pub(crate) type ReplicationObjectStore = crate::store::ECStore;
pub(crate) use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
pub(crate) use crate::object_api::{
GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader, ReplicationStatusWritebackCondition, ReplicationStatusWritebackMode,
};
#[cfg(test)]
pub(crate) use crate::object_api::{NamespaceLockFence, NamespaceLockSignalTestFence};
pub(crate) use crate::storage_api_contracts::list::{
ListOperations, StorageListObjectVersionsInfo, StorageListObjectsV2Info, StorageObjectInfoOrErr, StorageWalkOptions,
};
@@ -43,6 +43,7 @@ pub(crate) use crate::bucket::bucket_target_sys::{
pub(crate) use crate::bucket::target::BucketTarget;
pub(crate) use crate::bucket::target::BucketTargets;
pub use rustfs_replication::SsecPassthroughCapability;
pub use rustfs_replication::{ObjectLockIntegrity, object_lock_put_integrity};
pub(crate) use rustfs_replication::{
SsecPassthroughGate, is_replication_target_offline_error, ssec_passthrough_gate, version_identity_drifted,
};
@@ -260,8 +261,15 @@ pub(crate) fn replication_put_object_options(sc: &str, object_info: &ObjectInfo)
// the response path relies on). Keep the object's own multipart
// flag so encrypted objects stay on the multipart route.
} else {
let (checksum_meta, is_mp) = object_info.decrypt_checksums(0, &HeaderMap::new())?;
is_multipart = is_mp;
let (checksum_meta, checksum_record_is_multipart) = object_info.decrypt_checksums(0, &HeaderMap::new())?;
// The checksum record describes how the *checksum* is composed,
// not how the object is stored. A full-object checksum carries no
// MULTIPART flag even on a multipart upload, so trusting it here
// routed a 768-part object through a single PutObject and the
// target rejected the 6 GiB body with EntityTooLarge
// (rustfs#6825). The object's own shape is the authority: the
// record may only add multipart-ness, never take it away.
is_multipart = object_info.is_multipart() || checksum_record_is_multipart;
for (key, value) in checksum_meta.iter() {
if key != AMZ_CHECKSUM_TYPE {
@@ -518,6 +526,109 @@ mod tests {
use time::Duration;
use uuid::Uuid;
/// Serialize an object-level checksum record the way
/// `complete_multipart_upload` persists it for a **full-object** checksum:
/// the record carries the plain algorithm type, without the MULTIPART
/// flags that a composite record gets.
fn full_object_multipart_checksum_record() -> bytes::Bytes {
let checksum_type = rustfs_rio::ChecksumType::from_string_with_obj_type("crc32", "FULL_OBJECT");
assert!(checksum_type.is_set(), "crc32 FULL_OBJECT must be a valid checksum type");
assert!(checksum_type.full_object_requested());
let mut combined = Vec::new();
let mut checksum = rustfs_rio::Checksum {
checksum_type,
..Default::default()
};
for part in [b"part-one".as_slice(), b"part-two".as_slice()] {
let part_checksum = rustfs_rio::Checksum::new_from_data(checksum_type, part).expect("part checksum");
combined.extend_from_slice(part_checksum.raw.as_slice());
checksum.add_part(&part_checksum, part.len() as i64).expect("add part");
}
checksum.to_bytes(&combined)
}
#[test]
fn multipart_object_with_full_object_checksum_keeps_the_multipart_route() {
// rustfs#6825: a 768-part upload was replicated with a single
// PutObject and rejected by the target with EntityTooLarge. The
// object's storage shape says multipart; only the checksum record
// looked single-part, and the checksum record must not decide the
// transport.
let object_info = ObjectInfo {
etag: Some("0123456789abcdef0123456789abcdef-768".to_string()),
checksum: Some(full_object_multipart_checksum_record()),
size: 6 * 1024 * 1024 * 1024,
..Default::default()
};
assert!(object_info.is_multipart(), "the fixture must be a multipart object");
let (_, checksum_says_multipart) = object_info
.decrypt_checksums(0, &HeaderMap::new())
.expect("checksum record must decode");
assert!(
!checksum_says_multipart,
"fixture precondition: a full-object record carries no MULTIPART flag, which is what used to \
downgrade the transport"
);
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options");
assert!(
is_multipart,
"a multipart object must replicate over multipart whatever its checksum record looks like"
);
}
#[test]
fn checksum_record_never_changes_the_transport_a_single_part_object_needs() {
// The mirror of the rustfs#6825 guard: an object stored as one PUT
// must keep the single-PUT transport, or its replica's ETag would
// change shape and every ETag-based convergence check would re-copy it.
let checksum =
rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::CRC32, b"whole-object").expect("checksum fixture");
let object_info = ObjectInfo {
etag: Some("0123456789abcdef0123456789abcdef".to_string()),
checksum: Some(checksum.to_bytes(&[])),
..Default::default()
};
assert!(!object_info.is_multipart(), "the fixture must be a single-part object");
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options");
assert!(!is_multipart, "a single-part object must not be promoted onto the multipart transport");
}
#[test]
fn composite_checksum_multipart_object_keeps_the_multipart_route() {
// The checksum shape that already worked before rustfs#6825, pinned so
// the fix cannot regress it.
let mut checksum_type = rustfs_rio::ChecksumType::from_string("crc32");
checksum_type
.merge(rustfs_rio::ChecksumType::MULTIPART)
.merge(rustfs_rio::ChecksumType::INCLUDES_MULTIPART);
let mut combined = Vec::new();
for part in [b"part-one".as_slice(), b"part-two".as_slice()] {
let part_checksum =
rustfs_rio::Checksum::new_from_data(rustfs_rio::ChecksumType::from_string("crc32"), part).expect("part checksum");
combined.extend_from_slice(part_checksum.raw.as_slice());
}
let checksum = rustfs_rio::Checksum::new_from_data(checksum_type, &combined).expect("composite checksum");
let object_info = ObjectInfo {
etag: Some("0123456789abcdef0123456789abcdef-2".to_string()),
checksum: Some(checksum.to_bytes(&combined)),
..Default::default()
};
let (_, is_multipart) = replication_put_object_options("STANDARD", &object_info).expect("build put options");
assert!(is_multipart, "a composite-checksum multipart object must stay on the multipart transport");
}
#[test]
fn replication_action_for_target_head_existing_object_source_newer_null_version_requires_replication() {
let source = ObjectInfo {
@@ -739,6 +850,11 @@ mod tests {
metadata.insert(MINIO_INTERNAL_ENCRYPTION_ALGORITHM_HEADER.to_string(), "DAREv2-HMAC-SHA256".to_string());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_SSEC_SEALED_KEY_HEADER.to_string(), "sealed".to_string());
metadata.insert(MINIO_INTERNAL_ENCRYPTION_MULTIPART_HEADER.to_string(), "true".to_string());
rustfs_utils::http::insert_str(
&mut metadata,
rustfs_utils::http::SUFFIX_REPLICATION_GENERATION,
Uuid::from_u128(1).to_string(),
);
let object_info = ObjectInfo {
user_defined: Arc::new(metadata),
@@ -784,6 +900,13 @@ mod tests {
assert!(!options.user_metadata.contains_key(AMZ_SERVER_SIDE_ENCRYPTION));
assert!(!options.user_metadata.contains_key(SSEC_ALGORITHM_HEADER));
assert!(!options.user_metadata.contains_key(INTERNAL_ENCRYPTION_IV_HEADER));
assert!(
options
.user_metadata
.keys()
.all(|key| !key.contains(rustfs_utils::http::SUFFIX_REPLICATION_GENERATION)),
"source-local mutation generation must never cross the replication wire"
);
assert!(
!options
.user_metadata
@@ -0,0 +1,351 @@
// 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.
//! Sealed remote credentials shared by the three stores that persist one
//! (rustfs/backlog#2168): replication targets (`bucket-targets.json`), remote
//! tiers (`tier-config.bin`) and on-demand migration sources
//! (`on-demand-migration.json`).
//!
//! The design record is `docs/architecture/remote-credential-sealing-adr.md`.
//! What this module owns: the versioned envelope, the encryption context that
//! binds a ciphertext to the record owning it, the sealer registration point,
//! and the fail-closed error type. What it deliberately does not own: any KMS
//! call (ECStore does not depend on `rustfs-kms`; the binary installs a
//! sealer, exactly like `ON_DEMAND_MIGRATION_CONFIG_HOOK` and the event
//! dispatch hook in `crates/ecstore/src/services/event_notification.rs`), and
//! any decision about which stored field a consumer writes.
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::sync::{Arc, OnceLock};
/// Envelope format this build writes. A reader accepts only versions it
/// knows; an unknown version is a typed error, never a fallback.
pub const SEALED_CREDENTIAL_VERSION: u8 = 1;
/// Which store a sealed value belongs to. Part of the encryption context, so
/// a ciphertext cannot be replayed into a different store.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SealedCredentialStore {
/// `bucket-targets.json` (replication and other bucket targets).
BucketTargets,
/// `tier-config.bin` (remote tiers).
TierConfig,
/// `on-demand-migration.json` (migration sources).
OnDemandMigration,
}
impl SealedCredentialStore {
pub fn as_str(self) -> &'static str {
match self {
SealedCredentialStore::BucketTargets => "bucket-targets",
SealedCredentialStore::TierConfig => "tier-config",
SealedCredentialStore::OnDemandMigration => "on-demand-migration",
}
}
}
/// Identity of the record a secret belongs to: the store, its owner (bucket
/// name, tier name, or target ARN) and the field name. Rendered into the KMS
/// encryption context so a ciphertext moved between buckets, tiers or fields
/// fails to decrypt instead of silently authorizing a different remote.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SealScope {
pub store: SealedCredentialStore,
pub owner: String,
pub field: &'static str,
}
impl SealScope {
pub fn new(store: SealedCredentialStore, owner: impl Into<String>, field: &'static str) -> Self {
Self {
store,
owner: owner.into(),
field,
}
}
/// The encryption context handed to the sealer. Keys are stable: they are
/// part of the on-disk contract, because a ciphertext only decrypts under
/// the same context.
pub fn encryption_context(&self) -> HashMap<String, String> {
HashMap::from([
("rustfs:store".to_string(), self.store.as_str().to_string()),
("rustfs:owner".to_string(), self.owner.clone()),
("rustfs:field".to_string(), self.field.to_string()),
])
}
}
/// A sealed secret as persisted. `Debug` prints no ciphertext: a sealed value
/// is not a secret, but it is noise in a log line and an operator reading one
/// should see the key it is wrapped under, not the bytes.
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SealedCredential {
/// Envelope version; see [`SEALED_CREDENTIAL_VERSION`].
pub v: u8,
/// KMS master key id the data key is wrapped under.
pub key_id: String,
/// Master key version, when the backend reports one. Carried so the KMS
/// re-wrap job (`docs/architecture/kms-bulk-rekey-contract.md`) can tell
/// stale envelopes apart; nothing here rotates on its own.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_version: Option<String>,
/// Algorithm label reported by the sealer, for forensics and migration.
pub alg: String,
/// Ciphertext blob as produced by the sealer, base64 (standard, padded)
/// in the JSON stores and raw inside the tier msgpack payload.
pub ct: String,
}
impl fmt::Debug for SealedCredential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SealedCredential")
.field("v", &self.v)
.field("key_id", &self.key_id)
.field("key_version", &self.key_version)
.field("alg", &self.alg)
.field("ct", &format_args!("<{} bytes sealed>", self.ct.len()))
.finish()
}
}
impl SealedCredential {
/// Rejects an envelope this build cannot read. Called before every
/// unseal so an unknown version fails here rather than inside a backend.
pub fn check_version(&self) -> Result<(), SealedCredentialError> {
if self.v == SEALED_CREDENTIAL_VERSION {
Ok(())
} else {
Err(SealedCredentialError::UnsupportedVersion(self.v))
}
}
}
/// Why a seal or unseal did not produce a usable value. Every variant is
/// terminal for the record that carried it: a caller reports the remote as
/// unusable, and never substitutes a default or empty credential.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum SealedCredentialError {
/// No sealer is installed: KMS is not configured, or the process has not
/// finished startup. Reading a sealed record is impossible here.
#[error("no credential sealer is installed")]
NoSealer,
/// The stored envelope is from a newer (or otherwise unknown) format.
#[error("unsupported sealed credential envelope version {0}")]
UnsupportedVersion(u8),
/// The stored bytes are not a well-formed envelope.
#[error("malformed sealed credential: {0}")]
Malformed(String),
/// The sealer refused: wrong encryption context, missing key, revoked
/// access, or a failed authentication tag.
#[error("sealed credential could not be unsealed: {0}")]
Kms(String),
}
/// The KMS-backed half, installed by the binary at startup.
#[async_trait]
pub trait CredentialSealer: Send + Sync + 'static {
/// Wraps `plaintext` under the scope's encryption context.
async fn seal(&self, plaintext: &str, scope: &SealScope) -> Result<SealedCredential, SealedCredentialError>;
/// Unwraps a stored envelope. Must fail when the envelope was sealed
/// under a different scope.
async fn unseal(&self, sealed: &SealedCredential, scope: &SealScope) -> Result<String, SealedCredentialError>;
}
static CREDENTIAL_SEALER: OnceLock<Arc<dyn CredentialSealer>> = OnceLock::new();
/// Installs the process-wide sealer. Returns `false` when one is already
/// installed, matching the other ECStore hooks.
pub fn install_credential_sealer(sealer: Arc<dyn CredentialSealer>) -> bool {
CREDENTIAL_SEALER.set(sealer).is_ok()
}
/// The installed sealer, or `None` when KMS is not wired. Callers that only
/// need to know whether sealing is possible use this; callers that must have
/// it use [`seal_secret`] / [`unseal_secret`] and get the typed error.
pub fn credential_sealer() -> Option<Arc<dyn CredentialSealer>> {
CREDENTIAL_SEALER.get().cloned()
}
/// Seals one secret field. Fails closed: without a sealer the caller must
/// reject the write rather than persist the secret in clear text after the
/// operator asked for sealing.
pub async fn seal_secret(plaintext: &str, scope: &SealScope) -> Result<SealedCredential, SealedCredentialError> {
let sealer = credential_sealer().ok_or(SealedCredentialError::NoSealer)?;
sealer.seal(plaintext, scope).await
}
/// Unseals one secret field, rejecting an unknown envelope version first.
pub async fn unseal_secret(sealed: &SealedCredential, scope: &SealScope) -> Result<String, SealedCredentialError> {
sealed.check_version()?;
let sealer = credential_sealer().ok_or(SealedCredentialError::NoSealer)?;
sealer.unseal(sealed, scope).await
}
#[cfg(test)]
mod tests {
use super::*;
use parking_lot::Mutex;
/// Stands in for the KMS-backed sealer: records the context it was called
/// with, and refuses a ciphertext presented under a different one.
#[derive(Default)]
struct FakeSealer {
sealed_contexts: Mutex<Vec<HashMap<String, String>>>,
}
#[async_trait]
impl CredentialSealer for FakeSealer {
async fn seal(&self, plaintext: &str, scope: &SealScope) -> Result<SealedCredential, SealedCredentialError> {
let context = scope.encryption_context();
self.sealed_contexts.lock().push(context.clone());
let mut bound = serde_json::to_string(&context).expect("context serializes");
bound.push('|');
bound.push_str(plaintext);
Ok(SealedCredential {
v: SEALED_CREDENTIAL_VERSION,
key_id: "key-1".to_string(),
key_version: Some("3".to_string()),
alg: "AES-256-GCM".to_string(),
ct: base64_simd::STANDARD.encode_to_string(bound.as_bytes()),
})
}
async fn unseal(&self, sealed: &SealedCredential, scope: &SealScope) -> Result<String, SealedCredentialError> {
let raw = base64_simd::STANDARD
.decode_to_vec(sealed.ct.as_bytes())
.map_err(|err| SealedCredentialError::Malformed(err.to_string()))?;
let bound = String::from_utf8(raw).map_err(|err| SealedCredentialError::Malformed(err.to_string()))?;
let expected = serde_json::to_string(&scope.encryption_context()).expect("context serializes");
bound
.strip_prefix(&expected)
.and_then(|rest| rest.strip_prefix('|'))
.map(str::to_string)
.ok_or_else(|| SealedCredentialError::Kms("encryption context mismatch".to_string()))
}
}
fn scope(owner: &str) -> SealScope {
SealScope::new(SealedCredentialStore::OnDemandMigration, owner, "secret_key")
}
#[tokio::test]
async fn seal_round_trips_and_binds_the_scope() {
let sealer = Arc::new(FakeSealer::default());
let sealed = sealer.seal("super-secret", &scope("photos")).await.expect("seal");
assert_eq!(sealed.v, SEALED_CREDENTIAL_VERSION);
assert_eq!(sealed.key_version.as_deref(), Some("3"));
assert_eq!(sealer.unseal(&sealed, &scope("photos")).await.expect("unseal"), "super-secret");
// The same ciphertext under another bucket must not unseal.
let err = sealer
.unseal(&sealed, &scope("other-bucket"))
.await
.expect_err("a ciphertext must not move between owners");
assert!(matches!(err, SealedCredentialError::Kms(_)), "{err}");
// Nor under another field of the same record.
let other_field = SealScope::new(SealedCredentialStore::OnDemandMigration, "photos", "session_token");
let err = sealer
.unseal(&sealed, &other_field)
.await
.expect_err("a ciphertext must not move between fields");
assert!(matches!(err, SealedCredentialError::Kms(_)), "{err}");
let contexts = sealer.sealed_contexts.lock();
assert_eq!(contexts.len(), 1);
assert_eq!(contexts[0]["rustfs:store"], "on-demand-migration");
assert_eq!(contexts[0]["rustfs:owner"], "photos");
assert_eq!(contexts[0]["rustfs:field"], "secret_key");
}
#[tokio::test]
async fn an_unknown_envelope_version_is_rejected_before_the_sealer_is_asked() {
let sealed = SealedCredential {
v: SEALED_CREDENTIAL_VERSION + 1,
key_id: "key-1".to_string(),
key_version: None,
alg: "AES-256-GCM".to_string(),
ct: "Zm9v".to_string(),
};
assert_eq!(
sealed.check_version().expect_err("a newer envelope must not be read"),
SealedCredentialError::UnsupportedVersion(SEALED_CREDENTIAL_VERSION + 1)
);
// The global helper reports the version, not "no sealer", even in a
// process where none is installed.
assert_eq!(
unseal_secret(&sealed, &scope("photos")).await.expect_err("version first"),
SealedCredentialError::UnsupportedVersion(SEALED_CREDENTIAL_VERSION + 1)
);
}
#[tokio::test]
async fn without_a_sealer_both_directions_fail_closed() {
// This test binary installs no sealer, so the global helpers must
// report NoSealer rather than fall back to clear text.
assert!(credential_sealer().is_none(), "no sealer is installed in unit tests");
assert_eq!(
seal_secret("super-secret", &scope("photos")).await.expect_err("seal"),
SealedCredentialError::NoSealer
);
let sealed = SealedCredential {
v: SEALED_CREDENTIAL_VERSION,
key_id: "key-1".to_string(),
key_version: None,
alg: "AES-256-GCM".to_string(),
ct: "Zm9v".to_string(),
};
assert_eq!(
unseal_secret(&sealed, &scope("photos")).await.expect_err("unseal"),
SealedCredentialError::NoSealer
);
}
#[test]
fn debug_and_serde_keep_the_on_disk_shape_stable() {
let sealed = SealedCredential {
v: 1,
key_id: "key-1".to_string(),
key_version: None,
alg: "AES-256-GCM".to_string(),
ct: "Zm9v".to_string(),
};
// key_version is omitted when absent, so an envelope from a backend
// without version history stays compact.
assert_eq!(
serde_json::to_string(&sealed).expect("serialize"),
r#"{"v":1,"key_id":"key-1","alg":"AES-256-GCM","ct":"Zm9v"}"#
);
let parsed: SealedCredential = serde_json::from_str(r#"{"v":1,"key_id":"key-1","alg":"AES-256-GCM","ct":"Zm9v"}"#)
.expect("an envelope without key_version parses");
assert_eq!(parsed, sealed);
let rendered = format!("{sealed:?}");
assert!(rendered.contains("key-1"), "{rendered}");
assert!(!rendered.contains("Zm9v"), "Debug must not print the ciphertext: {rendered}");
}
#[test]
fn a_malformed_envelope_is_a_typed_error() {
let err = serde_json::from_str::<SealedCredential>(r#"{"v":1,"key_id":"key-1"}"#)
.map_err(|err| SealedCredentialError::Malformed(err.to_string()))
.expect_err("a truncated envelope must not parse");
assert!(matches!(err, SealedCredentialError::Malformed(_)), "{err}");
}
}
@@ -90,10 +90,6 @@ impl BucketVersioningSys {
/// caller's own instance context so a second in-process store never
/// answers with the first instance's versioning state; falls back to the
/// ambient system when the instance cell is not initialized.
#[allow(
dead_code,
reason = "instance-scoped seam (backlog#1052) with no caller in this port (backlog#1823)"
)]
pub(crate) async fn get_in(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<VersioningConfiguration> {
if bucket == RUSTFS_META_BUCKET || bucket.starts_with(RUSTFS_META_BUCKET) {
return Ok(VersioningConfiguration::default());
+1 -1
View File
@@ -48,7 +48,7 @@ pub use internode_data_transport::build_internode_data_transport_from_env;
pub(crate) use peer_rest_client::TierConfigReloadOutcome;
pub use peer_rest_client::{
KMS_SIGNAL_SUBSYSTEM, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, SERVICE_SIGNAL_REFRESH_CONFIG,
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity, ScannerPublicationLease,
SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerPeerActivity, ScannerPeerDirtyUsageSnapshot, ScannerPublicationLease,
};
pub(crate) use peer_s3_client::heal_bucket_local_on_disks;
pub use peer_s3_client::{

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