Compare commits

...
Author SHA1 Message Date
overtrue 4609660647 fix(ci): refresh Linux full E2E selection 2026-09-02 22:09:28 +08:00
housemeandGitHub 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 AnandGitHub 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 AnandGitHub 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 AnandGitHub 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
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 <[email protected]>

---------

Co-authored-by: heihutu <[email protected]>
2026-09-02 12:32:31 +00:00
cxymdsandGitHub 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
唐小鸭andGitHub 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
唐小鸭andGitHub 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
housemeandGitHub 68f47b9219 fix(s3): preserve s3s v0.16 compatibility (#7052) 2026-09-02 16:45:47 +08:00
housemeandGitHub de7422b508 fix(server): align vhost domains with s3s port matching (#7051) 2026-09-02 07:55:30 +00:00
cxymdsandGitHub 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 AnandGitHub 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 GuoandGitHub ca8bbbf8f3 test(heal): cover target endpoint recovery (#7046) 2026-09-02 05:51:23 +00:00
housemeandGitHub 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
cxymdsandGitHub 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
cxymdsandGitHub 87bc9d14ea fix(ecstore): defer zero-evidence delete diagnostics (#7036) 2026-09-02 01:50:39 +00:00
Zhengchao AnandGitHub fddaeba247 fix(ci): pass repository to preview release cleanup (#7038) 2026-09-02 09:10:09 +08:00
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 <[email protected]>
2026-09-02 01:09:55 +00:00
hectorandGitHub 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 AnandGitHub 0a975f2fe2 docs(knowledge-base): prune stale content and add agent-facing index (#7035) 2026-09-02 08:26:59 +08:00
Zhengchao AnandGitHub ceeff52229 docs(swift): align README feature lists with router and handler wiring (#7033) 2026-09-02 08:08:37 +08:00
Zhengchao AnandGitHub e04e15aed1 ci: bump repo-visuals-action to v1.3.1 (#7032) 2026-09-02 08:08:07 +08:00
Zhengchao AnandGitHub afa84fa988 docs(readme): refresh feature and status matrix (#7031) 2026-09-02 08:07:51 +08:00
Zhengchao AnandGitHub 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
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 <[email protected]>
2026-09-02 07:08:33 +08:00
b03804566c test(heal): cover coordinator restart during rebuild (#7027)
Co-authored-by: Henry Guo <[email protected]>
2026-09-02 07:06:16 +08:00
Zhengchao AnandGitHub 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
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 <[email protected]>
2026-09-01 17:21:43 +00:00
cxymdsandGitHub 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
cxymdsandGitHub 99073938ae perf(s3): bound Snowball archive decoders (#7022) 2026-09-02 00:25:29 +08:00
唐小鸭andGitHub 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
唐小鸭andGitHub 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
housemeandGitHub 5720c5c748 fix(ecstore): bootstrap verified MinIO adoption metadata (#7020) 2026-09-01 23:15:41 +08:00
cxymdsandGitHub 1941189499 ci(tier): isolate per-run evidence (#7017) 2026-09-01 23:11:28 +08:00
hectorandGitHub 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
唐小鸭andGitHub 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
hectorandGitHub 6a8a8a1eaf ci(functional): reliable chain driver, heal-once, backlog issues, clone retry (#7013)
Problem: the nightly functional chain has not completed end-to-end.
Evidence from recent runs:
- workflow_run events are fire-and-forget: after KMS finished at 17:09Z
  on 8/31 no tier run was created; rustfs-storage-test.yml has never run.
- 'if: conclusion == success' gates skip downstream suites on any
  failure (security was skipped after pool failed on 9/1 01:48Z).
- rustfs-pool-expand-test.yml embedded a heal pass without
  continue-on-error, so a heal failure failed the whole workflow.

Fixes:
- Add rustfs-functional-chain.yml: entry point that dispatches the first
  suite via repository_dispatch; each suite hands off to the next with an
  explicit, re-drivable API call instead of workflow_run triggers.
- Split heal out of the pool workflow (renamed to RustFS Pool Expansion
  Test): heal now runs exactly once per chain, in rustfs-heal-test.yml
  (storage -> heal -> pool).
- Every suite job gets continue-on-error so a failing test never fails
  the workflow; failures are filed as issues in rustfs/backlog (report
  + redacted log tail) and the chain moves on.
- Clone rustfs/auto-testing with the PF token via 'gh repo clone' plus a
  5-attempt retry loop (transient clone failures aborted whole suites).
- Stop rewriting functional/index.html from every suite (divergent
  copies raced each other with stale SHAs); the canonical index now
  lives in the dashboard repo.
- Standalone workflow_dispatch runs are unchanged and never forward the
  chain; performance runs on its own runner, dispatched in parallel.
2026-09-01 21:08:58 +08:00
GatewayJandGitHub 833cc51534 feat(s3-select): stream JSON document input (#6980) 2026-09-01 20:17:14 +08:00
唐小鸭andGitHub 43450df589 fix(ecstore): keep degraded objects listable when drives are offline (#7010) 2026-09-01 20:16:57 +08:00
唐小鸭andGitHub 394394cdfc test(ecstore): deflake early-ack PUT fixtures in set_disk ops (#7009)
Six set_disk::ops tests failed non-deterministically only under
concurrent full-suite load, rotating between runs while each passed in
isolation. All six share one root cause: a lock-owning put_object
quorum-acks once the rename fanout reaches write quorum and lets a
detached tail task finish the lagging disks, so a fixture that inspects
per-disk state immediately after PUT can observe a disk the tail has not
reached yet.

The two heal report fixtures, the inline-commit fixture, and the
transaction-fencing fixture read or delete physical shards right after
PUT, and hit FileNotFound on a lagging disk. The two metadata-cache
fixtures prime the cache after PUT, and the read fanout refuses to publish
a cache entry while any disk still reports an error, so the priming read
observably published nothing.

Keep every affected setup PUT on the full-fanout commit path with
no_lock: true, following the existing precedent in this module, so PUT
returns only after every disk has committed. The option only governs lock
acquisition, so it does not weaken what any of these fixtures assert; the
transaction-fencing gate in particular is driven by the fleet proof and
env vars, never by the lock option. Where a fixture also depends on cache
publication, re-prime until the current generation is observably cached
instead of asserting on a single read that a loaded host can stall past
the cache TTL. The heal race fixture's shard damage injection is
best-effort by construction, so it now skips injection when the previous
round's tail still lags rather than unwrapping a read that may
legitimately race.

No production code changes, and no retries or sleeps added.
2026-09-01 20:12:43 +08:00
唐小鸭andGitHub af896dc427 test(ecstore): remove host and load dependencies from flaky suites (#7008)
* test(ecstore): retain final decommission capacity snapshot override

take_decommission_capacity_info_override_for_test used to pop the queue
to exhaustion, after which get_decommission_all_pool_capacity_infos
silently fell back to the host's real statfs numbers. Any new sampling
point added to the decommission start paths re-introduced that host
dependency and broke tests on some dev machines (#6989 patched one
instance by topping up snapshot counts, but the coupling remained).

Keep the final queued snapshot and replay it for every subsequent
sample so tests always observe injected capacity once an override is
installed. All existing injection patterns (single snapshot, repeated
identical snapshots, decreasing sequences ending at the post-operation
state) keep their semantics.

* test(ci): serialize load-sensitive heal and cache-generation tests

Under a heavily parallel nextest run (~792 ecstore tests), two tests of
set_disk::ops::heal::heal_result_report_tests failed nondeterministically
per round (different members each time; all 29 pass standalone). Every
test in the module builds a TempDir-backed 4-disk hermetic erasure set
and drives MiB-scale writes plus deep-scan heal: under load a single
disk's IO can fail while write quorum still holds, flipping per-disk
readback and aggregate-outcome assertions. The module's #[serial]
markers do not serialize across nextest's process-per-test boundary.

Verification also caught complete_multipart_generation_retires_cached_snapshot
failing once under the same load; it and its object.rs sibling carry
#[serial(metadata_cache_invalidation_probe)] and assert
get_object_metadata_cache generation semantics - the same shape that
forced the transition matrix tests into the serial group.

Add both families to the ecstore-serial-flaky test-group in the default
and ci profiles. Preventive serialization only, no retries. Three full
parallel rounds after the change: 792/792 passed each round.
2026-09-01 12:00:38 +00:00
Zhengchao AnandGitHub 297ff4688c feat(license): add entitlement provider abstraction (#7006) 2026-09-01 19:31:35 +08:00
唐小鸭andGitHub b9b2aa0b76 fix(ecstore): retry rename preparation on a pruned parent (#7005)
A completed multipart upload's staging cleanup prunes empty parent
directories up to the volume root, which removes shared prefixes such as
`data-movement/` and the per-object `<sha>/` while a concurrent
new_multipart_upload builds its destination chain below them. The writer
holds a descriptor to the pruned component, so its next handle-relative
mkdirat fails NotFound. Because rename never retried NotFound, the cleanup
fan-out failed several disks in the same window and broke write quorum.

Give rename preparation its own retry rule: a NotFound is retried once per
component below the base directory, so a rebuilt walk outlasts a pruning
walk, which removes ancestors monotonically upward and stops at the base.
A destination whose parent is the base keeps NotFound terminal, so
speculative cleanup renames still fail fast, and the base is only ever
opened, never created, so a genuinely missing base still fails. The rename
itself keeps its own budget and its unchanged NotFound-is-terminal rule.
2026-09-01 19:31:19 +08:00
唐小鸭andGitHub 1dcdfe4817 build(make): resolve a Python 3.11+ interpreter for guard scripts (#7004)
scripts/check_test_wiring.py and scripts/check_security_coverage.py import
tomllib, which landed in Python 3.11. macOS ships /usr/bin/python3 at 3.9, so
`make pre-commit` failed on a clean machine with `ModuleNotFoundError: No
module named 'tomllib'` in test-wiring-check, even though the checkers
themselves are fine.

Add scripts/python_bin.sh, which resolves an interpreter (explicit
RUSTFS_PYTHON, then python3.14..3.11/python3/python on PATH, then a
`uv run --python 3.12 --no-project` fallback) and execs it, failing with the
concrete remediation when nothing usable exists. Route the Make call sites
through RUSTFS_PYTHON_BIN. CI workflows keep calling python3 directly because
their runners already provide 3.11+.
2026-09-01 19:09:36 +08:00
cxymdsandGitHub 6e26769265 fix(ecstore): make transitioned cleanup crash-safe (#6978)
* fix(ecstore): fence transitioned object cleanup

* fix(ecstore): address ILM recovery review findings

* fix(ecstore): complete crash-safe tier cleanup recovery

* test(ecstore): avoid typo false positive

* fix(ecstore): stabilize decommission error buckets

* fix(ecstore): stabilize transition delete validation

* fix(ecstore): resume authorized tier delete dispatch

* fix(ecstore): satisfy feature clippy
2026-09-01 19:09:22 +08:00
唐小鸭andGitHub bd66fa9dca test(e2e): poll for listing convergence after rolling upgrade restarts (#6997)
The mixed-version rolling upgrade suite asserted a single list_objects_v2
snapshot seconds after restarting a node. Peers keep a restarted node's
drive in Suspect/Returning for ~probe_interval(2s) x success_threshold(3),
and while one drive is excluded the strict listing quorum (write quorum,
3 of 4) drops objects that were themselves legally written at 3/4 during
an earlier node's identical post-restart window, under-counting the
listing (observed as 254 vs 258 in CI) even though every object still
GETs correctly. Replace the snapshot asserts with a bounded convergence
poll; a real upgrade data-loss regression still fails after the deadline.
2026-09-01 18:43:09 +08:00
1aea7541c8 fix(release): normalize development package versions (#6994)
* fix(release): normalize development package versions

* ci: build only the rustfs release binary

---------

Co-authored-by: Zhengchao An <[email protected]>
2026-09-01 18:32:16 +08:00
唐小鸭andGitHub 9e6d34785b test(ecstore): deflake inline fanout gate assertion under load (#6992)
test(ecstore): assert inline fanout gate on deterministic scheduled metric

non_inline_data_read_early_stop_does_not_add_inline_fanout_on_unequal_layout
compared disk_call_counters::KIND_READ_VERSION totals between the two-phase
read-plan gate being off and on. That counter records inside each spawned
fanout task, so the single-pending inline hedge read races the early-stop
abort_all(): whether the hedge task gets its first poll before cancellation
decides a 4-vs-5 count per read. Under concurrent nextest load the two reads
can disagree (reproduced locally at ~5% when run beside one other test,
matching the CI failure on PR #6961).

Assert on the rustfs_io_get_object_metadata_fanout_scheduled histogram
instead, which records the scheduling decision synchronously in the fanout
loop and is deterministic, using the CapturingRecorder + current-thread
runtime pattern already used by the neighboring tests in this module.
2026-09-01 18:31:35 +08:00
cxymdsandGitHub 03aecc5c3e fix(heal): avoid pool metadata lock recursion (#6991) 2026-09-01 18:31:21 +08:00
cxymdsandGitHub 45fe54e389 fix(admin): align tier backend error contract (#6988) 2026-09-01 18:31:07 +08:00
cxymdsandGitHub 2ed5c297ac fix(deps): update mysql_async to 0.37.1 (#7007) 2026-09-01 18:30:10 +08:00
Zhengchao AnandGitHub 23ab078c56 fix(ecstore): reclaim stale object prefixes (#6974) 2026-09-01 10:09:00 +00:00
cxymdsandGitHub 80c629bfe0 test(ci): refresh e2e full selection manifests (#6983) 2026-09-01 09:26:13 +00:00
47304cc68d feat(build): allow overriding release version (#6998)
Allow release builds to set RUSTFS_BUILD_VERSION at compile time while keeping the existing tag, short commit, and package-version fallback when the variable is unset or empty.

Co-authored-by: heihutu <[email protected]>
2026-09-01 17:08:46 +08:00
cxymdsandGitHub cee84561e7 fix(heal): preserve resumable retry signals (#6995) 2026-09-01 16:55:57 +08:00
Zhengchao AnandGitHub b09ce8e6b5 docs: make pre-pr validation conditional (#7001) 2026-09-01 16:48:21 +08:00
cxymdsandGitHub a45951260a test(ecstore): stabilize activation race capacity (#6989) 2026-09-01 16:05:04 +08:00
housemeandGitHub a41134eb8a chore(deps): update s3s and Rust MSRV (#6990)
* chore(deps): update s3s and Rust MSRV

* fix: silence startup IAM test hook warning
2026-09-01 07:35:08 +00:00
cxymdsandGitHub 35ce8cdb80 test(tier): enforce structured two-topology gate (#6985) 2026-09-01 14:12:11 +08:00
housemeandGitHub 0b1a588da5 fix: adapt object metadata to s3s DTO changes (#6981) 2026-09-01 13:04:23 +08:00
cxymdsandGitHub f6c6736a01 docs(deps): expand tokio-tar cleanup contract (#6984) 2026-09-01 12:07:07 +08:00
ab44ae7e83 fix(scanner): add supported usage state reset (#6972)
Add an authenticated scanner usage-state reset endpoint that publishes a fenced bootstrap marker for full rebuilds instead of requiring operators to delete usage metadata by hand.

Guard the reset with the scanner leader lock, storage publication epoch, and per-slot revision preconditions, and make startup resumable across stale cleanup leftovers while still rejecting newer conflicting usage state.

Co-authored-by: heihutu <[email protected]>
Co-authored-by: Zhengchao An <[email protected]>
2026-09-01 01:51:46 +00:00
Zhengchao AnandGitHub b0256e3453 test(e2e): cover mixed-version rolling upgrades (#6975) 2026-09-01 07:09:00 +08:00
Zhengchao AnandGitHub 14a77f9d79 fix(ecstore): supplement split latest listings (#6977) 2026-09-01 07:08:25 +08:00
Zhengchao AnandGitHub 436a1be899 ci: allow macOS release builds to finish (#6976) 2026-09-01 05:01:23 +08:00
1ea1dfa0a1 fix(put): honor bucket default SSE in path selection (#6970)
Co-authored-by: heihutu <[email protected]>
2026-09-01 03:43:39 +08:00
Zhengchao AnandGitHub c45a8c35c4 test(ecstore): cover disk metric sequence snapshot (#6872) 2026-09-01 03:43:26 +08:00
唐小鸭andGitHub 4932d1dedf fix(admin): allow re-pairing non-empty sites in site replication add (#6961)
The add preflight unconditionally rejected any topology with data on
more than one site, which made `replicate remove` a one-way door: a DR
cluster whose sites both hold data could never be re-paired, and the
only way out was wiping one side by hand.

Admit a multi-non-empty add when every bucket held by more than one
requested site is provably safe to merge through the existing
backfill/resync convergence: versioning must be Enabled on every holder
(so a same-key object from the peer lands as another version instead of
replacing the only copy) and object-lock enablement must match (lock
cannot be toggled after bucket creation). Incompatible adds are still
rejected, now with the operator recovery steps (empty one side, re-run
replicate add, resync) instead of a bare refusal. Bucket configs that
fail to decode fail the preflight closed.

A committed add now also clears this site's own half-finished
pending_remove, mirroring the join receiver (rustfs/rustfs#5963);
otherwise the reconcile tick would replay the stale removal against the
freshly re-paired peer and dismantle the new pairing.

Refs rustfs/backlog#2070
2026-09-01 01:21:06 +08:00
cxymdsandGitHub 041af14143 perf(s3): bound snowball member imports (#6945)
* fix(s3): harden Snowball extract error boundaries

* fix(s3): close Snowball extract compatibility gaps

* fix(s3): verify Snowball request body completion

* test(s3): reject forged Snowball streaming signatures

* build(deps): pin Snowball archive parser limits

* fix(s3): preserve Snowball trailer and member errors

* docs(architecture): register Snowball tar fork cleanup

* refactor(s3): route Snowball errors through object boundary

* ci(deps): allow pinned tokio-tar source

* fix: align Snowball archive codec detection

* fix(s3): harden Snowball codec compatibility

* fix(s3): preserve Snowball codec compatibility

* test(zip): align yield wake assertion with Tokio

* fix(rio): preserve legacy large-block reads

* fix(zip): accept blank tar numeric fields

* fix(s3): align Snowball member import semantics

* fix(s3): authorize PAX legal-hold conditions

* refactor(s3): preserve Snowball error boundary

* fix(iam): support legal-hold policy conditions

* perf(s3): bound Snowball member imports

* fix(s3): preserve bounded Snowball import contracts

* fix(s3): preserve bounded import invariants

* fix(s3): route Snowball limits through app boundary

* refactor(s3): keep bounded import errors behind facade

* fix(s3): satisfy Snowball feature lint gates

* fix(s3): preserve Snowball safety guardrails
2026-08-31 16:45:32 +00:00
e44007012b fix(scanner): recover legacy usage floor from backup (#6964)
* fix(scanner): recover legacy usage floor from backup

Allow scanner usage-floor startup and leadership fencing to use a valid legacy backup when the legacy primary read fails with a corruption-shaped error.

Keep v2 primary read failures, stale metadata, transient I/O, and missing or invalid backups fail-closed.

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): cover legacy backup fencing gaps (#6966)

* fix(scanner): recover legacy usage from valid backup

* fix(scanner): recover legacy usage floor from backup

Allow scanner usage-floor startup and leadership fencing to use a valid legacy backup when the legacy primary read fails with a corruption-shaped error.

Keep v2 primary read failures, stale metadata, transient I/O, and missing or invalid backups fail-closed.

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): cover legacy backup fencing gaps

---------

Co-authored-by: houseme <[email protected]>
Co-authored-by: heihutu <[email protected]>

---------

Co-authored-by: heihutu <[email protected]>
Co-authored-by: Henry Guo <[email protected]>
2026-09-01 00:01:38 +08:00
Zhengchao AnandGitHub e3ca1ca54c chore(release): prepare 1.0.0-rc.5 (#6968) 2026-08-31 23:54:35 +08:00
cxymdsandGitHub ec0a65703a fix(s3): align snowball member semantics (#6944)
* fix(s3): harden Snowball extract error boundaries

* fix(s3): close Snowball extract compatibility gaps

* fix(s3): verify Snowball request body completion

* test(s3): reject forged Snowball streaming signatures

* build(deps): pin Snowball archive parser limits

* fix(s3): preserve Snowball trailer and member errors

* docs(architecture): register Snowball tar fork cleanup

* refactor(s3): route Snowball errors through object boundary

* ci(deps): allow pinned tokio-tar source

* fix: align Snowball archive codec detection

* fix(s3): harden Snowball codec compatibility

* fix(s3): preserve Snowball codec compatibility

* test(zip): align yield wake assertion with Tokio

* fix(rio): preserve legacy large-block reads

* fix(zip): accept blank tar numeric fields

* fix(s3): align Snowball member import semantics

* fix(s3): authorize PAX legal-hold conditions

* refactor(s3): preserve Snowball error boundary

* fix(iam): support legal-hold policy conditions
2026-08-31 15:18:28 +00:00
hectorandGitHub 0d1e40ee73 ci: add storage engine workflow to functional test chain (#6971) 2026-08-31 23:05:15 +08:00
281e40f1cc fix(scanner): fit usage persistence within publication lease (#6967)
Lower the default scanner cache save timeout so the derived usage persistence budget stays inside the effective distributed publication lease window.

Add focused regressions for the default publication budget and bootstrap-pending observational baselines, and update operator docs with the new default.

Co-authored-by: heihutu <[email protected]>
2026-08-31 22:58:29 +08:00
7541bb2c5d fix(ecstore): stabilize decommission capacity retries (#6959)
* fix(heal): retry unavailable recreate targets

* fix(heal): refresh put-file epochs after target restart

* test(e2e): harden heal restart evidence

Co-Authored-By: heihutu <[email protected]>

* test(e2e): cancel competing heal before restart

Co-Authored-By: heihutu <[email protected]>

* fix(ecstore): complete decommission capacity recovery

* fix(ecstore): stabilize decommission capacity tests

Keep decommission test capacity snapshots deterministic across startup and mutation probes, serialize capacity-ledger entries during retries, and avoid reacquiring a multipart fence already covered by the outer migration fence.

Co-Authored-By: heihutu <[email protected]>

* fix(ecstore): satisfy decommission test lint

Co-Authored-By: heihutu <[email protected]>

* fix(ecstore): restore free-version decommission owner

Co-Authored-By: heihutu <[email protected]>

---------

Co-authored-by: marshawcoco <[email protected]>
Co-authored-by: heihutu <[email protected]>
Co-authored-by: overtrue <[email protected]>
2026-08-31 22:52:47 +08:00
25dd879cf4 test(perf): add PUT after-probes and node telemetry (#6965)
Co-authored-by: heihutu <[email protected]>
2026-08-31 14:28:13 +00:00
唐小鸭andGitHub af1ebbfb8e fix(admin): converge site replication IAM deletions and retry backoff (#6962)
* fix(admin): replay recorded IAM deletions in site replication retry drain

An IAM deletion whose peer delivery failed during an outage window was
previously unrecoverable without a manual repair: the collapsed retry
entry carries no body, the snapshot resend cannot express "this entity
no longer exists", and the drain escalated the entry into a permanent
marker. The deleted user kept working credentials on the peer until an
operator intervened — a security exposure (backlog#2071).

Record the verbatim deletion body (user/policy/group-removal/
policy-mapping-clear/service-account) in the persisted state, in the
same transaction as the retry-event upsert. The drain now replays the
recorded deletions before the snapshot resend — snapshot-after ordering
restores any entity recreated locally in the meantime — and settles the
collapsed entry when its whole liability is provably replayed. Entries
that predate recording, merge with legacy rows, or overflow the
per-peer record cap keep the escalation semantics: only explicitly
recorded deletion events are ever replayed, never a cross-site diff.

The peer apply handlers become idempotent for deletion shapes (missing
policy/group/member tolerated, matching the existing user-delete
tolerance), so a replayed deletion that already converged settles
instead of wedging the drain. The IAM change hook now attempts every
peer instead of failing fast, so a multi-site broadcast books a retry
entry (and deletion record) for each unreachable peer rather than only
the first. Repair success and peer removal clear the affected peer's
records alongside the entries they accompany.

* fix(admin): probe recovered peers to lift retry drain backoff

A bucket created while a peer was unreachable accumulated three failed
deliveries and entered exponential backoff (2400s and up, capped at a
day). After the peer recovered, the reconcile tick's drain kept
skipping the entry until the backoff elapsed, so the site stayed
diverged — NoSuchBucket resync noise on the source, missing bucket on
the peer — for up to 24 hours with nothing else driving convergence
(backlog#2071, round-four R1.6).

Split reachability from replay: the drain now probes each peer whose
replayable backlog is held back only by backoff (one cheap devnull POST
per peer per tick) and promotes the backlog when the peer answers, so a
recovered peer converges at the next 600s tick. A failed probe advances
nothing — retry counts only move on real delivery attempts, keeping the
exponential schedule intact for a peer that is genuinely down. The base
backoff still floors re-attempts against a reachable peer that keeps
rejecting a delivery. SITE_REPLICATION_RETRY_FAILED_AFTER stays at 3:
the flag is retryStats visibility only, and with the probe in place an
early failed mark is a timely operator signal rather than a dead end.

The drain tick also logs an operator-visible warning whenever the queue
holds failed or escalated entries, instead of backing off in silence.
2026-08-31 22:16:51 +08:00
唐小鸭andGitHub 3e3eb4d8d5 fix(replication): let replicated version purges pass the peer WORM gate (#6960)
A replicated version purge reaches the peer without the governance
bypass header, so a GOVERNANCE-retained version deleted on the source
with x-amz-bypass-governance-retention was rejected by the peer's WORM
deletion gate forever: retryStats ended at a permanent failed count and
the sites stayed diverged (issue #6850).

The source is authoritative for such a purge: the same WORM gate
already ran there, and GOVERNANCE retention with an authorized bypass
is the only lock state it can purge through. The peer's commit-time
deletion gate now treats an authorized replication delete addressed to
an explicit version as carrying that judged bypass, reusing the same
trust judgment as the replication write exemption
(ObjectOptions::replication_request, set only after the handler
authorized ReplicateDeleteAction). COMPLIANCE retention and legal hold
keep blocking replicated purges, and a plain client delete without the
bypass header stays rejected.
2026-08-31 22:16:38 +08:00
cxymdsandGitHub 48b6548988 ci(pool): add stage-aware expansion diagnostics (#6963) 2026-08-31 13:55:31 +00:00
cxymdsandGitHub 655f6ae452 fix(s3): align Snowball codec compatibility (#6943)
* fix(s3): harden Snowball extract error boundaries

* fix(s3): close Snowball extract compatibility gaps

* fix(s3): verify Snowball request body completion

* test(s3): reject forged Snowball streaming signatures

* build(deps): pin Snowball archive parser limits

* fix(s3): preserve Snowball trailer and member errors

* docs(architecture): register Snowball tar fork cleanup

* refactor(s3): route Snowball errors through object boundary

* ci(deps): allow pinned tokio-tar source

* fix: align Snowball archive codec detection

* fix(s3): harden Snowball codec compatibility

* fix(s3): preserve Snowball codec compatibility

* test(zip): align yield wake assertion with Tokio

* fix(rio): preserve legacy large-block reads

* fix(zip): accept blank tar numeric fields
2026-08-31 13:09:51 +00:00
61821a6f3e fix(heal): resume remote rebuilds after target restart (#6941)
* fix(heal): retry unavailable recreate targets

* fix(heal): refresh put-file epochs after target restart

* test(e2e): harden heal restart evidence

Co-Authored-By: heihutu <[email protected]>

* test(e2e): cancel competing heal before restart

Co-Authored-By: heihutu <[email protected]>

---------

Co-authored-by: houseme <[email protected]>
Co-authored-by: heihutu <[email protected]>
2026-08-31 19:39:47 +08:00
hectorandGitHub 896781a52b feat(ci): add upgrade compatibility suite and reorder functional chain (#6950)
New RustFS Upgrade Test workflow (SUITE: upgrade) runs first in the
nightly functional chain:

- Nightly GNU Build -> Upgrade -> S3 -> KMS -> Tier -> Pool/Heal -> Security
- S3 compatibility now triggers on "RustFS Upgrade Test" completion, so an
  upgrade regression gates the rest of the chain.
- Security suite moves to the end, after pool/heal, on the shared VMs.
- The upgrade suite drives auto-testing's rustfs-upgrade-test.sh
  (UPG-101..402): seed golden data/identity/config on the OLD deb, upgrade
  in place to the NEW deb, verify byte-identical preservation, and publish
  functional-reports/upgrade/<date>.md.
- Add the Upgrade tab to every dashboard index writer so the shared
  functional/index.html stays consistent.
2026-08-31 19:32:07 +08:00
hectorandGitHub 612dd38fea ci(kms): add enforcement/frame/config-secret lane inputs (#6946)
New backlog#2024 KMS supplements (KMS-106..502) are gated behind node env
flags. Add workflow_dispatch inputs that append the corresponding
KEY=VALUE lines to /etc/default/rustfs via the suite's --extra-env option:

- enforce_sse_key_policy -> RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY (KMS-401/402)
- frame_v2               -> RUSTFS_ENCRYPTION_FRAME_V2 (KMS-318)
- config_secret          -> RUSTFS_KMS_CONFIG_SECRET (KMS-107)

Nightly runs keep the default local+vault-kv2 lane unchanged.
2026-08-31 19:31:51 +08:00
cxymdsandGitHub ff28b79088 fix(s3): harden Snowball archive extraction (#6942)
* fix(s3): harden Snowball extract error boundaries

* fix(s3): close Snowball extract compatibility gaps

* fix(s3): verify Snowball request body completion

* test(s3): reject forged Snowball streaming signatures

* build(deps): pin Snowball archive parser limits

* fix(s3): preserve Snowball trailer and member errors

* docs(architecture): register Snowball tar fork cleanup

* refactor(s3): route Snowball errors through object boundary

* ci(deps): allow pinned tokio-tar source

* ci(e2e): refresh Snowball smoke selection
2026-08-31 11:18:09 +00:00
唐小鸭andGitHub 35456bcede test(scanner): serialize tests sharing process-global scanner state (#6940)
Under the cargo test fallback (threads in one process), tests that touch
the process-global scanner cycle recovery status or the global usage-save
metrics raced each other and failed randomly in full-suite runs.

Mark all touchers with #[serial] per docs/testing/README.md:
- 22 tests reading or writing scanner_cycle_recovery_status() via
  load_scanner_cycle_state_for_startup / reset_scanner_cycle_recovery
- 24 tests mutating global_metrics() usage-save counters via
  store_data_usage_in_backend*, which raced the existing serial
  test_deferred_usage_save_keeps_last_real_save_metric

No-op under nextest, which isolates each test in its own process.
2026-08-31 18:20:25 +08:00
Zhengchao AnandGitHub 9d4ccb7884 fix(ecstore): finalize decommission capacity recovery (#6955) 2026-08-31 18:09:09 +08:00
Zhengchao AnandGitHub 9a22cb85f3 fix(ecstore): complete decommission capacity recovery (#6949) 2026-08-31 16:53:14 +08:00
Zhengchao AnandGitHub 6c67086d0b fix(ecstore): reserve decommission capacity safely (#6917) 2026-08-31 15:20:09 +08:00
hectorandGitHub ea01cd339c fix(ci): continue functional chain and publish heal/pool reports (#6932) 2026-08-31 15:19:46 +08:00
bb37841362 chore(capacity): update default refresh tuning (#6938)
Align object-capacity refresh defaults with the production-oriented environment values and keep docs, script examples, and tests in sync.

Co-authored-by: heihutu <[email protected]>
2026-08-31 14:43:59 +08:00
GatewayJandGitHub 59a7194d7f feat(s3select): schedule streaming progress events (#6913)
* feat(s3select): schedule streaming progress events

* test(s3select): poll permit release until timeout
2026-08-31 13:36:47 +08:00
GatewayJandGitHub f647ada320 feat(table-catalog): update object namespace properties (#6815)
* feat(table-catalog): update object namespace properties

* test(table-catalog): cover object namespace properties

* fix(ci): use admin storage contract in catalog test

* fix(table-catalog): reject corrupt namespace properties
2026-08-31 13:36:23 +08:00
GatewayJandGitHub 589a954478 feat(s3select): support compressed CSV and JSON input (#6915) 2026-08-31 13:35:59 +08:00
1d606e1cf6 perf(ecstore): retry degraded GET with late parity (#6933)
Co-authored-by: heihutu <[email protected]>
2026-08-31 13:32:46 +08:00
Zhengchao AnandGitHub dc2e25b48c fix(admin): preserve raw XML in metadata backups (#6936) 2026-08-31 13:15:35 +08:00
housemeandGitHub d690f5d60d test(ecstore): stabilize tier recovery cursor fixture (#6935) 2026-08-31 12:09:19 +08:00
housemeandGitHub 3eca80e37d test(ecstore): make heal rename fixture deterministic (#6934) 2026-08-31 12:09:01 +08:00
45a2ccb734 fix(ecstore): recover late parity after exact quorum (#6927)
Co-authored-by: heihutu <[email protected]>
2026-08-31 03:26:20 +00:00
c876df53f5 fix(ecstore): fence snapshot stream polls on lock loss (#6930)
Co-authored-by: heihutu <[email protected]>
2026-08-31 02:26:24 +00:00
Zhengchao AnandGitHub 769da6d81f test(admin): prove backup import rollback compatibility (#6929) 2026-08-31 02:19:37 +00:00
Zhengchao AnandGitHub ca46ae9e56 test(ecstore): pin bucket metadata rollback reads (#6928) 2026-08-31 01:48:03 +00:00
Zhengchao AnandGitHub 7df0920c80 test(replication): bind writable paths to DTO fields (#6923) 2026-08-31 00:48:10 +00:00
Zhengchao AnandGitHub c4ac11d22e fix(scanner): persist decommission catch-up debt (#6922) 2026-08-31 08:45:36 +08:00
602ed2cbcd test(ecstore): add targeted refresh-loss harness (#6924)
Co-authored-by: heihutu <[email protected]>
2026-08-31 08:45:04 +08:00
hectorandGitHub b6c3108e53 fix(ci): keep functional workflow chain running after failures (#6926)
* fix(ci): isolate s3 compat temp file paths

* fix(ci): use rooted auto-testing s3 temp fix

* fix(ci): follow auto-testing main after temp-path merge

* fix(ci): stabilize tier mqtt bootstrap on shared runner

* feat(ci): publish functional reports and keep workflows non-blocking

* fix(ci): keep functional chain running after failures

* fix(ci): standardize functional workflow cleanup steps
2026-08-31 08:43:28 +08:00
8ecd8f2520 fix(scanner): preserve cache cycle during usage recovery (#6921)
Co-authored-by: heihutu <[email protected]>
2026-08-31 00:19:54 +00:00
Zhengchao AnandGitHub e6234d3714 test(ecstore): pin default bucket config bytes (#6920) 2026-08-31 00:03:07 +00:00
Zhengchao AnandGitHub 042a0c3014 docs: register persisted XML compatibility cleanup (#6918)
docs: register persisted XML compatibility
2026-08-30 23:53:02 +00:00
87333f7b24 test(e2e): exercise cluster volume fault proxy (#6919)
Co-authored-by: heihutu <[email protected]>
2026-08-30 23:37:34 +00:00
Zhengchao AnandGitHub 9945c67f7e fix(ecstore): supervise decommission worker recovery (#6908) 2026-08-31 06:18:00 +08:00
fca1514aac fix(scanner): recover legacy empty usage floor (#6914)
Co-authored-by: heihutu <[email protected]>
2026-08-31 06:17:26 +08:00
47ad69b691 fix(ecstore): fail closed on unverifiable data quorum (#6903)
fix(ecstore): require verification source for degraded GET

Fail closed when reconstruction has only an exact decode quorum, because no surplus source remains to validate the rebuilt data. Cover both erasure engines and the data-shards-only rollout gate.

Co-authored-by: heihutu <[email protected]>
2026-08-30 21:07:47 +00:00
489408c0b0 perf(ecstore): reuse prepared Select metadata (#6911)
Co-authored-by: heihutu <[email protected]>
2026-08-30 20:41:24 +00:00
1b3744a1da test(perf): align GET attribution harness with backlog 2093 (#6912)
Co-authored-by: heihutu <[email protected]>
2026-08-30 20:28:28 +00:00
9244eb36ed test(e2e): route cluster volume endpoints through fault proxy (#6909)
Co-authored-by: heihutu <[email protected]>
2026-08-30 20:17:16 +00:00
442298d5f7 test(ecstore): prove in-flight prefetch cancellation (#6904)
Co-authored-by: heihutu <[email protected]>
2026-08-30 19:17:44 +00:00
be7d35d441 perf(get): release disk permits for buffered bodies (#6906)
Co-authored-by: heihutu <[email protected]>
2026-08-30 19:10:26 +00:00
唐小鸭andGitHub ec1cd606d3 fix(replication): surface object-lock denied purges and back off heal retries (#6900) 2026-08-30 18:59:55 +00:00
16af688a7a fix(rpc): reject unsigned v2 control mutations (#6905)
Co-authored-by: heihutu <[email protected]>
2026-08-30 18:17:43 +00:00
唐小鸭andGitHub 37b23a16da fix(replication): verify replica integrity and default to plain signed payloads (#6895) 2026-08-31 01:43:45 +08:00
006e9b7d28 test(e2e): cover four-node four-drive cluster topology (#6902)
Co-authored-by: heihutu <[email protected]>
2026-08-30 17:32:36 +00:00
d214c27583 perf(ecstore): consolidate non-inline read planning (#6892)
Co-authored-by: heihutu <[email protected]>
2026-08-30 17:15:48 +00:00
GatewayJandGitHub 8fd364a99c feat(s3-tables): support object-backed table rename (#6899) 2026-08-31 00:30:49 +08:00
c2d8488728 docs(architecture): reconcile generation contract (#6901)
Co-authored-by: heihutu <[email protected]>
2026-08-31 00:20:39 +08:00
唐小鸭andGitHub 1370434f3a fix(scanner): unblock quota usage baseline on never-converged sites (#6896) 2026-08-31 00:20:04 +08:00
唐小鸭andGitHub 5dde2c188c fix(replication): retry failed multipart aborts on bounded backoff (#6897) 2026-08-31 00:19:49 +08:00
2f9c75d04f perf(ecstore): reuse prepared metadata across pools (#6889)
Co-authored-by: heihutu <[email protected]>
2026-08-30 16:15:10 +00:00
唐小鸭andGitHub 9ee7b1221d fix(admin): replicate user secret-key rotation to peer sites (#6893) 2026-08-30 23:32:07 +08:00
Zhengchao AnandGitHub fcc3c7fb6b test(s3): promote passing compatibility cases (#6891) 2026-08-30 21:56:17 +08:00
Zhengchao AnandGitHub 01dc55ee5b docs(security): add unsigned presign header lesson (#6894) 2026-08-30 21:55:47 +08:00
3d24526704 fix(ecstore): preserve parity reserves for data-only GET (#6888)
fix(ecstore): hedge data-only GET with parity

Route the opt-in data-shards-only lockstep path through the bounded parity race and preserve deferred parity reserves across canceled hedges.

Co-authored-by: heihutu <[email protected]>
2026-08-30 20:16:32 +08:00
51532e19fb test(ecstore): cover multipart snapshot overwrite race (#6887)
test(ecstore): cover multipart GET overwrite snapshot

Co-authored-by: heihutu <[email protected]>
2026-08-30 12:15:02 +00:00
931ff60182 test(ci): refresh cluster nightly selection (#6886)
Co-authored-by: heihutu <[email protected]>
2026-08-30 12:10:07 +00:00
07212c4e26 perf(ecstore): gate quorum-aware GET early stop (#6885)
* perf(ecstore): add gated two-phase GET metadata reads

Co-Authored-By: heihutu <[email protected]>

* fix(ecstore): require data-shard coverage for read plans

Co-Authored-By: heihutu <[email protected]>

* perf(ecstore): avoid inline overhead in read plan rollout

Co-Authored-By: heihutu <[email protected]>

* perf(ecstore): accept quorum-complete read candidates

Co-Authored-By: heihutu <[email protected]>

---------

Co-authored-by: heihutu <[email protected]>
2026-08-30 09:33:35 +00:00
GatewayJandGitHub 4932af080b feat(s3select): expand typed JSON source paths (#6864) 2026-08-30 06:46:36 +00:00
GatewayJandGitHub d6f9a7c462 feat(table-catalog): vend credentials from LoadTable (#6878)
* feat(table-catalog): vend credentials from LoadTable

* fix(table-catalog): preserve entry-relative metadata paths
2026-08-30 06:33:28 +00:00
7345b49cf6 perf(ecstore): gate GET metadata timing when metrics off (#6879)
Co-authored-by: heihutu <[email protected]>
2026-08-30 05:43:08 +00:00
housemeandGitHub 4753e35035 chore(deps): update flake.lock (#6880) 2026-08-30 13:14:13 +08:00
GatewayJandGitHub 96239fc034 feat(s3select): report uncompressed input byte metrics (#6865) 2026-08-30 04:07:20 +00:00
hectorandGitHub b428875bed fix(ci): stabilize tier MQTT bootstrap on shared runner (#6877)
* fix(ci): isolate s3 compat temp file paths

* fix(ci): use rooted auto-testing s3 temp fix

* fix(ci): follow auto-testing main after temp-path merge

* fix(ci): stabilize tier mqtt bootstrap on shared runner
2026-08-30 11:14:28 +08:00
Zhengchao AnandGitHub cf362282f0 fix(test): serialize transition matrix tests under nextest (#6874)
The transition_matrix_tests use #[serial_test::serial] which has no
effect under nextest (each test runs in a separate process). When running
alongside thousands of other ecstore tests, the shared metadata cache
generation counter can race, causing intermittent 'metadata read should
publish the generation under test' panics.

Add both tests to the ecstore-serial-flaky test group in both default
and ci nextest profiles so they run single-threaded.
2026-08-30 10:42:23 +08:00
Zhengchao AnandGitHub b2a2e637a5 fix(ci): refresh Linux full E2E selection (#6875) 2026-08-30 10:42:14 +08:00
cxymdsandGitHub 0c18012442 fix(admin): version remote target credential capabilities (#6876) 2026-08-30 10:42:10 +08:00
ee39e4fccb fix(scanner): own publication mutations through storage drain (#6867)
* fix(scanner): own publication mutations through storage drain

Co-Authored-By: heihutu <[email protected]>

* fix(storage): remove unused rename data shim

Co-Authored-By: heihutu <[email protected]>

---------

Co-authored-by: heihutu <[email protected]>
2026-08-30 02:39:07 +00:00
90ab2e24c3 perf(ecstore): reuse local fd metadata snapshots (#6868)
* perf(ecstore): reuse local fd metadata snapshots

Cache the validated shard length beside each reusable descriptor so read hits avoid a repeated fstat while retaining generation and mutation invalidation semantics.

Co-Authored-By: heihutu <[email protected]>

* fix(ecstore): pass cached entry to fd cache

Co-Authored-By: heihutu <[email protected]>

---------

Co-authored-by: heihutu <[email protected]>
2026-08-30 09:08:44 +08:00
cxymdsandGitHub 21e5b3dc64 fix(ecstore): require durable decommission ledger format (#6871) 2026-08-30 08:47:09 +08:00
cxymdsandGitHub 1e8c8d4cd5 feat(replication): support temporary target credentials (#6860) 2026-08-30 08:44:34 +08:00
ff3ad30f0c fix(scanner): bound publication proof retries on main (#6870)
* fix(scanner): retain completed publication candidates

* fix(scanner): export publication activity helper

* test(ecstore): retain activity snapshot across retries

* fix(scanner): rebase publication proof retry onto main

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): resolve publication proof retry conflicts

Co-Authored-By: heihutu <[email protected]>

---------

Co-authored-by: Henry Guo <[email protected]>
Co-authored-by: heihutu <[email protected]>
2026-08-29 22:41:36 +00:00
47a3f5ef01 perf(ecstore): converge disk metric atomic loads (#6866)
Use the seqlock version as the publication fence and keep payload reads relaxed while validating the final version. This reduces ordering overhead in disk metric recording and snapshot collection without changing the rolling-window contract.

Co-authored-by: heihutu <[email protected]>
2026-08-29 20:38:59 +00:00
a22fa7461d perf(put): adapt eager threshold to concurrency (#6863)
Co-authored-by: heihutu <[email protected]>
2026-08-29 20:21:05 +00:00
814ab5bbf3 fix(ecstore): classify system metadata failures (#6862)
fix(ecstore): classify system metadata volume failures

Preserve retryable quorum errors when system metadata reads or writes encounter missing volumes, and cover the create-bucket data-usage path with regressions.

Co-authored-by: heihutu <[email protected]>
2026-08-29 19:48:50 +00:00
498205b7ec fix(ecstore): keep 1MiB GET off mid-size reader (#6861)
Co-authored-by: heihutu <[email protected]>
2026-08-29 19:39:51 +00:00
c235f7c05d fix(scanner): retain usage across transient peer failures (#6859)
* test(scanner): cover bucket drive guard lifecycle

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): recover usage floor from fenced backups

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): retry transient activity probes

Retry one failed scanner activity probe after a bounded reconnect when the failure is transport-like or timed out. Keep protocol and response validation failures fail-closed.

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): retain post-scan observations

Preserve a complete scanner walk as a non-converged observation when the final activity probe is unavailable. Advance the cycle as partial without acknowledging dirty usage.\n\nCo-Authored-By: heihutu <[email protected]>

* fix(scanner): classify publication lease deferrals

Distinguish persistence budget and lease deadline deferrals from unavailable activity baselines, and ensure lease-gate deferrals update usage metrics. Keep the fixed lease gate fail-closed while storage-owned commit scope work remains pending.

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): recover usage floor from fenced backups

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): preserve publication lease defer reasons

Keep lease expiry and release failures distinct from activity baseline failures so scanner freshness metrics and cycle outcomes identify the publication barrier that blocked progress. Preserve fail-closed behavior.

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): reuse recovered usage baseline for publication

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): fence legacy usage floor fallback

Co-Authored-By: heihutu <[email protected]>

* fix(scanner): use typed activity timeout error

---------

Co-authored-by: heihutu <[email protected]>
2026-08-30 02:54:20 +08:00
cxymdsandGitHub 64cca79fbb feat(admin): expose remote target credential capability state (#6857) 2026-08-30 00:42:14 +08:00
759e1041bd feat(nix): add NixOS service module and client package (#6856)
Co-authored-by: heihutu <[email protected]>
2026-08-29 16:14:44 +00:00
GatewayJandGitHub 8055aeb1d4 test(s3select): cover SelectRequest XML root alias (#6820) 2026-08-29 14:14:12 +00:00
GatewayJandGitHub 79bd6fa862 fix(s3select): return encryption response headers (#6819) 2026-08-29 19:46:34 +08:00
fa0be5d271 fix(ci): split workflows and add perf version reporting (#6848)
* ci: pass package selector to test run steps (fix rc.3 fallback)

* fix(ci): split workflows and add perf version reporting

* fix(ci): enforce strict shared workflow order

---------

Signed-off-by: houseme <[email protected]>
Co-authored-by: houseme <[email protected]>
2026-08-29 19:32:19 +08:00
78cb142c91 fix(s3): accept empty put without content length (#6849)
Co-authored-by: heihutu <[email protected]>
2026-08-29 19:31:33 +08:00
hectorandGitHub 5fa3d2a682 ci: pass package selector to test run steps (fix rc.3 fallback) (#6846) 2026-08-29 17:34:04 +08:00
hectorandGitHub fd8ddf0a02 fix: remove redundant --repo flag in preview release cleanup (#6847)
The check_preview_release_workflow.sh script uses exact line matching
(grep -Fxq) to verify the cleanup-preview-releases job contains:

  gh release delete "$preview_tag" --yes

The extra --repo flag is unnecessary in GitHub Actions context since
gh auto-detects the repository from GITHUB_REPOSITORY, and it causes
the Workflow Pin Report check to fail on all PRs.
2026-08-29 17:33:34 +08:00
Zhengchao AnandGitHub e1ea99ff06 fix(s3): return BadDigest for Content-MD5 mismatch (#6842) 2026-08-29 09:00:18 +00:00
唐小鸭andGitHub 11c6ee42ea fix(kms): restore persisted configuration after restart (#6821)
* fix(kms): restore persisted configuration after restart

* docs(kms): cover the reload route and startup load states

The admin contract matrix pins every dynamic KMS route for the rc and
console handoff, so the new POST /kms/reload needs a row there, and the
reload response reuses the configure snapshot shape rather than adding a
wire type. The observability runbook gains the operator procedure the
reload exists for: telling a load_failed startup apart from a server
that was never configured, and recovering without resubmitting secrets.
2026-08-29 16:21:22 +08:00
hectorandGitHub 9307d2c8a8 ci: make MQTT broker setup deterministic in functional test suite (#6837)
* ci: make MQTT broker setup deterministic in functional test suite

* ci: default functional test suite to latest nightly deb
2026-08-29 15:59:56 +08:00
hectorandGitHub 84c5f2170f ci: upload performance report to rustfs/dashboard reports/YYYY-MM-DD.md (#6843)
* ci: upload performance report to rustfs/dashboard reports/YYYY-MM-DD.md

* ci: update token comment to dashboard

* ci: english-only report metadata in performance workflow
2026-08-29 15:50:46 +08:00
唐小鸭andGitHub e009eab4f1 fix(replication): surface failed objects and abort orphaned uploads (#6840)
fix(replication): surface per-object failures and abort orphaned multipart uploads

Replication could mark an object FAILED with no server-log line naming
the object: the target-offline skip paths logged at debug without the
object key, and several failure branches omitted the key entirely. A
failed multipart transfer also leaked its incomplete upload on the
target, since nothing ever aborted it after CreateMultipartUpload
succeeded.

Log the offline skips at warn with the object key (they report the
object FAILED, matching the per-object put_object failure level), add
the object field to the remaining failure branches, and abort the
target-side multipart upload best-effort on any post-create failure
without masking the original transfer error.

Fixes #6825
2026-08-29 15:49:59 +08:00
唐小鸭andGitHub ab84c3f5cf fix(replication): keep versionId on version-purge delete replication (#6841)
fix(replication): never mint delete markers when replicating a version purge

Heal/resync/MRF rebuilds of a delete-marker version purge carry
delete_marker: true together with a purge-shaped entry. Passing that flag
straight into replication_delete_remove_options made the target DELETE
omit the versionId (marker-creation semantics), so a generic S3 target
that ignores the internal source-version headers minted a fresh delete
marker on every retry instead of purging one — the marker count on the
target grew monotonically (rustfs#6823).

- Gate marker-creation semantics on the new pure helper
  delete_replication_creates_marker (delete_marker && !version purge) so
  a purge always addresses the exact version.
- Stop falling through to the marker-creation send when the pre-send
  source delete-marker verification fails with a transient error; fail
  the entry instead so the MRF replay / heal scanner retries without
  minting a marker on the target.
- Pin the purge-shape contract with unit tests in
  crates/replication/src/delete.rs.
2026-08-29 15:49:50 +08:00
b5f9cbcee4 fix(heal): bound read-repair object commit locks (#6839)
Co-authored-by: heihutu <[email protected]>
2026-08-29 15:48:18 +08:00
Zhengchao AnandGitHub af6c229914 fix(ecstore): tier force removal bypasses lifecycle reference check (#6835) 2026-08-29 05:16:58 +00:00
c0155f0dfa fix(logging): bound ECStore debug output (#6809)
Also replace deprecated Atomic::fetch_update calls with try_update so the
current Rust toolchain keeps lint and CI jobs warning-clean.

Co-authored-by: heihutu <[email protected]>
2026-08-29 04:51:37 +00:00
435 changed files with 95526 additions and 18974 deletions
+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`
@@ -48,6 +48,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### S3 object actions, copy, multipart, and upload policy validation
- `GHSA-g8w9-qw9q-fghr`: a valid presigned `PutObject` accepted extra `x-amz-tagging`, website redirect, and storage-class headers omitted from `SignedHeaders`. Lesson: a presigned URL is a bounded capability; reject `x-amz-*` headers that are not cryptographically bound by the signature so unsigned metadata cannot change authorization, lifecycle, redirect, cost, or durability semantics.
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
@@ -119,7 +120,7 @@ Use these targeted searches when a diff touches security-sensitive code:
```bash
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
rg -n "authorize_operation|FtpsDriver|SftpDriver|RETR|MKD|SIZE|MDTM|CreateBucket|GetObject|HeadObject" crates/protocols rustfs
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|content-length-range|starts-with" rustfs crates
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|presign|SignedHeaders|content-length-range|starts-with" rustfs crates
rg -n "ListBucketVersions|GetObjectVersion|versionId|VersionId|ExistingObjectTag|ForAllValues|ForAnyValue|POLICY_PLUGIN|opa" rustfs crates
rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
@@ -136,6 +137,7 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases, and assert denied paths do not reach the storage backend.
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, and storage-class headers; require rejection before storage access, and verify explicitly signed equivalents still work.
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks.
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=d6aa36cfaae2c4d8590482c7e47138c5965b335b34a75f50d11ffc3366e9021e
sha256-linux=96db8060fce98addda4f69092d297ca236bec4892d820617a26a261eedac61b0
sha256-darwin=9dccb0cd537cf79ae70c1c20e8281d36d03f2f09f81142a5341e26e3dc18709d
sha256-linux=86e69337ad1440252a2ee20a12063c989ed12442d3b1ddf9e9233acf0f2ec089
+1 -1
View File
@@ -1 +1 @@
sha256=9b9bc336b43b70d0e06e0adb5455bf035bb18945d85d60936eb6fe4d48e0e680
sha256=d06524b44de97ed8f62b0fd8cf9fa504e3cd520ffcaacc32691d6f890ebe7f20
+1 -1
View File
@@ -1 +1 @@
sha256=294350518743cac8d7c41880a2835216e4b697908d7b0b1bc92b62816d94c59d
sha256=db9bd8cdcb0abe43461aa6b36499b17cabd4098e5b34e300b1a0f0d0f34d9884
+1 -1
View File
@@ -23,4 +23,4 @@ coverage: core-deps ## Workspace line coverage (cargo-llvm-cov + nextest; slow,
@mkdir -p target/llvm-cov
cargo llvm-cov report --lcov --output-path target/llvm-cov/lcov.info
cargo llvm-cov report --json --output-path target/llvm-cov/coverage.json
python3 scripts/coverage_per_crate.py target/llvm-cov/coverage.json
$(RUSTFS_PYTHON_BIN) scripts/coverage_per_crate.py target/llvm-cov/coverage.json
+1 -1
View File
@@ -88,7 +88,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
.PHONY: test-wiring-check
test-wiring-check: ## Check tests stay registered and selected by their intended runners
@echo "🧪 Checking test wiring..."
python3 ./scripts/check_test_wiring.py
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
.PHONY: log-analyzer-rules-check
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
+6 -5
View File
@@ -35,13 +35,14 @@ script-tests: ## Run shell script tests
./scripts/test_pinned_paired_abba_bench.sh
./scripts/test_manual_transition_runbooks.sh
./scripts/test_fuzz_runner.sh
./scripts/test_python_bin.sh
./scripts/check_embedded_secrets.sh --self-test
python3 ./scripts/check_test_wiring.py --self-test
python3 ./scripts/check_security_coverage.py --self-test
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
python3 ./scripts/s3-tests/test_report_compat.py
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
.PHONY: test
+84 -17
View File
@@ -46,6 +46,11 @@ e2e-reliability = { max-threads = 1 }
e2e-inline-boundaries = { max-threads = 1 }
e2e-cluster-nightly = { max-threads = 1 }
# Deep async storage futures are composed into tests across several crates.
# Keep the test stack bounded but above libtest's 2 MiB default.
[scripts.setup.ecstore-base-stack]
command = ['sh', '-c', 'echo RUST_MIN_STACK=4194304 >> "$NEXTEST_ENV"']
# These exact regression scenarios build deep async storage futures that exceed
# libtest's 2 MiB spawned-thread stack on Linux. Give only their test processes
# the same 32 MiB stack already used by the crate's dedicated large-stack tests.
@@ -60,9 +65,13 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
# --- default profile (local): serialize the flaky groups, never retry --------
[[profile.default.scripts]]
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|prepared_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)))$/)'
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
setup = 'ecstore-large-stack'
[[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))$/))'
setup = 'ecstore-base-stack'
[[profile.default.scripts]]
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
setup = 'lifecycle-large-stack'
@@ -80,6 +89,29 @@ test-group = 'ecstore-serial-flaky'
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
test-group = 'ecstore-serial-flaky'
# Serialize the heal result-report tests. Every test in the module builds a
# real-disk (TempDir-backed) hermetic erasure set and drives MiB-scale writes
# plus deep-scan heal — the same load-sensitive cross-disk IO shape as the
# crash_consistency scenarios above. Under a heavily parallel run a single
# disk's IO can fail while write quorum still holds, which flips per-disk
# readback and aggregate-outcome assertions nondeterministically (different
# tests each round; all pass standalone). Preventive serialization only, no
# retries. The matching ci-profile override is after [profile.ci].
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::heal::heal_result_report_tests::/)'
test-group = 'ecstore-serial-flaky'
# Serialize the metadata-cache generation-retirement pair. Both carry
# #[serial(metadata_cache_invalidation_probe)] — a no-op across nextest's
# process boundary — and assert get_object_metadata_cache generation
# semantics on a 4-disk hermetic set, the same load-sensitive shape that
# forced the transition matrix tests into this group. Preventive
# serialization only, no retries. The matching ci-profile override is after
# [profile.ci].
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & test(retires_cached_snapshot)'
test-group = 'ecstore-serial-flaky'
# The production-handler relocation regression builds an isolated 8-disk,
# 2-pool store and commits a 72 MiB multipart object. Keep that cross-disk IO
# from overlapping the ecstore commit fixtures above.
@@ -100,12 +132,29 @@ test-group = 'embedded-test-ports'
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
test-group = 'ecstore-serial-flaky'
# Serialize the transition matrix tests. They build a 4-disk hermetic erasure
# set, populate the get_object_metadata_cache, and assert generation lifecycle
# semantics. serial_test's #[serial] has no effect across nextest's process
# boundary, so concurrent execution races the shared metadata-cache generation
# counter and causes spurious "metadata read should publish the generation"
# panics. Preventive serialization, no retries.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & test(set_disk::transition_matrix_tests::)'
test-group = 'ecstore-serial-flaky'
# The durable ILM decommission regressions build isolated multi-pool stores and
# deliberately take source or target disks offline while checking fencing.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
test-group = 'ecstore-serial-flaky'
# Decommission entry and marker/barrier tests share process-wide fault hooks and
# deterministic commit barriers. Keep the whole init decommission family in one
# nextest group; serial_test alone cannot isolate separate test processes.
[[profile.default.overrides]]
filter = 'package(rustfs-ecstore) & test(/^store::init::tests::(decommission_|suspended_.*decommission)$/)'
test-group = 'ecstore-serial-flaky'
# Serialize the bucket-incarnation / lifecycle-fence tests. They drive
# init_bucket_metadata_sys and bucket_metadata_sys_of, i.e. process-global
# OnceLock state that serial_test's #[serial] cannot protect across nextest's
@@ -157,9 +206,13 @@ fail-fast = false
path = "junit.xml"
[[profile.ci.scripts]]
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|prepared_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)))$/)'
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
setup = 'ecstore-large-stack'
[[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))$/))'
setup = 'ecstore-base-stack'
[[profile.ci.scripts]]
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
setup = 'lifecycle-large-stack'
@@ -220,6 +273,20 @@ test-group = 'e2e-reliability'
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
test-group = 'ecstore-serial-flaky'
# Serialize the heal result-report tests under the ci profile too (see the
# matching default-profile override near the top). Not a quarantine: no
# retries, just serialized real-disk heal IO.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::heal::heal_result_report_tests::/)'
test-group = 'ecstore-serial-flaky'
# Serialize the metadata-cache generation-retirement pair under the ci
# profile too (see the matching default-profile override near the top). Not a
# quarantine: no retries.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(retires_cached_snapshot)'
test-group = 'ecstore-serial-flaky'
# Match the default-profile embedded test isolation without quarantining or
# retrying failures in CI.
[[profile.ci.overrides]]
@@ -232,10 +299,20 @@ test-group = 'embedded-test-ports'
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
test-group = 'ecstore-serial-flaky'
# Serialize the transition matrix tests under the ci profile too (see the
# matching default-profile override near the top). No retries.
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(set_disk::transition_matrix_tests::)'
test-group = 'ecstore-serial-flaky'
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
test-group = 'ecstore-serial-flaky'
[[profile.ci.overrides]]
filter = 'package(rustfs-ecstore) & test(/^store::init::tests::(decommission_|suspended_.*decommission)$/)'
test-group = 'ecstore-serial-flaky'
# Serialize the bucket-incarnation / lifecycle-fence tests under the ci profile
# too (see the matching default-profile override near the top). No retries.
[[profile.ci.overrides]]
@@ -278,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 —
@@ -400,7 +478,7 @@ path = "junit.xml"
[profile.e2e-nightly]
default-filter = """
package(e2e_test)
& test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_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)::/)
"""
fail-fast = false
@@ -431,7 +509,7 @@ 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
@@ -452,23 +530,12 @@ path = "junit.xml"
# parallel-safe — the same property e2e-smoke relies on. The exceptions are the
# 4-disk reliability / degraded-read fault-injection tests and the fixed-port
# Vault tests, both serialized below.
# KNOWN-FAILURE EXCLUSIONS (characterization run 29381309848, 2026-07-15:
# 341 ran / 32 failed on the suites' first automated run ever). Deterministic
# product failures cannot be quarantined away with retries, so each family is
# excluded here with its tracking issue, under the same discipline as the
# ci-profile quarantine (docs/testing/README.md): every entry MUST cite one
# OPEN issue, and the fixing PR MUST delete the exclusion. The passing
# negative-path siblings of each family stay in as regression guards.
# * rustfs#4843 — over-limit archive entry paths hard-reject the whole
# archive even under ignore-errors semantics.
[profile.e2e-full]
default-filter = """
package(e2e_test)
& !test(/^protocols::/)
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_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(/^replication_extension_test::/)
& !test(/^multipart_auth_test::test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled$/)
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(ignores_invalid_entries_when_requested|supports_standard_headers_with_combined_extract_options)$/)
"""
fail-fast = false
+1
View File
@@ -6,3 +6,4 @@ self-hosted-runner:
- sm-standard-4
- dind-sm-standard-2
- smoke-testing
- pf-testing
+12
View File
@@ -24,8 +24,11 @@ on:
- '.github/actions/**'
- '.github/workflows/**'
- 'scripts/release/create_or_update_release.sh'
- 'scripts/release/package_versions.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
pull_request:
types: [ opened, synchronize, reopened, closed ]
@@ -37,8 +40,11 @@ on:
- '.github/actions/**'
- '.github/workflows/**'
- 'scripts/release/create_or_update_release.sh'
- 'scripts/release/package_versions.sh'
- 'scripts/test_package_versions.sh'
- 'scripts/security/check_performance_ab_workflow.sh'
- 'scripts/security/check_preview_release_workflow.sh'
- 'scripts/security/check_tier_artifact_workflow.sh'
- 'scripts/security/check_workflow_pins.sh'
schedule:
# Daily, not weekly. This schedule exists to catch RustSec advisories
@@ -146,6 +152,12 @@ jobs:
- name: Check performance A/B workflow trust boundary
run: ./scripts/security/check_performance_ab_workflow.sh
- name: Check tier evidence workflow isolation
run: ./scripts/security/check_tier_artifact_workflow.sh
- name: Check package version contract
run: ./scripts/test_package_versions.sh
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
+3 -3
View File
@@ -244,7 +244,7 @@ jobs:
needs: [ build-check, prepare-platform-matrix ]
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
runs-on: ${{ matrix.os }}
timeout-minutes: 150
timeout-minutes: 180
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
# Release binaries ship without dial9 telemetry and therefore do not need
@@ -408,9 +408,9 @@ jobs:
if [[ "${{ matrix.cross }}" == "true" ]]; then
# All cross targets in the matrix are Linux; zigbuild handles them.
cargo zigbuild --release --target ${{ matrix.target }} -p rustfs --bins
cargo zigbuild --release --target ${{ matrix.target }} -p rustfs --bin rustfs
else
cargo build --release --target ${{ matrix.target }} -p rustfs --bins
cargo build --release --target ${{ matrix.target }} -p rustfs --bin rustfs
fi
- name: Create release package
+18 -6
View File
@@ -49,8 +49,20 @@ env:
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
jobs:
direct-upgrade:
name: Direct upgrade from rc.2
upgrade:
name: ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
- name: Direct upgrade from rc.2
cache_key: e2e-direct-upgrade
test: direct_upgrade_from_rc2_preserves_object_contracts
artifact: direct-upgrade
- name: Mixed-version rolling upgrade from rc.2
cache_key: e2e-mixed-version-upgrade
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
artifact: mixed-version-upgrade
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -64,7 +76,7 @@ jobs:
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: e2e-direct-upgrade
cache-shared-key: ${{ matrix.cache_key }}
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: "false"
@@ -89,17 +101,17 @@ jobs:
cargo build --locked -p rustfs --bin rustfs
: > target/debug/rustfs.features
- name: Run direct-upgrade compatibility test
- name: Run upgrade compatibility test
run: |
cargo test --locked -p e2e_test \
upgrade_compatibility_test::direct_upgrade_from_rc2_preserves_object_contracts \
"upgrade_compatibility_test::${{ matrix.test }}" \
-- --ignored --exact --nocapture
- name: Upload server logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: direct-upgrade-server-logs-${{ github.run_number }}
name: ${{ matrix.artifact }}-server-logs-${{ github.run_number }}
path: ${{ runner.temp }}/rustfs-upgrade-logs
if-no-files-found: warn
retention-days: 14
+2
View File
@@ -27,6 +27,7 @@ on:
paths:
- 'flake.nix'
- 'flake.lock'
- 'nix/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/nix.yml'
@@ -36,6 +37,7 @@ on:
paths:
- 'flake.nix'
- 'flake.lock'
- 'nix/**'
- 'Cargo.toml'
- 'Cargo.lock'
- '.github/workflows/nix.yml'
+170 -97
View File
@@ -21,10 +21,10 @@
# - workflow_run: automatically package after "Build and Release" completes
# for a release tag (the mac/windows/linux binaries are already uploaded
# to the GitHub release before packaging starts)
# - workflow_dispatch: manual fallback (backfill / re-run) with optional tag/run_id
# - workflow_dispatch: manual fallback with a release tag and/or exact build run ID
#
# Flow:
# 1. Resolve the triggering Build workflow run for the release tag
# 1. Resolve and validate the selected Build workflow run and source identity
# 2. Download Linux binaries (x86_64-gnu, aarch64-gnu) from build artifacts
# 3. Build DEB packages for amd64 and arm64
# 4. Build RPM packages for x86_64 and aarch64
@@ -51,7 +51,7 @@ on:
required: false
type: string
build_run_id:
description: "Build workflow run ID (overrides tag lookup)"
description: "Build workflow run ID (when combined with tag, both must identify the same release commit)"
required: false
type: string
@@ -82,6 +82,9 @@ jobs:
version: ${{ steps.resolve.outputs.version }}
build_type: ${{ steps.resolve.outputs.build_type }}
build_run_id: ${{ steps.resolve.outputs.build_run_id }}
build_run_number: ${{ steps.resolve.outputs.build_run_number }}
head_sha: ${{ steps.resolve.outputs.head_sha }}
dev_sequence: ${{ steps.resolve.outputs.dev_sequence }}
tag: ${{ steps.resolve.outputs.tag }}
steps:
- name: Resolve build run
@@ -89,90 +92,129 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
REPOSITORY: ${{ github.repository }}
INPUT_TAG: ${{ github.event.inputs.tag }}
INPUT_RUN_ID: ${{ github.event.inputs.build_run_id }}
run: |
set -euo pipefail
# Determine tag
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
TAG="${HEAD_BRANCH}"
elif [[ -n "$INPUT_TAG" ]]; then
TAG="$INPUT_TAG"
fail() {
echo "❌ $1" >&2
exit 1
}
TAG=""
BUILD_RUN_ID=""
case "$EVENT_NAME" in
workflow_run)
TAG="$HEAD_BRANCH"
BUILD_RUN_ID="$WORKFLOW_RUN_ID"
;;
workflow_dispatch)
TAG="$INPUT_TAG"
BUILD_RUN_ID="$INPUT_RUN_ID"
;;
*) fail "unsupported event: $EVENT_NAME" ;;
esac
# Validate and classify tags before using them in API paths or logs.
semver_core='(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)'
prerelease_id='(alpha|beta|rc)\.(0|[1-9][0-9]*)'
if [[ -n "$TAG" ]]; then
if [[ "$TAG" =~ ^${semver_core}-${prerelease_id}-preview\.(0|[1-9][0-9]*)$ ]]; then
BUILD_TYPE=preview
elif [[ "$TAG" =~ ^${semver_core}-${prerelease_id}$ ]]; then
BUILD_TYPE=prerelease
elif [[ "$TAG" =~ ^${semver_core}$ ]]; then
BUILD_TYPE=release
else
fail "tag is not a supported strict package version"
fi
else
TAG=""
BUILD_TYPE=development
fi
echo "Tag: ${TAG:-<none>}"
# Determine build run ID
BUILD_RUN_ID=""
if [[ -n "$INPUT_RUN_ID" ]]; then
# Explicit run ID takes priority
BUILD_RUN_ID="$INPUT_RUN_ID"
echo "Using explicit build run ID: $BUILD_RUN_ID"
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
# Use the Build and Release run that triggered this workflow
BUILD_RUN_ID="${WORKFLOW_RUN_ID}"
echo "Using triggering workflow run: $BUILD_RUN_ID"
if [[ -n "$BUILD_RUN_ID" ]]; then
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "build run ID must be a positive decimal integer"
echo "Using selected build run: $BUILD_RUN_ID"
elif [[ -n "$TAG" ]]; then
# Find the build run that produced this tag
echo "Looking for build run for tag: $TAG"
BUILD_RUN_ID=$(gh api \
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?branch=${TAG}&status=success&per_page=1" \
--jq '.workflow_runs[0].id' 2>/dev/null || echo "")
BUILD_RUN_ID=$(gh api --method GET \
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f branch="$TAG" -f status=success -F per_page=1 \
--jq '.workflow_runs[0].id // empty' 2>/dev/null || true)
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
# Tag might not be a branch; try event=push with head_branch matching
BUILD_RUN_ID=$(gh api \
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?event=push&status=success&per_page=100" \
--jq ".workflow_runs[] | select(.head_branch == \"$TAG\") | .id" 2>/dev/null | head -1 || echo "")
fi
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
echo "❌ No successful build run found for tag: $TAG"
exit 1
if [[ -z "$BUILD_RUN_ID" ]]; then
BUILD_RUN_ID=$(gh api --method GET \
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f event=push -f status=success -F per_page=100 2>/dev/null |
jq -r --arg tag "$TAG" \
'[.workflow_runs[] | select(.head_branch == $tag)][0].id // empty' || true)
fi
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful build run found for tag"
echo "Found build run: $BUILD_RUN_ID"
else
# No tag — latest successful main build
echo "No tag specified, looking for latest main build"
BUILD_RUN_ID=$(gh api \
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?branch=main&status=success&per_page=1" \
--jq '.workflow_runs[0].id' 2>/dev/null || echo "")
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
echo "❌ No successful main build found"
exit 1
fi
BUILD_RUN_ID=$(gh api --method GET \
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
-f branch=main -f status=success -F per_page=1 \
--jq '.workflow_runs[0].id // empty' 2>/dev/null || true)
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful main build found"
echo "Latest main build: $BUILD_RUN_ID"
fi
# Determine version and build type
# Fetch once and use the same immutable run metadata for identity,
# ordering, workflow provenance, and release-channel validation.
RUN_JSON=$(gh api "repos/${REPOSITORY}/actions/runs/${BUILD_RUN_ID}") ||
fail "cannot read selected build run"
RUN_ID=$(jq -r '.id // empty' <<<"$RUN_JSON")
RUN_NUMBER=$(jq -r '.run_number // empty' <<<"$RUN_JSON")
RUN_STATUS=$(jq -r '.status // empty' <<<"$RUN_JSON")
RUN_CONCLUSION=$(jq -r '.conclusion // empty' <<<"$RUN_JSON")
RUN_PATH=$(jq -r '.path // empty' <<<"$RUN_JSON")
HEAD_SHA=$(jq -r '.head_sha // empty' <<<"$RUN_JSON")
RUN_HEAD_BRANCH=$(jq -r '.head_branch // empty' <<<"$RUN_JSON")
[[ "$RUN_ID" == "$BUILD_RUN_ID" ]] || fail "run metadata ID mismatch"
[[ "$RUN_NUMBER" =~ ^[1-9][0-9]*$ ]] || fail "build run number must be a positive decimal integer"
[[ "$RUN_STATUS" == completed && "$RUN_CONCLUSION" == success ]] || fail "selected build run is not successful"
[[ "$RUN_PATH" == .github/workflows/build.yml ]] || fail "selected run is not Build and Release"
[[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] || fail "selected build run has an invalid head SHA"
[[ "$RUN_HEAD_BRANCH" != *$'\n'* && -n "$RUN_HEAD_BRANCH" ]] || fail "selected build run has an invalid head branch"
if [[ -n "$TAG" ]]; then
[[ "$RUN_HEAD_BRANCH" == "$TAG" ]] || fail "tag and build run head branch do not match"
TAG_REF_JSON=$(gh api "repos/${REPOSITORY}/git/ref/tags/${TAG}") ||
fail "cannot resolve release tag ref"
TAG_OBJECT_TYPE=$(jq -r '.object.type // empty' <<<"$TAG_REF_JSON")
TAG_OBJECT_SHA=$(jq -r '.object.sha // empty' <<<"$TAG_REF_JSON")
depth=0
while [[ "$TAG_OBJECT_TYPE" == tag && $depth -lt 5 ]]; do
TAG_OBJECT_JSON=$(gh api "repos/${REPOSITORY}/git/tags/${TAG_OBJECT_SHA}") ||
fail "cannot peel annotated release tag"
TAG_OBJECT_TYPE=$(jq -r '.object.type // empty' <<<"$TAG_OBJECT_JSON")
TAG_OBJECT_SHA=$(jq -r '.object.sha // empty' <<<"$TAG_OBJECT_JSON")
depth=$((depth + 1))
done
[[ "$TAG_OBJECT_TYPE" == commit && "$TAG_OBJECT_SHA" =~ ^[0-9a-f]{40}$ ]] ||
fail "release tag does not resolve to a commit"
[[ "$TAG_OBJECT_SHA" == "$HEAD_SHA" ]] || fail "release tag commit and build run head SHA do not match"
VERSION="$TAG"
if [[ "$TAG" == *"-preview"* ]]; then
BUILD_TYPE="preview"
elif [[ "$TAG" == *"alpha"* || "$TAG" == *"beta"* || "$TAG" == *"rc"* ]]; then
BUILD_TYPE="prerelease"
else
BUILD_TYPE="release"
fi
DEV_SEQUENCE=""
else
SHORT_SHA=$(gh api "repos/${{ github.repository }}/actions/runs/${BUILD_RUN_ID}" \
--jq '.head_sha' 2>/dev/null | head -c 7)
VERSION="dev-${SHORT_SHA}"
BUILD_TYPE="development"
VERSION="dev-${HEAD_SHA}"
DEV_SEQUENCE="$RUN_NUMBER"
fi
{
echo "version=$VERSION"
echo "build_type=$BUILD_TYPE"
echo "build_run_id=$BUILD_RUN_ID"
echo "build_run_number=$RUN_NUMBER"
echo "head_sha=$HEAD_SHA"
echo "dev_sequence=$DEV_SEQUENCE"
echo "tag=${TAG}"
} >> "$GITHUB_OUTPUT"
@@ -180,6 +222,7 @@ jobs:
echo " Version: $VERSION"
echo " Build type: $BUILD_TYPE"
echo " Build run ID: $BUILD_RUN_ID"
echo " Build run number: $RUN_NUMBER"
# Build DEB and RPM packages for each architecture
package:
@@ -206,6 +249,22 @@ jobs:
with:
persist-credentials: false
- name: Normalize package metadata
id: versions
shell: bash
env:
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
SOURCE_VERSION: ${{ needs.resolve.outputs.version }}
DEV_SEQUENCE: ${{ needs.resolve.outputs.dev_sequence }}
DEB_ARCH: ${{ matrix.deb_arch }}
RPM_ARCH: ${{ matrix.rpm_arch }}
run: |
set -euo pipefail
normalized=$(./scripts/release/package_versions.sh \
"$BUILD_TYPE" "$SOURCE_VERSION" "$DEV_SEQUENCE" "$DEB_ARCH" "$RPM_ARCH")
printf '%s\n' "$normalized" >> "$GITHUB_OUTPUT"
- name: Download binary artifact from build run
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
@@ -224,7 +283,7 @@ jobs:
ZIP_FILE=$(find ./binary-artifact -name "*.zip" -type f | head -1)
if [[ -z "$ZIP_FILE" ]]; then
echo "❌ No binary artifact found"
ls -la ./binary-artifact/ || true
find ./binary-artifact -mindepth 1 -maxdepth 1 -print 2>/dev/null || true
exit 1
fi
@@ -239,24 +298,22 @@ jobs:
fi
chmod +x ./bin/rustfs
ls -lh ./bin/rustfs
stat --printf='%n %s bytes\n' ./bin/rustfs
echo "✅ Binary extracted"
- name: Build DEB package
id: deb
shell: bash
env:
DEB_VERSION: ${{ steps.versions.outputs.deb_version }}
DEB_ARCH: ${{ matrix.deb_arch }}
DEB_FILE: ${{ steps.versions.outputs.deb_file }}
run: |
set -euo pipefail
VERSION="${{ needs.resolve.outputs.version }}"
DEB_ARCH="${{ matrix.deb_arch }}"
# DEB version: replace - with ~ (1.0.0-beta.12 -> 1.0.0~beta.12)
# Use a variable for ~ to prevent tilde expansion by bash
TILDE='~'
DEB_VERSION="${VERSION/-/$TILDE}"
PKG_DIR="rustfs_${DEB_VERSION}_${DEB_ARCH}"
PKG_DIR="${DEB_FILE%.deb}"
echo "Building DEB: ${PKG_DIR}.deb"
echo "Building DEB: ${DEB_FILE}"
mkdir -p "${PKG_DIR}/DEBIAN"
mkdir -p "${PKG_DIR}/usr/bin"
@@ -333,26 +390,32 @@ jobs:
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
cp README.md "${PKG_DIR}/usr/share/doc/rustfs/"
fakeroot dpkg-deb --build "${PKG_DIR}"
fakeroot dpkg-deb --build "${PKG_DIR}" "$DEB_FILE"
DEB_FILE="${PKG_DIR}.deb"
ls -lh "$DEB_FILE"
[[ $(dpkg-deb -f "$DEB_FILE" Package) == rustfs ]]
[[ $(dpkg-deb -f "$DEB_FILE" Version) == "$DEB_VERSION" ]]
[[ $(dpkg-deb -f "$DEB_FILE" Architecture) == "$DEB_ARCH" ]]
dpkg-deb --fsys-tarfile "$DEB_FILE" | tar -tf - | grep -Fx './usr/bin/rustfs' >/dev/null
stat --printf='%n %s bytes\n' "$DEB_FILE"
echo "deb_file=$DEB_FILE" >> "$GITHUB_OUTPUT"
echo "✅ DEB built: $DEB_FILE"
- name: Build RPM package
id: rpm
shell: bash
env:
RPM_VERSION: ${{ steps.versions.outputs.rpm_version }}
RPM_RELEASE: ${{ steps.versions.outputs.rpm_release }}
RPM_ARCH: ${{ matrix.rpm_arch }}
RPM_FILE: ${{ steps.versions.outputs.rpm_file }}
run: |
set -euo pipefail
VERSION="${{ needs.resolve.outputs.version }}"
RPM_ARCH="${{ matrix.rpm_arch }}"
echo "Building RPM for ${RPM_ARCH}"
sudo apt-get update && sudo apt-get install -y ruby ruby-dev build-essential
sudo apt-get update && sudo apt-get install -y ruby ruby-dev build-essential rpm
sudo gem install fpm
./scripts/test_package_versions.sh --require-package-managers
# Create config file for fpm (DEB build creates it in its package dir structure,
# but fpm needs the file to exist before packaging)
@@ -367,8 +430,10 @@ jobs:
fpm -s dir -t rpm \
--name rustfs \
--version "$VERSION" \
--version "$RPM_VERSION" \
--iteration "$RPM_RELEASE" \
--architecture "$RPM_ARCH" \
--package "$RPM_FILE" \
--depends "glibc >= 2.31" \
--maintainer "RustFS Team <[email protected]>" \
--description "High-performance distributed object storage" \
@@ -410,13 +475,16 @@ jobs:
LICENSE=/usr/share/doc/rustfs/LICENSE \
README.md=/usr/share/doc/rustfs/README.md
RPM_FILE=$(ls -1 rustfs-*.rpm 2>/dev/null | head -1)
if [[ -z "$RPM_FILE" ]]; then
if [[ ! -f "$RPM_FILE" ]]; then
echo "❌ RPM build failed"
exit 1
fi
ls -lh "$RPM_FILE"
RPM_METADATA=$(rpm -qp --qf '%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n' "$RPM_FILE")
EXPECTED_METADATA=$(printf 'rustfs\n%s\n%s\n%s' "$RPM_VERSION" "$RPM_RELEASE" "$RPM_ARCH")
[[ "$RPM_METADATA" == "$EXPECTED_METADATA" ]]
rpm -qpl "$RPM_FILE" | grep -Fx '/usr/bin/rustfs' >/dev/null
stat --printf='%n %s bytes\n' "$RPM_FILE"
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
echo "✅ RPM built: $RPM_FILE"
@@ -437,6 +505,9 @@ jobs:
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
AWS_EC2_METADATA_DISABLED: true
BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
shell: bash
run: |
set -euo pipefail
@@ -454,7 +525,6 @@ jobs:
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="auto"
BUILD_TYPE="${{ needs.resolve.outputs.build_type }}"
if [[ "$BUILD_TYPE" == "development" ]]; then
R2_PREFIX="artifacts/rustfs/packages/dev"
else
@@ -464,9 +534,6 @@ jobs:
echo "📤 Uploading to $R2_PATH"
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
RPM_FILE="${{ steps.rpm.outputs.rpm_file }}"
for f in "$DEB_FILE" "$RPM_FILE"; do
if [[ -n "$f" && -f "$f" ]]; then
echo "Uploading: $f"
@@ -492,14 +559,13 @@ jobs:
if: needs.resolve.outputs.tag != ''
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.resolve.outputs.tag }}
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
shell: bash
run: |
set -euo pipefail
TAG="${{ needs.resolve.outputs.tag }}"
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
RPM_FILE="${{ steps.rpm.outputs.rpm_file }}"
# Upload the packages, then refresh the release checksums so the new
# assets are covered, matching the binary release flow.
for f in "$DEB_FILE" "$RPM_FILE"; do
@@ -551,12 +617,19 @@ jobs:
steps:
- name: Print summary
shell: bash
env:
SUMMARY_VERSION: ${{ needs.resolve.outputs.version }}
SUMMARY_BUILD_TYPE: ${{ needs.resolve.outputs.build_type }}
SUMMARY_BUILD_RUN_ID: ${{ needs.resolve.outputs.build_run_id }}
SUMMARY_PACKAGE_STATUS: ${{ needs.package.result }}
run: |
echo "## 📦 Package Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Item | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Version | \`${{ needs.resolve.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Build Type | ${{ needs.resolve.outputs.build_type }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Build Run | #${{ needs.resolve.outputs.build_run_id }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Package Status | ${{ needs.package.result }} |" >> "$GITHUB_STEP_SUMMARY"
{
echo "## 📦 Package Summary"
echo ""
echo "| Item | Value |"
echo "|------|-------|"
echo "| Version | \`${SUMMARY_VERSION}\` |"
echo "| Build Type | ${SUMMARY_BUILD_TYPE} |"
echo "| Build Run | #${SUMMARY_BUILD_RUN_ID} |"
echo "| Package Status | ${SUMMARY_PACKAGE_STATUS} |"
} >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,74 @@
# 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.
# 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,
# so a standalone run never drags the rest of the chain behind it.
#
# Why not workflow_run chaining: GitHub does not guarantee delivery of
# workflow_run events (they are fire-and-forget), and the head-SHA filter made
# newly added suites (storage) unable to trigger at all. Explicit
# repository_dispatch handoffs are verifiable and re-drivable.
name: RustFS Functional Chain
on:
workflow_dispatch:
workflow_run:
# Entry point: start the chain after the nightly build completes. The
# build's own conclusion does not gate the chain; each suite reports its
# own result to rustfs/backlog and the dashboard.
workflows: ["Nightly GNU Build"]
types: [completed]
permissions:
contents: read
jobs:
start-chain:
name: Start functional chain (upgrade first)
runs-on: ubuntu-latest
timeout-minutes: 10
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.event == 'schedule') }}
steps:
- name: Dispatch first suite (upgrade)
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 start the functional chain" >&2
exit 1
fi
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-upgrade' \
-F 'client_payload[from_suite]=nightly-build'
- name: Dispatch performance suite (parallel, own runner)
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch performance" >&2
exit 1
fi
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-performance' \
-F 'client_payload[from_suite]=nightly-build'
+230 -25
View File
@@ -23,12 +23,11 @@ on:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
workflow_run:
# Run after the nightly build completes: the nightly deb is what the test
# installs. Heal also runs inside the functional suite; this standalone
# workflow enables manual single-suite runs as well.
workflows: ["Nightly GNU Build"]
types: [completed]
repository_dispatch:
# Chain handoff: dispatched when the storage suite finishes. Heal runs
# exactly once per chain; the pool expansion workflow no longer embeds
# its own heal pass.
types: [rustfs-chain-heal]
permissions:
contents: read
@@ -36,7 +35,7 @@ permissions:
# Only one test at a time: both this and the pool-expansion workflow mutate
# the same test environment, so they share one concurrency group.
concurrency:
group: rustfs-pool-expansion-test
group: rustfs-shared-functional-tests
cancel-in-progress: false
defaults:
@@ -49,24 +48,39 @@ env:
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
jobs:
heal-test:
runs-on: smoke-testing
# Requirement: a failing suite must not fail the workflow; failures
# are filed to rustfs/backlog and the chain continues.
continue-on-error: true
timeout-minutes: 480
# Run on manual dispatch, or when the nightly build completed successfully.
# Skipped when nightly failed.
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
# Standalone manual run, or one link of the nightly functional chain
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/auto-testing
ref: main
path: auto-testing
persist-credentials: false
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
# 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: |
@@ -76,11 +90,24 @@ jobs:
warp --version || true
df -h /data | tail -1
- name: Reset test environment (before)
- name: Cleanup environment (before)
if: ${{ inputs.cleanup_before != 'false' }}
run: |
chmod +x auto-testing/rustfs_heal_test.sh
./auto-testing/rustfs_heal_test.sh --reset -y
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 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 /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
'
done
- name: Install RustFS package & start cluster
run: |
@@ -103,14 +130,129 @@ jobs:
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
- name: Run heal test (write -> outage -> heal -> verify)
id: test
run: |
./auto-testing/rustfs_heal_test.sh \
--steps "3,4,5,6,7" -y \
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
--stop-node-gb "${{ inputs.stop_node_gb }}" \
--warp-stop-gb "${{ inputs.warp_stop_gb }}" \
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
--log-file /tmp/rustfs-heal-test.log
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-heal-test.log
REPORT_FILE: /tmp/rustfs-heal-report.md
run: |
set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}'
if [ -n "${PACKAGE_URL}" ]; then
PACKAGE_SOURCE="${PACKAGE_URL}"
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
{
echo "# RustFS heal 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 "- Test Step Outcome: ${{ steps.test.outcome }}"
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-heal-report.md
SUITE: heal
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: 'heal'
SUITE_LABEL: 'Heal'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-heal-report.md'
LOG_FILE: '/tmp/rustfs-heal-test.log'
run: |
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 test logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -121,10 +263,73 @@ jobs:
/tmp/rustfs-warp.*.log
if-no-files-found: warn
- name: Reset test environment (after)
- name: Cleanup environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }}
run: |
./auto-testing/rustfs_heal_test.sh --reset -y
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 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 /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
'
done
- 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. 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 -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-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()
+394
View File
@@ -0,0 +1,394 @@
name: RustFS KMS 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
enforce_sse_key_policy:
description: 'Enable RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY (runs KMS-401/402)'
type: boolean
default: false
frame_v2:
description: 'Enable RUSTFS_ENCRYPTION_FRAME_V2 (runs KMS-318)'
type: boolean
default: false
config_secret:
description: 'Set RUSTFS_KMS_CONFIG_SECRET (runs KMS-107 config sealing)'
required: false
type: string
repository_dispatch:
# Chain handoff: dispatched when the S3 compatibility suite finishes.
types: [rustfs-chain-kms]
permissions:
contents: read
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:
kms-test:
runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 420
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
docker --version || 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 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 /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
'
done
- name: Ensure docker (Vault container)
run: |
if ! command -v docker >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y docker.io
fi
sudo systemctl enable --now docker
docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1
- name: Run KMS suite
id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-kms.log
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-kms-test.sh
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
ARGS=(--all-topologies --backends "local,vault-kv2" -y --log-file "${LOG_FILE}")
EXTRA_ENV=""
if [ "${{ inputs.enforce_sse_key_policy }}" = "true" ]; then
EXTRA_ENV+="RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY=true"$'\n'
fi
if [ "${{ inputs.frame_v2 }}" = "true" ]; then
EXTRA_ENV+="RUSTFS_ENCRYPTION_FRAME_V2=true"$'\n'
fi
if [ -n "${{ inputs.config_secret }}" ]; then
EXTRA_ENV+="RUSTFS_KMS_CONFIG_SECRET=${{ inputs.config_secret }}"$'\n'
fi
if [ -n "${EXTRA_ENV}" ]; then
ARGS+=(--extra-env "${EXTRA_ENV}")
fi
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ]; then
ARGS+=(--version "${RUSTFS_VERSION}")
else
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-kms.log
REPORT_FILE: /tmp/rustfs-kms-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}" ]; then
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
CASE_TABLE="/tmp/rustfs-kms-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-Z]+-[0-9]+)\s+(.+?)\s+---$')
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
rows = []
index = {}
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{
echo "# RustFS KMS test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
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-kms-report.md
SUITE: kms
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: 'kms'
SUITE_LABEL: 'KMS'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-kms-report.md'
LOG_FILE: '/tmp/rustfs-kms.log'
run: |
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-kms-test-${{ github.run_id }}
path: |
/tmp/rustfs-kms.log
/tmp/rustfs-kms-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 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 /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
'
done
- name: "Continue functional chain (next: Tier)"
# Only chain-triggered runs forward to the next suite; standalone
# 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 -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-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()
run: |
echo "RustFS KMS suite failed"
echo "See the uploaded report and log artifacts for details."
+131 -28
View File
@@ -48,10 +48,10 @@ on:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
workflow_run:
# Run after the nightly build completes; the nightly deb is what the test installs.
workflows: ["Nightly GNU Build"]
types: [completed]
repository_dispatch:
# Chain entry: dispatched by rustfs-functional-chain.yml (runs on its own
# pf-testing runner, in parallel with the shared-VM chain).
types: [rustfs-chain-performance]
permissions:
contents: read
@@ -78,25 +78,39 @@ env:
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
# Fixed benchmark result directory so later steps can read summary.md
RUSTFS_RESULT_DIR: /tmp/rustfs-perf-results
# Cross-repo token for writing to rustfs/backlog (set in repo settings)
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
jobs:
performance-test:
runs-on: pf-testing
# Requirement: a failing benchmark must not fail the workflow;
# failures are filed to rustfs/backlog.
continue-on-error: true
timeout-minutes: 900
# Run on manual dispatch, or when the nightly build completed successfully.
# Skipped when nightly failed.
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/auto-testing
ref: main
path: auto-testing
persist-credentials: false
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
# 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: |
@@ -149,40 +163,128 @@ jobs:
run: |
./auto-testing/rustfs_performance_test.sh --step 6 -y
- name: Post results to backlog issue
- name: Collect RustFS version info
if: ${{ steps.benchmark.conclusion == 'success' }}
env:
VERSION_FILE: /tmp/rustfs-version.txt
run: |
set -euo pipefail
read -r -a NODES <<< "${RUSTFS_NODES}"
[ "${#NODES[@]}" -gt 0 ] || { echo "RUSTFS_NODES is empty"; exit 1; }
NODE="${NODES[0]}"
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
{
echo "Node: ${NODE}"
echo "Command: rustfs --version"
echo ""
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
"${SSH_USER}@${NODE}" 'rustfs --version'
} > "${VERSION_FILE}"
- name: Upload report to dashboard (reports/YYYY-MM-DD.md)
if: ${{ steps.benchmark.conclusion == 'success' }}
env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }}
VERSION_FILE: /tmp/rustfs-version.txt
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; skipping issue post"
echo "PF_TESTING_GH_TOKEN is not configured; skipping report upload"
exit 0
fi
SUMMARY="${RESULT_DIR}/summary.md"
[ -f "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="reports/${DATE}.md"
{
echo "## RustFS nightly build performance testing report"
echo "# RustFS nightly build performance testing report"
echo ""
echo "- **日期**: ${DATE}"
echo "- **Date**: ${DATE}"
echo "- **Run**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- **触发方式**: ${{ github.event_name }}"
echo "- **Trigger**: ${{ github.event_name }}"
echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
echo ""
cat "${SUMMARY}"
} > /tmp/rustfs-perf-issue-body.md
TITLE="RustFS nightly build performance testing report"
EXISTING="$(gh issue list --repo rustfs/backlog \
--search "in:title \"${TITLE}\"" --state all --limit 5 \
--json number --jq '.[0].number // empty')"
if [ -n "${EXISTING}" ]; then
gh issue comment "${EXISTING}" --repo rustfs/backlog --body-file /tmp/rustfs-perf-issue-body.md
echo "commented on existing issue #${EXISTING}"
echo ""
echo "## RustFS version"
echo '```text'
cat "${VERSION_FILE}"
echo '```'
} > /tmp/rustfs-perf-report.md
CONTENT="$(python3 -c 'import base64; print(base64.b64encode(open("/tmp/rustfs-perf-report.md","rb").read()).decode())')"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report: ${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
echo "updated ${REPORT_PATH} in rustfs/dashboard"
else
gh issue create --repo rustfs/backlog --title "${TITLE}" --body-file /tmp/rustfs-perf-issue-body.md
jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
echo "created ${REPORT_PATH} in rustfs/dashboard"
fi
- name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.benchmark.outcome == 'failure' || steps.benchmark.outcome == 'cancelled') }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
SUITE: 'performance'
SUITE_LABEL: 'Performance'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-perf-report.md'
LOG_FILE: '/tmp/rustfs-perf-test.log'
run: |
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 test logs & results
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
@@ -191,6 +293,7 @@ jobs:
path: |
/tmp/rustfs-perf-test*.log
/tmp/rustfs-perf-results/**
/tmp/rustfs-version.txt
if-no-files-found: warn
- name: Reset test environment (after)
+525 -274
View File
@@ -1,12 +1,11 @@
name: RustFS Functional Test Suite (S3/KMS/tier/pool/heal)
name: RustFS Pool Expansion Test
on:
workflow_dispatch:
inputs:
rustfs_version:
description: 'RustFS release tag to test (e.g. 1.0.0-rc.3)'
description: 'RustFS release tag to test (leave empty to use the latest nightly deb)'
required: false
default: '1.0.0-rc.3'
package_url:
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
required: false
@@ -26,18 +25,14 @@ on:
description: 'warp write duration (e.g. 5m, 10m)'
required: false
default: '10m'
warp_concurrent:
description: 'Pool fill: concurrent warp operations'
required: false
default: '32'
run_decommission:
description: 'Run the pool decommission step (3-pool topology only)'
type: boolean
default: true
stop_node_gb:
description: 'Heal: stop the outage node when surviving nodes reach N GiB'
required: false
default: '15'
warp_stop_gb:
description: 'Heal: stop warp when surviving nodes reach N GiB'
required: false
default: '40'
cleanup_before:
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
type: boolean
@@ -46,19 +41,18 @@ on:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
workflow_run:
# Run after the nightly build completes: S3 -> KMS -> tier -> pool -> heal.
workflows: ["Nightly GNU Build"]
types: [completed]
repository_dispatch:
# Chain handoff: dispatched when the heal suite finishes.
types: [rustfs-chain-pool]
permissions:
contents: read
# Only one test run at a time: every job mutates the same shared test
# Only one test run at a time: the job mutates the same shared test
# environment (vm000/vm001/vm002), so concurrent runs must not clobber each
# other. Jobs inside a run are chained with needs to serialize them.
# other.
concurrency:
group: rustfs-pool-expansion-test
group: rustfs-shared-functional-tests
cancel-in-progress: false
defaults:
@@ -71,181 +65,55 @@ env:
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
# Package used by the nightly run (workflow_dispatch inputs are empty for
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
jobs:
# All test scripts live in rustfs/auto-testing; rustfs stores only this
# workflow. Each job checks out auto-testing before running.
s3-compat-test:
name: S3 compatibility test
runs-on: smoke-testing
timeout-minutes: 240
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/auto-testing
ref: main
path: auto-testing
persist-credentials: false
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Run S3 compatibility suite
run: |
chmod +x auto-testing/rustfs-s3-compat-test.sh
ARGS=(--all-topologies -y --log-file /tmp/rustfs-s3-compat.log)
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
ARGS+=(--version "${{ inputs.rustfs_version }}")
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
- name: Upload test logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-s3-compat-${{ github.run_id }}
path: |
/tmp/rustfs-s3-compat*.log
if-no-files-found: warn
- name: Notify on failure
if: failure()
run: |
echo "RustFS S3 compatibility suite failed"
echo "See the uploaded log artifact for details."
kms-test:
name: KMS test (after S3)
runs-on: smoke-testing
timeout-minutes: 360
needs: s3-compat-test
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/auto-testing
ref: main
path: auto-testing
persist-credentials: false
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Ensure docker (Vault container)
run: |
if ! command -v docker >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y docker.io
fi
sudo systemctl enable --now docker
docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1
- name: Run KMS suite
run: |
chmod +x auto-testing/rustfs-kms-test.sh
ARGS=(--all-topologies --backends local,vault-kv2 -y --log-file /tmp/rustfs-kms.log)
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
ARGS+=(--version "${{ inputs.rustfs_version }}")
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
- name: Upload test logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-kms-test-${{ github.run_id }}
path: |
/tmp/rustfs-kms*.log
if-no-files-found: warn
- name: Notify on failure
if: failure()
run: |
echo "RustFS KMS suite failed"
echo "See the uploaded log artifact for details."
tier-test:
name: Tier / event / audit test (after KMS)
runs-on: smoke-testing
timeout-minutes: 360
needs: kms-test
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/auto-testing
ref: main
path: auto-testing
persist-credentials: false
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Ensure MQTT broker + clients (event notification tests)
run: |
if ! command -v mosquitto_sub >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y mosquitto mosquitto-clients
fi
sudo mkdir -p /etc/mosquitto/conf.d
printf 'listener 1883 0.0.0.0\nallow_anonymous true\n' | sudo tee /etc/mosquitto/conf.d/rustfs-test.conf >/dev/null
sudo systemctl restart mosquitto
sleep 2
ss -tln 2>/dev/null | grep -q ':1883' || { echo "mosquitto not listening on 1883"; exit 1; }
- name: Run tier / event / audit suite
run: |
chmod +x auto-testing/rustfs-tier-test.sh
ARGS=(--all-topologies -y --log-file /tmp/rustfs-tier.log)
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
ARGS+=(--version "${{ inputs.rustfs_version }}")
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./auto-testing/rustfs-tier-test.sh "${ARGS[@]}"
- name: Upload test logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-tier-test-${{ github.run_id }}
path: |
/tmp/rustfs-tier*.log
if-no-files-found: warn
- name: Notify on failure
if: failure()
run: |
echo "RustFS tier/event/audit suite failed"
echo "See the uploaded log artifact for details."
# Pool expansion: dispatched by the heal suite's chain handoff. Heal
# itself lives in rustfs-heal-test.yml and runs exactly once per chain.
pool-expansion-test:
name: Pool expansion / decommission test (after tier)
name: Pool expansion / decommission test
runs-on: smoke-testing
# Requirement: a failing suite must not fail the workflow; failures
# are filed to rustfs/backlog and the chain continues.
continue-on-error: true
timeout-minutes: 360
needs: tier-test
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
env:
RUSTFS_POOL_ADMIN_ENDPOINT: ${{ secrets.RUSTFS_POOL_ADMIN_ENDPOINT || vars.RUSTFS_POOL_ADMIN_ENDPOINT || 'http://rustfs-node1:9000' }}
RUSTFS_POOL_PROXY_ENDPOINT: http://127.0.0.1:19000
RUSTFS_POOL_WARP_ENDPOINT: http://127.0.0.1:19000
RUSTFS_SHARED_PROXY_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
RUSTFS_POOL_NODE_ENDPOINTS: ${{ secrets.RUSTFS_POOL_NODE_ENDPOINTS || vars.RUSTFS_POOL_NODE_ENDPOINTS || 'http://rustfs-node1:9000 http://rustfs-node2:9000 http://rustfs-node3:9000' }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/auto-testing
ref: main
path: auto-testing
persist-credentials: false
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
# 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: Initialize pool test artifacts
run: |
set -euo pipefail
ARTIFACT_DIR="${RUNNER_TEMP}/rustfs-pool-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -p "${ARTIFACT_DIR}"
echo "POOL_ARTIFACT_DIR=${ARTIFACT_DIR}" >> "${GITHUB_ENV}"
- name: Show environment
run: |
@@ -255,15 +123,32 @@ jobs:
warp --version || true
df -h /data | tail -1
- name: Reset test environment (before)
- name: Cleanup environment (before)
if: ${{ inputs.cleanup_before != 'false' }}
run: |
chmod +x auto-testing/rustfs_pool_expand.sh
./auto-testing/rustfs_pool_expand.sh --reset -y
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 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 /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
'
done
- name: Install RustFS package & start first pool
- name: Install RustFS package & start cluster
run: |
ARGS=(--steps "1,2,3" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
ARGS=(--steps "1,2,3" -y \
--admin-endpoint "${RUSTFS_POOL_ADMIN_ENDPOINT}" \
--warp-endpoint "${RUSTFS_POOL_WARP_ENDPOINT}" \
--node-endpoints "${RUSTFS_POOL_NODE_ENDPOINTS}" \
--log-file "${POOL_ARTIFACT_DIR}/pool-test.log")
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
@@ -275,7 +160,11 @@ jobs:
- name: Preflight checks
run: |
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
ARGS=(--preflight \
--admin-endpoint "${RUSTFS_POOL_ADMIN_ENDPOINT}" \
--warp-endpoint "${RUSTFS_POOL_WARP_ENDPOINT}" \
--node-endpoints "${RUSTFS_POOL_NODE_ENDPOINTS}" \
--log-file "${POOL_ARTIFACT_DIR}/pool-test.log")
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
@@ -285,6 +174,60 @@ jobs:
fi
./auto-testing/rustfs_pool_expand.sh "${ARGS[@]}"
- name: Reset dedicated pool proxy
run: |
set -euo pipefail
RUSTFS_POOL_NGINX_CONFIG_PATH=/etc/nginx/conf.d/rustfs-pool-test.conf \
RUSTFS_POOL_NGINX_LISTEN="${RUSTFS_POOL_PROXY_ENDPOINT#http://}" \
RUSTFS_POOL_NGINX_ACCESS_LOG=/var/log/nginx/rustfs-pool-test-access.log \
RUSTFS_POOL_NGINX_ERROR_LOG=/var/log/nginx/rustfs-pool-test-error.log \
./auto-testing/rustfs_pool_nginx_stage.sh cleanup
- name: Capture pool test baseline
run: |
set -uo pipefail
BASELINE_FILE="${POOL_ARTIFACT_DIR}/pool-baseline.log"
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
read -r -a DIRECT_ENDPOINTS <<< "${RUSTFS_POOL_NODE_ENDPOINTS}"
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
failed=0
: > "${BASELINE_FILE}"
if [ "${#DIRECT_ENDPOINTS[@]}" -lt "${#NODES[@]}" ]; then
echo "not enough direct endpoints for the configured nodes" | tee -a "${BASELINE_FILE}" >&2
exit 1
fi
for index in "${!NODES[@]}"; do
node="${NODES[$index]}"
endpoint="${DIRECT_ENDPOINTS[$index]}"
body_file="${POOL_ARTIFACT_DIR}/ready-baseline-$((index + 1)).body"
{
echo "--- node=${node} endpoint=${endpoint} ---"
if ! 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"
echo "--- rustfs version ---"
rustfs --version
echo "--- systemd state ---"
${SUDO} systemctl show rustfs --no-pager \
--property=ActiveState,SubState,Result,ExecMainPID,ExecMainStartTimestamp,NRestarts
'; then
echo "baseline collection failed for ${node}"
failed=1
fi
curl -sS --connect-timeout 5 --max-time 15 -o "${body_file}" \
-w "baseline_ready=${endpoint} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
"${endpoint%/}/health/ready" || true
echo "--- readiness body ---"
cat "${body_file}" 2>/dev/null || true
echo
} >> "${BASELINE_FILE}" 2>&1
done
[ "${failed}" -eq 0 ] || exit 1
- name: Run pool expansion & decommission test
id: pool_test
run: |
@@ -296,27 +239,409 @@ jobs:
STEPS="$STEPS,9"
fi
fi
./auto-testing/rustfs_pool_expand.sh \
--steps "$STEPS" --with-warp -y \
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
ARGS=(--steps "$STEPS" --with-warp -y \
--admin-endpoint "${RUSTFS_POOL_ADMIN_ENDPOINT}" \
--warp-endpoint "${RUSTFS_POOL_WARP_ENDPOINT}" \
--node-endpoints "${RUSTFS_POOL_NODE_ENDPOINTS}" \
--storage-threshold "${{ inputs.storage_threshold || '50' }}" \
--warp-duration "${{ inputs.warp_duration || '10m' }}" \
--log-file /tmp/rustfs-pool-test.log
--warp-concurrent "${{ inputs.warp_concurrent || '32' }}" \
--log-file "${POOL_ARTIFACT_DIR}/pool-test.log")
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
ARGS+=(--proxy-endpoint "${RUSTFS_POOL_PROXY_ENDPOINT}")
fi
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
elif [ -n "${{ inputs.rustfs_version }}" ]; then
ARGS+=(--version "${{ inputs.rustfs_version }}")
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
RUSTFS_WARP_LOG_FILE="${POOL_ARTIFACT_DIR}/warp.log" \
RUSTFS_PROXY_STAGE_HOOK=./auto-testing/rustfs_pool_nginx_stage.sh \
RUSTFS_POOL_NGINX_CONFIG_PATH=/etc/nginx/conf.d/rustfs-pool-test.conf \
RUSTFS_POOL_NGINX_LISTEN="${RUSTFS_POOL_PROXY_ENDPOINT#http://}" \
RUSTFS_POOL_NGINX_ACCESS_LOG=/var/log/nginx/rustfs-pool-test-access.log \
RUSTFS_POOL_NGINX_ERROR_LOG=/var/log/nginx/rustfs-pool-test-error.log \
./auto-testing/rustfs_pool_expand.sh "${ARGS[@]}"
- name: Collect pool test diagnostics
if: always()
run: |
set -uo pipefail
ARTIFACT_DIR="${POOL_ARTIFACT_DIR:-${RUNNER_TEMP}/rustfs-pool-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}}"
mkdir -p "${ARTIFACT_DIR}"
echo "POOL_ARTIFACT_DIR=${ARTIFACT_DIR}" >> "${GITHUB_ENV}"
redact() {
sed -E \
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)=).*/\1[REDACTED]/Ig' \
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
-e 's/(proxy_set_header[[:space:]]+Authorization[[:space:]]+).*/\1[REDACTED];/Ig' \
-e 's/^.*(password|secret|token).*/[REDACTED SENSITIVE LINE]/Ig'
}
if [ "$(id -u)" -eq 0 ]; then
SUDO=()
else
SUDO=(sudo -n)
fi
{
echo "captured_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "run_id=${GITHUB_RUN_ID}"
echo "run_attempt=${GITHUB_RUN_ATTEMPT}"
if command -v nginx >/dev/null 2>&1; then
"${SUDO[@]}" nginx -T 2>&1 || echo "nginx -T failed"
else
echo "nginx is not installed on the runner"
fi
} | redact > "${ARTIFACT_DIR}/nginx-config-redacted.txt"
for log_path in \
/var/log/nginx/access.log \
/var/log/nginx/error.log \
/var/log/nginx/rustfs-pool-test-access.log \
/var/log/nginx/rustfs-pool-test-error.log; do
log_name="$(basename "${log_path}")"
if "${SUDO[@]}" test -r "${log_path}" 2>/dev/null; then
"${SUDO[@]}" cat "${log_path}" 2>&1 | redact \
> "${ARTIFACT_DIR}/nginx-${log_name%.log}-redacted.log"
else
echo "unavailable: ${log_path}" > "${ARTIFACT_DIR}/nginx-${log_name%.log}-redacted.log"
fi
done
"${SUDO[@]}" journalctl -u nginx --no-pager -n 5000 2>&1 | redact \
> "${ARTIFACT_DIR}/nginx-journal-redacted.log" || true
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
for node in "${NODES[@]}"; do
safe_node="${node//[^A-Za-z0-9_.-]/_}"
{
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
"${SSH_USER}@${node}" '
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
echo "--- rustfs version ---"
rustfs --version 2>&1 || true
echo "--- systemd state ---"
${SUDO} systemctl show rustfs --no-pager \
--property=ActiveState,SubState,Result,ExecMainPID,ExecMainStartTimestamp,NRestarts 2>&1 || true
echo "--- rustfs journal ---"
${SUDO} journalctl -u rustfs --no-pager -n 10000 2>&1 || true
echo "--- rustfs file logs ---"
if ${SUDO} test -d /var/log/rustfs; then
${SUDO} find /var/log/rustfs -maxdepth 2 -type f -print 2>/dev/null | while IFS= read -r file; do
echo "--- ${file} (last 5000 lines) ---"
${SUDO} tail -n 5000 "${file}" 2>&1 || true
done
else
echo "/var/log/rustfs is unavailable"
fi
'; then
echo "SSH diagnostics failed for ${node}"
fi
} 2>&1 | redact > "${ARTIFACT_DIR}/${safe_node}-rustfs-redacted.log"
done
: > "${ARTIFACT_DIR}/endpoint-ready-probes.log"
read -r -a DIRECT_ENDPOINTS <<< "${RUSTFS_POOL_NODE_ENDPOINTS}"
probe_index=0
for endpoint in "${DIRECT_ENDPOINTS[@]}"; do
probe_index=$((probe_index + 1))
curl -sS --connect-timeout 5 --max-time 15 -o "${ARTIFACT_DIR}/ready-direct-${probe_index}.body" \
-w "direct[${probe_index}]=${endpoint} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
"${endpoint%/}/health/ready" >> "${ARTIFACT_DIR}/endpoint-ready-probes.log" 2>&1 || true
done
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
curl -sS --connect-timeout 5 --max-time 15 -o "${ARTIFACT_DIR}/ready-proxy.body" \
-w "proxy=${RUSTFS_POOL_PROXY_ENDPOINT} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
"${RUSTFS_POOL_PROXY_ENDPOINT%/}/health/ready" >> "${ARTIFACT_DIR}/endpoint-ready-probes.log" 2>&1 || true
fi
if [ -n "${RUSTFS_SHARED_PROXY_ENDPOINT}" ]; then
curl -sS --connect-timeout 5 --max-time 15 -o "${ARTIFACT_DIR}/ready-shared-proxy.body" \
-w "shared_proxy=${RUSTFS_SHARED_PROXY_ENDPOINT} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
"${RUSTFS_SHARED_PROXY_ENDPOINT%/}/health/ready" >> "${ARTIFACT_DIR}/endpoint-ready-probes.log" 2>&1 || true
fi
- name: Generate report
if: always()
run: |
set -euo pipefail
LOG_FILE="${POOL_ARTIFACT_DIR}/pool-test.log"
REPORT_FILE="${POOL_ARTIFACT_DIR}/pool-report.md"
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
if [ -n "${PACKAGE_URL}" ]; then
PACKAGE_SOURCE="${PACKAGE_URL}"
elif [ -n "${RUSTFS_VERSION}" ]; then
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
{
echo "# RustFS pool expansion 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 "- Warp concurrent: ${{ inputs.warp_concurrent || '32' }}"
echo "- Test Step Outcome: ${{ steps.pool_test.outcome }}"
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}" || true
echo '```'
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
- name: Validate pool diagnostic completeness
if: always()
run: |
set -euo pipefail
failed=0
require_nonempty() {
if [ ! -s "$1" ]; then
echo "required diagnostic is missing or empty: $1" >&2
failed=1
fi
}
require_available() {
if [ ! -e "$1" ]; then
echo "required diagnostic is missing: $1" >&2
failed=1
elif grep -Fq 'unavailable:' "$1" 2>/dev/null; then
echo "required diagnostic could not be collected: $1" >&2
failed=1
fi
}
require_nonempty "${POOL_ARTIFACT_DIR}/pool-test.log"
require_nonempty "${POOL_ARTIFACT_DIR}/warp.log"
require_nonempty "${POOL_ARTIFACT_DIR}/pool-report.md"
require_nonempty "${POOL_ARTIFACT_DIR}/pool-baseline.log"
require_nonempty "${POOL_ARTIFACT_DIR}/nginx-config-redacted.txt"
require_nonempty "${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-access-redacted.log"
require_available "${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-access-redacted.log"
require_available "${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-error-redacted.log"
require_nonempty "${POOL_ARTIFACT_DIR}/endpoint-ready-probes.log"
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
if grep -Fq 'baseline collection failed' "${POOL_ARTIFACT_DIR}/pool-baseline.log" 2>/dev/null; then
echo "one or more node baselines could not be collected" >&2
failed=1
fi
for node in "${NODES[@]}"; do
safe_node="${node//[^A-Za-z0-9_.-]/_}"
node_log="${POOL_ARTIFACT_DIR}/${safe_node}-rustfs-redacted.log"
require_nonempty "${node_log}"
if grep -Fq "SSH diagnostics failed for ${node}" "${node_log}" 2>/dev/null; then
echo "node diagnostics failed: ${node_log}" >&2
failed=1
fi
if ! grep -Eq '^rustfs @' "${node_log}" 2>/dev/null \
|| ! grep -Eq '^NRestarts=[0-9]+$' "${node_log}" 2>/dev/null; then
echo "node version or restart evidence is incomplete: ${node_log}" >&2
failed=1
elif grep -Eq '^NRestarts=[1-9][0-9]*$' "${node_log}"; then
echo "RustFS restarted unexpectedly during the run: ${node_log}" >&2
failed=1
fi
done
if ! grep -Fq "upstream_status=\"\$upstream_status\"" \
"${POOL_ARTIFACT_DIR}/nginx-config-redacted.txt"; then
echo "Nginx config does not expose upstream status fields" >&2
failed=1
fi
if ! grep -Eq '^proxy=.* http=200([[:space:]]|$)' "${POOL_ARTIFACT_DIR}/endpoint-ready-probes.log"; then
echo "dedicated proxy readiness probe did not return HTTP 200" >&2
failed=1
fi
if grep -Eq 'status=50(2|4)|upstream_status="[^"]*50(2|4)' \
"${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-access-redacted.log"; then
echo "dedicated proxy access log contains a 502/504 response" >&2
failed=1
fi
if grep -Eiq 'upstream prematurely closed connection|upstream timed out|(connect\(\)|recv\(\)|send\(\)) failed.*upstream|connection reset by peer.*upstream' \
"${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-error-redacted.log"; then
echo "dedicated proxy error log contains an upstream timeout or connection failure" >&2
failed=1
fi
[ "${failed}" -eq 0 ] || exit 1
- name: Upload functional report to dashboard
if: always()
continue-on-error: true
env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
SUITE: pool
run: |
set -euo pipefail
REPORT_FILE="${POOL_ARTIFACT_DIR}/pool-report.md"
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.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
SUITE: 'pool'
SUITE_LABEL: 'Pool expansion'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '${{ env.POOL_ARTIFACT_DIR }}/pool-report.md'
LOG_FILE: '${{ env.POOL_ARTIFACT_DIR }}/pool-test.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 test logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-pool-test-${{ github.run_id }}
path: |
/tmp/rustfs-pool-test*.log
/tmp/rustfs-warp.*.log
name: rustfs-pool-test-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/rustfs-pool-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: warn
- name: Reset test environment (after)
- name: Restore dedicated pool proxy
if: always()
run: |
set -euo pipefail
RUSTFS_POOL_NGINX_CONFIG_PATH=/etc/nginx/conf.d/rustfs-pool-test.conf \
RUSTFS_POOL_NGINX_LISTEN="${RUSTFS_POOL_PROXY_ENDPOINT#http://}" \
RUSTFS_POOL_NGINX_ACCESS_LOG=/var/log/nginx/rustfs-pool-test-access.log \
RUSTFS_POOL_NGINX_ERROR_LOG=/var/log/nginx/rustfs-pool-test-error.log \
./auto-testing/rustfs_pool_nginx_stage.sh cleanup
- name: Cleanup environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }}
run: |
./auto-testing/rustfs_pool_expand.sh --reset -y
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 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 /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
'
done
- name: "Continue functional chain (next: Security)"
# Only chain-triggered runs forward to the next suite; standalone
# 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 -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-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()
@@ -324,77 +649,3 @@ jobs:
echo "RustFS pool expansion test failed"
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
echo "See the uploaded log artifact for details."
# Heal regression runs after the pool test regardless of its outcome: a pool
# failure must be reported (it makes the run red) but must not block heal.
heal-test:
name: Heal test (after pool test)
runs-on: smoke-testing
timeout-minutes: 480
needs: pool-expansion-test
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
steps:
- name: Checkout auto-testing scripts
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
repository: rustfs/auto-testing
ref: main
path: auto-testing
persist-credentials: false
token: ${{ secrets.PF_TESTING_GH_TOKEN }}
- name: Reset test environment (before)
if: ${{ inputs.cleanup_before != 'false' }}
run: |
chmod +x auto-testing/rustfs_heal_test.sh
./auto-testing/rustfs_heal_test.sh --reset -y
- name: Install RustFS package & start cluster
run: |
ARGS=(--steps "1,2" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
- name: Preflight checks
run: |
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
if [ -n "${{ inputs.package_url }}" ]; then
ARGS+=(--package-url "${{ inputs.package_url }}")
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
- name: Run heal test (write -> outage -> heal -> verify)
run: |
./auto-testing/rustfs_heal_test.sh \
--steps 3,4,5,6,7 -y \
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
--log-file /tmp/rustfs-heal-test.log
- name: Upload test logs
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-heal-test-${{ github.run_id }}
path: |
/tmp/rustfs-heal-test.log
/tmp/rustfs-warp.*.log
if-no-files-found: warn
- name: Reset test environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }}
run: |
./auto-testing/rustfs_heal_test.sh --reset -y
- name: Notify on failure
if: failure()
run: |
echo "RustFS heal test failed"
echo "See the uploaded log artifact for details."
@@ -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."
+374
View File
@@ -0,0 +1,374 @@
name: RustFS S3 Compatibility 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
repository_dispatch:
# Chain handoff: dispatched when the upgrade suite finishes.
types: [rustfs-chain-s3]
permissions:
contents: read
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:
s3-compat-test:
runs-on: smoke-testing
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
df -h /data | tail -1
- 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 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 /var/log/rustfs
'
done
- name: Run S3 compatibility suite
id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-s3-compat.log
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-s3-compat-test.sh
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
ARGS=(--all-topologies -y --log-file "${LOG_FILE}")
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ]; then
ARGS+=(--version "${RUSTFS_VERSION}")
else
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-s3-compat.log
REPORT_FILE: /tmp/rustfs-s3-compat-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}" ]; 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-s3-compat-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 = {}
current = None
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
current = case_id
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
current = None
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{
echo "# RustFS S3 compatibility test report"
echo ""
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-s3-compat-report.md
SUITE: s3
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: 's3'
SUITE_LABEL: 'S3 compatibility'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-s3-compat-report.md'
LOG_FILE: '/tmp/rustfs-s3-compat.log'
run: |
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-s3-compat-${{ github.run_id }}
path: |
/tmp/rustfs-s3-compat.log
/tmp/rustfs-s3-compat-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 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 /var/log/rustfs
'
done
- name: "Continue functional chain (next: KMS)"
# Only chain-triggered runs forward to the next suite; standalone
# 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 -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-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()
run: |
echo "RustFS S3 compatibility suite failed"
echo "See the uploaded report and log artifacts for details."
+318
View File
@@ -0,0 +1,318 @@
# 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 Security 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
topology:
description: 'Topology to run (all = SNSD, SNMD, MNMD)'
type: choice
options:
- all
- single-single
- single-multi
- multi-multi
default: all
oidc_live:
description: 'Run the live Keycloak OIDC/SSO gate as part of the suite'
type: boolean
default: true
cleanup_before:
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
type: boolean
default: true
cleanup_after:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
repository_dispatch:
# Chain handoff: dispatched when the pool expansion suite finishes.
types: [rustfs-chain-security]
permissions:
contents: read
# The security 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:
security-test:
runs-on: smoke-testing
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: Checkout repository (for the OIDC live gate script)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Show environment
run: |
uname -a
jq --version
openssl version
aws --version || true
docker --version || true
df -h /data | tail -1
- name: Cleanup environment (before)
if: ${{ inputs.cleanup_before != 'false' || github.event_name != 'workflow_dispatch' }}
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 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 /var/log/rustfs /var/lib/rustfs/kms
'
done
- name: Run security suite
id: test
continue-on-error: true
env:
REPORT_FILE: /tmp/rustfs-security-report.md
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/scripts/test/oidc_keycloak_live.sh
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-security-test.sh
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
TOPOLOGY='${{ inputs.topology }}'
ARGS=(-y)
if [ "${TOPOLOGY}" = "all" ] || [ -z "${TOPOLOGY}" ] || [ "${TOPOLOGY}" = "null" ]; then
ARGS+=(--all-topologies)
else
ARGS+=(--topology "${TOPOLOGY}")
fi
if [ "${{ inputs.oidc_live }}" = "true" ] || [ "${{ github.event_name }}" != "workflow_dispatch" ]; then
ARGS+=(--oidc-live)
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-security-test.sh "${ARGS[@]}"
- name: Generate report
if: always()
run: |
set -euo pipefail
if [ ! -f /tmp/rustfs-security-report.md ]; then
{
echo "# RustFS security test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Test Step Outcome: failure (suite did not produce a report)"
} > /tmp/rustfs-security-report.md
fi
cat /tmp/rustfs-security-report.md >> "${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-security-report.md
SUITE: security
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: 'security'
SUITE_LABEL: 'Security'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-security-report.md'
LOG_FILE: ''
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-security-test-${{ github.run_id }}
path: |
/tmp/rustfs-security-report.md
/tmp/rustfs-security.*/*
if-no-files-found: ignore
retention-days: 3
- name: Cleanup environment (after)
if: ${{ always() && (inputs.cleanup_after != 'false' || github.event_name != 'workflow_dispatch') }}
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 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 /var/log/rustfs /var/lib/rustfs/kms
'
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: |
echo "RustFS security test failed"
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
echo "See the uploaded report and logs for details."
+389
View File
@@ -0,0 +1,389 @@
name: RustFS Storage Engine 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
topology:
description: 'Topology to run (all = SNSD, SNMD, MNMD)'
type: choice
options:
- all
- single-single
- single-multi
- multi-multi
default: all
repository_dispatch:
# Chain handoff: dispatched when the tier suite finishes.
types: [rustfs-chain-storage]
permissions:
contents: read
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:
storage-test:
runs-on: smoke-testing
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
df -h /data | tail -1
- 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 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 /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
'
done
- name: Run storage engine suite
id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-storage.log
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-storage-test.sh
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
TOPOLOGY='${{ inputs.topology }}'
ARGS=(-y --log-file "${LOG_FILE}")
if [ "${TOPOLOGY}" = "all" ] || [ -z "${TOPOLOGY}" ] || [ "${TOPOLOGY}" = "null" ]; then
ARGS+=(--all-topologies)
else
ARGS+=(--topology "${TOPOLOGY}")
fi
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ]; then
ARGS+=(--version "${RUSTFS_VERSION}")
else
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-storage.log
REPORT_FILE: /tmp/rustfs-storage-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}" ]; 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-storage-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 = {}
current = None
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
current = case_id
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
current = None
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{
echo "# RustFS storage engine test report"
echo ""
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-storage-report.md
SUITE: storage
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: 'storage'
SUITE_LABEL: 'Storage engine'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-storage-report.md'
LOG_FILE: '/tmp/rustfs-storage.log'
run: |
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-storage-${{ github.run_id }}
path: |
/tmp/rustfs-storage.log
/tmp/rustfs-storage-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 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 /var/log/rustfs
'
done
- name: "Continue functional chain (next: Heal)"
# Only chain-triggered runs forward to the next suite; standalone
# 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 -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-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()
run: |
echo "RustFS storage engine suite failed"
echo "See the uploaded report and log artifacts for details."
+484
View File
@@ -0,0 +1,484 @@
name: RustFS Tier 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
rc_sha256:
description: 'Optional SHA-256 for the preinstalled rc binary; mismatch is an infrastructure failure.'
required: false
type: string
force_case_failure:
description: 'Diagnostic only: rewrite single-single/TIER-101 to FAIL after execution to verify artifact and final-gate behavior.'
required: false
default: false
type: boolean
repository_dispatch:
# Chain handoff: dispatched when the KMS suite finishes.
types: [rustfs-chain-tier]
permissions:
contents: read
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' }}
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || vars.RUSTFS_TIER_RC_SHA256 }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
TIER_ARTIFACTS_DIR: /tmp/rustfs-tier-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
jobs:
tier-test:
runs-on: smoke-testing
# Requirement: a failing suite must not fail the workflow; failures
# are filed to rustfs/backlog and the chain continues.
continue-on-error: true
timeout-minutes: 420
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Initialize run evidence directory
id: evidence
run: |
set -euo pipefail
umask 077
if ! mkdir -- "${TIER_ARTIFACTS_DIR}"; then
echo "refusing to reuse tier evidence path: ${TIER_ARTIFACTS_DIR}" >&2
exit 1
fi
test -d "${TIER_ARTIFACTS_DIR}"
test ! -L "${TIER_ARTIFACTS_DIR}"
# 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
df -h /data | tail -1
- name: Cleanup environment (before)
run: |
set -euo pipefail
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
sudo rm -f /tmp/rustfs-mosquitto.conf
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 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 /var/log/rustfs /var/lib/rustfs/kms
'
done
- name: Ensure MQTT broker + clients
run: |
set -euo pipefail
if ! command -v mosquitto_sub >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y mosquitto-clients
fi
command -v docker >/dev/null 2>&1 || { echo 'docker not found on runner'; exit 1; }
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
cat <<'EOF' | sudo tee /tmp/rustfs-mosquitto.conf >/dev/null
listener 1883 0.0.0.0
allow_anonymous true
EOF
sudo docker run -d --name rustfs-test-mqtt -p 1883:1883 \
-v /tmp/rustfs-mosquitto.conf:/mosquitto/config/mosquitto.conf:ro \
eclipse-mosquitto:2 >/dev/null
for _ in {1..10}; do
if ss -tln 2>/dev/null | grep -q ':1883'; then
break
fi
sleep 1
done
ss -tln 2>/dev/null | grep -q ':1883' || {
echo 'mosquitto container is not listening on 1883'
sudo docker logs rustfs-test-mqtt || true
exit 1
}
- name: Run tier suite
id: test
continue-on-error: true
env:
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
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="$(command -v rc)"
PACKAGE_URL="${PACKAGE_URL_INPUT}"
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
ARGS=(
--all-topologies
-y
--log-file "${LOG_FILE}"
--rc-bin "${RC_BIN}"
--artifacts-dir "${TIER_ARTIFACTS_DIR}"
)
if [ -n "${RUSTFS_EXPECTED_RC_SHA256}" ]; then
ARGS+=(--expected-rc-sha256 "${RUSTFS_EXPECTED_RC_SHA256}")
fi
if [ -n "${PACKAGE_URL}" ]; then
ARGS+=(--package-url "${PACKAGE_URL}")
elif [ -n "${RUSTFS_VERSION}" ]; then
ARGS+=(--version "${RUSTFS_VERSION}")
else
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
./auto-testing/rustfs-tier-test.sh "${ARGS[@]}"
- name: Inject diagnostic case failure
if: ${{ always() && steps.evidence.outcome == 'success' && inputs.force_case_failure }}
run: |
set -euo pipefail
RESULT_FILE="${TIER_ARTIFACTS_DIR}/cases/single-single--TIER-101.json"
test -s "${RESULT_FILE}"
TMP_FILE="$(mktemp "${TIER_ARTIFACTS_DIR}/cases/.forced.XXXXXX")"
jq '.status = "FAIL" | .case_rc = 97' "${RESULT_FILE}" > "${TMP_FILE}"
mv "${TMP_FILE}" "${RESULT_FILE}"
- name: Generate report
if: ${{ always() && steps.evidence.outcome == 'success' }}
env:
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
TEST_OUTCOME: ${{ steps.test.outcome }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
TRIGGER_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
test -d "${TIER_ARTIFACTS_DIR}"
test ! -L "${TIER_ARTIFACTS_DIR}"
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
REPORT_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier-report.md"
CASE_TABLE="${TIER_ARTIFACTS_DIR}/rustfs-tier-cases.md"
GATE_RC_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier-gate.rc"
PACKAGE_URL="${PACKAGE_URL_INPUT}"
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
if [ -n "${PACKAGE_URL}" ]; then
PACKAGE_SOURCE="${PACKAGE_URL}"
elif [ -n "${RUSTFS_VERSION}" ]; then
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
set +e
python3 auto-testing/rustfs_tier_report.py \
--results-dir "${TIER_ARTIFACTS_DIR}/cases" \
--provenance "${TIER_ARTIFACTS_DIR}/provenance.json" \
--output "${CASE_TABLE}"
CASE_GATE_RC=$?
set -e
printf '%s\n' "${CASE_GATE_RC}" > "${GATE_RC_FILE}"
if [ ! -s "${CASE_TABLE}" ]; then
{
echo "## Case Summary"
echo ""
echo "Structured report generation failed before producing output (exit ${CASE_GATE_RC})."
} > "${CASE_TABLE}"
fi
{
echo "# RustFS tier test report"
echo ""
echo "- Run: ${RUN_URL}"
echo "- Trigger: ${TRIGGER_NAME}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- Test Step Outcome: ${TEST_OUTCOME}"
echo "- Structured Gate Exit: ${CASE_GATE_RC}"
echo ""
cat "${CASE_TABLE}"
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() && steps.evidence.outcome == 'success' }}
continue-on-error: true
env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-report.md
SUITE: tier
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: Verify required tier evidence
id: evidence_verify
if: ${{ always() && steps.evidence.outcome == 'success' }}
run: |
set -euo pipefail
failed=0
for name in \
rustfs-tier.log \
rustfs-tier-report.md \
rustfs-tier-cases.md \
rustfs-tier-gate.rc \
provenance.json; do
if [ ! -s "${TIER_ARTIFACTS_DIR}/${name}" ]; then
echo "required tier evidence is missing or empty: ${name}" >&2
failed=1
fi
done
for name in cases logs; do
if [ ! -d "${TIER_ARTIFACTS_DIR}/${name}" ]; then
echo "required tier evidence directory is missing: ${name}" >&2
failed=1
fi
done
if ! find "${TIER_ARTIFACTS_DIR}/cases" -maxdepth 1 -type f -name '*.json' -print -quit 2>/dev/null | grep -q .; then
echo "no atomic tier case result was produced" >&2
failed=1
fi
[ "${failed}" -eq 0 ]
- name: Upload report and logs
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-tier-test-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.TIER_ARTIFACTS_DIR }}/
if-no-files-found: error
- name: Cleanup environment (after)
if: always()
run: |
set -euo pipefail
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
sudo rm -f /tmp/rustfs-mosquitto.conf
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 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 /var/log/rustfs /var/lib/rustfs/kms
'
done
- name: Enforce tier suite result
id: gate
if: always()
env:
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
TEST_OUTCOME: ${{ steps.test.outcome }}
GATE_RC_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-gate.rc
run: |
set -euo pipefail
failed=0
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
echo "tier evidence directory initialization is ${EVIDENCE_OUTCOME}, expected success" >&2
failed=1
fi
if [ "${TEST_OUTCOME}" != "success" ]; then
echo "tier suite step outcome is ${TEST_OUTCOME}, expected success" >&2
failed=1
fi
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
echo "structured gate result is unavailable because evidence initialization failed" >&2
elif [ ! -s "${GATE_RC_FILE}" ]; then
echo "structured gate result is missing" >&2
failed=1
else
GATE_RC="$(tr -d '[:space:]' < "${GATE_RC_FILE}")"
if ! [[ "${GATE_RC}" =~ ^[0-9]+$ ]] || [ "${GATE_RC}" -ne 0 ]; then
echo "structured 56-case gate failed with exit ${GATE_RC:-invalid}" >&2
failed=1
fi
fi
[ "${failed}" -eq 0 ]
- name: File failure issue in rustfs/backlog
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled' || steps.evidence_verify.outcome == 'failure' || steps.evidence_verify.outcome == 'cancelled' || steps.gate.outcome == 'failure' || steps.gate.outcome == 'cancelled') }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
SUITE: 'tier'
SUITE_LABEL: 'Tier'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVIDENCE_DIR: ${{ env.TIER_ARTIFACTS_DIR }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
VERIFY_OUTCOME: ${{ steps.evidence_verify.outcome }}
GATE_OUTCOME: ${{ steps.gate.outcome }}
REPORT_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-report.md
LOG_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier.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 "- Evidence initialization: ${EVIDENCE_OUTCOME}"
echo "- Evidence verification: ${VERIFY_OUTCOME}"
echo "- Final gate: ${GATE_OUTCOME}"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
echo "(the run evidence directory was rejected; its contents were not read)"
elif [ ! -d "${EVIDENCE_DIR}" ] || [ -L "${EVIDENCE_DIR}" ]; then
echo "(the run evidence directory is missing or unsafe; its contents were not read)"
elif [ -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: "Continue functional chain (next: Storage engine)"
# Only chain-triggered runs forward to the next suite; standalone
# 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 -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-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()
run: |
echo "RustFS tier suite failed"
echo "See the uploaded report and log artifacts for details."
+444
View File
@@ -0,0 +1,444 @@
# 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 Upgrade Test
on:
workflow_dispatch:
inputs:
from_version:
description: 'OLD RustFS release tag (e.g. 1.0.0-rc.4-preview.1)'
required: false
default: '1.0.0-rc.4-preview.1'
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)'
required: false
to_url:
description: 'NEW .deb URL. Overrides to_version / nightly default.'
required: false
type: string
topology:
description: 'Topology to run (all = SNSD, SNMD, MNMD)'
type: choice
options:
- all
- single-single
- single-multi
- multi-multi
default: all
backends:
description: 'KMS backends to run (local,vault-kv2)'
required: false
default: 'local,vault-kv2'
cleanup_before:
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
type: boolean
default: true
cleanup_after:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
repository_dispatch:
# Functional-chain entry: dispatched by rustfs-functional-chain.yml.
types: [rustfs-chain-upgrade]
permissions:
contents: read
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:
upgrade-test:
runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 420
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 || true
docker --version || true
df -h /data | tail -1
- name: Cleanup environment (before)
if: ${{ inputs.cleanup_before != 'false' || github.event_name != 'workflow_dispatch' }}
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 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 /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
'
done
- name: Ensure docker (Vault container)
run: |
if ! command -v docker >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y docker.io
fi
sudo systemctl enable --now docker
docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1
- name: Run upgrade compatibility suite
id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-upgrade.log
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-upgrade-test.sh
FROM_URL='${{ inputs.from_url }}'
FROM_VERSION='${{ inputs.from_version }}'
TO_URL='${{ inputs.to_url }}'
TO_VERSION='${{ inputs.to_version }}'
TOPOLOGY='${{ inputs.topology }}'
BACKENDS='${{ inputs.backends }}'
ARGS=(-y --log-file "${LOG_FILE}")
if [ "${TOPOLOGY}" = "all" ] || [ -z "${TOPOLOGY}" ] || [ "${TOPOLOGY}" = "null" ]; then
ARGS+=(--all-topologies)
else
ARGS+=(--topology "${TOPOLOGY}")
fi
if [ -n "${BACKENDS}" ] && [ "${BACKENDS}" != "null" ]; then
ARGS+=(--backends "${BACKENDS}")
fi
if [ -n "${FROM_URL}" ]; then
ARGS+=(--from-url "${FROM_URL}")
elif [ -n "${FROM_VERSION}" ] && [ "${FROM_VERSION}" != "null" ]; then
ARGS+=(--from-version "${FROM_VERSION}")
fi
if [ -n "${TO_URL}" ]; then
ARGS+=(--to-url "${TO_URL}")
elif [ -n "${TO_VERSION}" ] && [ "${TO_VERSION}" != "null" ]; then
ARGS+=(--to-version "${TO_VERSION}")
else
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-upgrade.log
REPORT_FILE: /tmp/rustfs-upgrade-report.md
run: |
set -euo pipefail
FROM_URL='${{ inputs.from_url }}'
FROM_VERSION='${{ inputs.from_version }}'
TO_URL='${{ inputs.to_url }}'
TO_VERSION='${{ inputs.to_version }}'
if [ -n "${FROM_URL}" ]; then
FROM_SOURCE="${FROM_URL}"
elif [ -n "${FROM_VERSION}" ]; then
FROM_SOURCE="version ${FROM_VERSION}"
else
FROM_SOURCE="release (default)"
fi
if [ -n "${TO_URL}" ]; then
TO_SOURCE="${TO_URL}"
elif [ -n "${TO_VERSION}" ]; then
TO_SOURCE="version ${TO_VERSION}"
else
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
CASE_TABLE="/tmp/rustfs-upgrade-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-Z]+-[0-9]+)\s+(.+?)\s+---$')
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
rows = []
index = {}
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = start_re.match(line)
if m:
case_id, name = m.group(1), m.group(2)
if case_id not in index:
index[case_id] = len(rows)
rows.append([case_id, name, 'RUNNING'])
continue
m = done_re.match(line)
if m:
status, case_id = m.group(1), m.group(2)
if case_id in index:
rows[index[case_id]][2] = status
else:
rows.append([case_id, case_id, status])
index[case_id] = len(rows) - 1
except FileNotFoundError:
rows = []
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
for _, _, status in rows:
counts[status] = counts.get(status, 0) + 1
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Case Summary\n\n')
out.write(f"- Total: {len(rows)}\\n")
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
out.write('\\n')
out.write('| Case | Name | Status |\\n')
out.write('| --- | --- | --- |\\n')
for case_id, name, status in rows:
out.write(f'| {case_id} | {name} | {status} |\\n')
PY
{
echo "# RustFS upgrade compatibility report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Trigger: ${{ github.event_name }}"
echo "- From: ${FROM_SOURCE}"
echo "- To: ${TO_SOURCE}"
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-upgrade-report.md
SUITE: upgrade
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: 'upgrade'
SUITE_LABEL: 'Upgrade compatibility'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: '/tmp/rustfs-upgrade-report.md'
LOG_FILE: '/tmp/rustfs-upgrade.log'
run: |
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-upgrade-test-${{ github.run_id }}
path: |
/tmp/rustfs-upgrade-report.md
/tmp/rustfs-upgrade.*/*
if-no-files-found: ignore
retention-days: 3
- name: Cleanup environment (after)
if: ${{ always() && (inputs.cleanup_after != 'false' || github.event_name != 'workflow_dispatch') }}
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 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 /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
'
done
- 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. 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 -uo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
DISPATCHED=0
for attempt in 1 2 3; do
if gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-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()
run: |
echo "RustFS upgrade compatibility test failed"
echo "From: ${{ inputs.from_url || inputs.from_version || 'release (default)' }}"
echo "To: ${{ inputs.to_url || inputs.to_version || 'nightly (R2 latest)' }}"
echo "See the uploaded report and logs for details."
+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
+2 -3
View File
@@ -57,9 +57,6 @@ docs/*
!docs/operations/**
!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 +80,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 -5
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,
@@ -123,12 +124,13 @@ runtime/build output:
- Use `make pre-commit` only when its repository-wide fast checks add confidence
beyond the focused checks.
### Broad or High-Risk Changes
### Broad Cross-Module Changes
After the required adversarial review, run `make pre-pr` when targeted coverage
cannot bound the impact, including dependency/toolchain/build-matrix changes,
unbounded cross-crate APIs, or locking, durability, erasure coding, replication,
RPC, IAM/KMS/auth, cryptography, on-disk/on-wire, and S3-visible behavior.
Do not run `make pre-pr` by default before opening a PR. Consider it only when
the final diff is broad, spans multiple modules, and targeted checks cannot
bound the impact. Decide dynamically from the affected boundaries and risks;
otherwise use the scoped formatting, linting, compilation, and test checks
above.
`make pre-pr` includes `make pre-commit`; never run both for the same unchanged
diff. Do not repeat a check already covered by a successful umbrella gate.
+1 -1
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/`)
+2 -1
View File
@@ -15,7 +15,7 @@ cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all # format (required before PR)
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # full pre-PR gate: fmt + arch checks + clippy + tests
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
```
@@ -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)
+20 -7
View File
@@ -62,12 +62,20 @@ make test
# Fast pre-commit gate — see below for exactly what it runs
make pre-commit
# Full pre-PR gate (pre-commit gates + clippy + tests)
# Optional full gate for broad cross-module changes (pre-commit + clippy + tests)
make pre-pr
```
> `make test` requires [cargo-nextest](https://nexte.st) (CI runs it and only nextest honours `.config/nextest.toml` test-groups). Install it with `cargo install cargo-nextest --locked` or a prebuilt binary (see https://nexte.st/docs/installation/). To run the plain `cargo test` fallback anyway (results not authoritative — serialization semantics differ from CI), set `RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1`.
> Some guard checks are Python (`test-wiring-check` in `make pre-commit`, plus the
> security-coverage and scheduled-validation self-tests in `make test`) and import
> `tomllib`, so they need **Python 3.11+**. Make resolves the interpreter through
> `scripts/python_bin.sh`, which prefers a `python3.11`+ on `PATH` and otherwise falls
> back to `uv run --python 3.12`. macOS ships `/usr/bin/python3` at 3.9, so install a
> newer one (`brew install python@3.12`) or [uv](https://docs.astral.sh/uv/); pin a
> specific interpreter with `RUSTFS_PYTHON=/path/to/python3.12`.
> For the full test-layer taxonomy (unit / ecstore black-box / e2e / s3s-e2e / S3 compatibility / chaos / fuzz / bench), each layer's entry command, the naming conventions the migration gate depends on, and the serial/nextest rules, see [docs/testing/README.md](docs/testing/README.md).
> For the event, timeout, required-status, and local reproduction matrix, see [docs/testing/ci-gates.md](docs/testing/ci-gates.md).
@@ -88,14 +96,16 @@ make pre-pr
8. `quick-check` — `cargo check --workspace --exclude e2e_test`
**`make pre-commit` does NOT run clippy and does NOT run any tests.**
A green `make pre-commit` is not enough to open a pull request.
It does not replace the scoped Clippy and test checks applicable to a change.
`make pre-pr` is the **full** gate: it runs all of the guard checks above,
then `clippy-check` (`cargo clippy --all-targets --all-features -- -D warnings`)
and `test` (shell script tests, workspace tests excluding `e2e_test`, and doc
tests). Complete the applicable multi-role adversarial review described in
`AGENTS.md` before running `make pre-pr`; then run the gate before opening or
updating a pull request. This is what CI enforces.
`AGENTS.md` first. Do not run `make pre-pr` locally by default before opening or
updating a pull request. Consider it only for a broad change that spans multiple
modules and whose impact cannot be bounded by targeted checks; decide from the
affected boundaries and risks. CI still runs its configured repository gates.
### 🔒 Git Pre-commit Hooks (optional)
@@ -114,8 +124,9 @@ Or manually:
chmod +x .git/hooks/pre-commit
```
With or without a hook, the expectation is the same: run `make pre-commit`
before committing and `make pre-pr` before opening a pull request.
With or without a hook, follow the verification tiers in `AGENTS.md`. Run the
applicable scoped checks, and reserve `make pre-pr` for broad cross-module
changes whose impact cannot be bounded by those checks.
### 📝 Formatting Configuration
@@ -154,7 +165,9 @@ Example output when formatting fails:
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
4. **Commit your changes**: `git commit -m "your message"`
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
6. **Run the full gate before opening/updating a PR**: `make pre-pr` (clippy + tests)
6. **Run applicable scoped checks before opening/updating a PR**; consider
`make pre-pr` only for broad cross-module changes whose impact cannot be
bounded by targeted checks
7. **Push to your branch**: `git push`
### 🛠️ IDE Integration
Generated
+226 -155
View File
File diff suppressed because it is too large Load Diff
+64 -61
View File
@@ -29,6 +29,7 @@ members = [
"crates/heal-contracts", # Heal request/response channel contracts
"crates/iam", # Identity and Access Management
"crates/keystone", # OpenStack Keystone integration
"crates/license", # License and entitlement provider contracts
"crates/lifecycle", # Lifecycle rule evaluation contracts
"crates/kms", # Key Management Service
"crates/lock", # Distributed locking implementation
@@ -71,8 +72,8 @@ resolver = "3"
edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/rustfs/rustfs"
rust-version = "1.97.1"
version = "1.0.0-rc.4"
rust-version = "1.98.0"
version = "1.0.0-rc.5"
homepage = "https://rustfs.com"
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
@@ -89,60 +90,61 @@ redundant_clone = "warn"
[workspace.dependencies]
# RustFS Internal Crates
rustfs = { path = "./rustfs", version = "1.0.0-rc.4" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.4" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.4" }
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.4" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.4" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.4" }
rustfs-common = { path = "crates/common", version = "1.0.0-rc.4" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.4" }
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.4" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.4" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.4" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.4" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.4" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.4" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.4" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.4" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.4" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.4" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.4" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.4" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.4" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.4" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.4" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.4" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.4", default-features = false }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.4" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.4" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.4" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.4" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.4" }
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.4" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.4" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.4" }
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.4" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.4" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.4" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.4" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.4" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.4" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.4" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.4" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.4" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.4" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.4" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.4" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.4" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.4" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.4" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.4" }
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-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" }
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.5" }
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.5" }
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.5" }
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.5" }
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.5" }
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.5" }
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.5" }
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.5" }
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.5" }
rustfs-license = { path = "crates/license", version = "1.0.0-rc.5" }
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.5" }
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.5" }
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.5" }
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.5" }
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.5" }
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.5" }
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.5" }
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.5" }
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.5", default-features = false }
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.5" }
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.5" }
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.5" }
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.5" }
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.5" }
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.5" }
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.5" }
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.5" }
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.5" }
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.5" }
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.5" }
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.5" }
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.5" }
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.5" }
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.5" }
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.5" }
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.5" }
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.5" }
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.5" }
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.5" }
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.5" }
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.5" }
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.5" }
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
# Async Runtime and Networking
async-channel = "2.5.0"
async_zip = { default-features = false, version = "0.0.19" }
mysql_async = { default-features = false, version = "0.37" }
mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.43" }
async-recursion = "1.1.1"
async-trait = "0.1.92"
@@ -155,7 +157,7 @@ futures-util = "0.3.34"
pollster = "1.0.1"
pulsar = { default-features = false, version = "6.9.0" }
lapin = { default-features = false, version = "4.10.0" }
hyper = { version = "1.11.0" }
hyper = { version = "1.11.1" }
hyper-rustls = { default-features = false, version = "0.27.9" }
hyper-util = { version = "0.1.20" }
http = "1.5.0"
@@ -174,7 +176,7 @@ tonic = { version = "0.14.6" }
tonic-prost = { version = "0.14.6" }
tonic-prost-build = { version = "0.14.6" }
tower = { version = "0.5.3" }
tower-http = { version = "0.7.0" }
tower-http = { version = "0.7.1" }
# Serialization and Data Formats
apache-avro = { version = "0.22.0", features = ["snappy", "zstandard"] }
@@ -232,7 +234,8 @@ tokio-postgres-rustls = "0.14.0"
# Utilities and Tools
anyhow = "1.0.104"
arc-swap = "1.9.2"
astral-tokio-tar = "0.6.4"
# RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin until every parser hardening used by Snowball is released upstream. Remove after astral-sh/tokio-tar#118 is merged and a published release includes extension, physical-entry, and sparse limits, cancellation-safe sparse parsing, and error-fused entry streams.
astral-tokio-tar = { git = "https://github.com/cxymds/tokio-tar.git", rev = "603756478b7668436e464519c77ccac22a99ba96" }
atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.11.0" }
@@ -257,7 +260,7 @@ datafusion = { default-features = false, version = "55.0.0" }
derive_builder = "0.20.2"
enumset = "1.1.14"
faster-hex = "0.10.0"
flate2 = "1.1.9"
flate2 = "1.1.10"
glob = "0.3.4"
google-cloud-storage = "1.18.0"
google-cloud-auth = "1.16.0"
@@ -282,7 +285,7 @@ mime_guess = "2.0.5"
moka = { version = "0.12.16" }
netif = "0.1.6"
num_cpus = { version = "1.17.0" }
nvml-wrapper = "0.12.1"
nvml-wrapper = "0.13.0"
parking_lot = "0.12.5"
path-absolutize = "4.0.1"
percent-encoding = "2.3.2"
@@ -304,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 = "6e7b41252c7ba218a90886f58d297716ddf68acf", 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" }
@@ -354,8 +357,8 @@ pyroscope = { version = "2.1.1" }
# FTP and SFTP
libunftp = { version = "0.23.0" }
unftp-core = "0.1.0"
suppaftp = { version = "10.0.2" }
rcgen = { version = "0.14.9", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
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-sftp = "2.4.0"
+6
View File
@@ -23,6 +23,12 @@ SHELL := $(shell which bash)
.SHELLFLAGS = -eu -o pipefail -c
DOCKER_CLI ?= docker
# Python interpreter for the repository's helper scripts. They import tomllib
# (Python 3.11+), while macOS still ships /usr/bin/python3 at 3.9, so calls go
# through a resolver that picks a new-enough interpreter (or falls back to uv).
# Override with RUSTFS_PYTHON=/path/to/python3.12, or replace the resolver via
# RUSTFS_PYTHON_BIN=<command>.
RUSTFS_PYTHON_BIN ?= ./scripts/python_bin.sh
IMAGE_NAME ?= rustfs:v1.0.0
CONTAINER_NAME ?= rustfs-dev
# Docker build configurations
+48 -11
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
@@ -115,7 +132,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# Using specific version
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.5
```
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
@@ -245,6 +262,26 @@ nix build
nix run
```
The flake also exports a NixOS module and the RustFS `rc` client. Add the
module to your system and provide credentials through runtime files (for
example, sops-nix or agenix) so secrets are never stored in the Nix store:
```nix
imports = [ inputs.rustfs.nixosModules.rustfs ];
services.rustfs = {
enable = true;
accessKeyFile = "/run/secrets/rustfs-access-key";
secretKeyFile = "/run/secrets/rustfs-secret-key";
volumes = [ "/var/lib/rustfs" ];
};
```
Install the S3-compatible client with
`nix profile install github:rustfs/rustfs#rustfs-client` (the executable is named
`rc`), or use `inputs.rustfs.packages.${pkgs.system}.rustfs-client` in a system
configuration.
### 6\. X-CMD (Option 6)
If you are an [x-cmd](https://www.x-cmd.com/install/rustfs) user:
+7 -1
View File
@@ -112,7 +112,7 @@ chown -R 10001:10001 data logs
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
# 使用指定版本运行
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.5
```
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
@@ -191,6 +191,12 @@ nix build
nix run
```
该 Flake 同时提供 NixOS 模块和 RustFS `rc` 客户端。将
`inputs.rustfs.nixosModules.rustfs` 加入 `imports`,并通过运行时密钥文件
(例如 sops-nix 或 agenix)配置 `accessKeyFile``secretKeyFile`,避免密钥
进入 Nix store。客户端包为
`inputs.rustfs.packages.${pkgs.system}.rustfs-client`,安装后的命令名为 `rc`
### 6\. X-CMD (Option 6)
如果你是 [x-cmd](https://www.x-cmd.com/install/rustfs) 用户:
+20 -8
View File
@@ -59,20 +59,20 @@ pub const ENV_CAPACITY_MAX_TIMEOUT: &str = "RUSTFS_CAPACITY_MAX_TIMEOUT";
// ============================================================================
/// Scheduled update interval in seconds
/// Default: 120 seconds (2 minutes)
pub const DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS: u64 = 120;
/// Default: 600 seconds (10 minutes)
pub const DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS: u64 = 600;
/// Write trigger delay in seconds
/// Default: 5 seconds
pub const DEFAULT_WRITE_TRIGGER_DELAY_SECS: u64 = 5;
/// Default: 30 seconds
pub const DEFAULT_WRITE_TRIGGER_DELAY_SECS: u64 = 30;
/// Write frequency threshold (writes per minute)
/// Default: 5 writes/minute
pub const DEFAULT_WRITE_FREQUENCY_THRESHOLD: usize = 5;
/// Default: 20 writes/minute
pub const DEFAULT_WRITE_FREQUENCY_THRESHOLD: usize = 20;
/// Fast update threshold in seconds
/// Default: 30 seconds
pub const DEFAULT_FAST_UPDATE_THRESHOLD_SECS: u64 = 30;
/// Default: 120 seconds
pub const DEFAULT_FAST_UPDATE_THRESHOLD_SECS: u64 = 120;
/// Maximum files threshold for sampling
/// Default: 200,000 files
@@ -129,4 +129,16 @@ mod tests {
assert_eq!(ENV_CAPACITY_MIN_TIMEOUT, "RUSTFS_CAPACITY_MIN_TIMEOUT");
assert_eq!(ENV_CAPACITY_MAX_TIMEOUT, "RUSTFS_CAPACITY_MAX_TIMEOUT");
}
#[test]
fn test_capacity_default_values() {
assert_eq!(DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS, 600);
assert_eq!(DEFAULT_WRITE_TRIGGER_DELAY_SECS, 30);
assert_eq!(DEFAULT_WRITE_FREQUENCY_THRESHOLD, 20);
assert_eq!(DEFAULT_FAST_UPDATE_THRESHOLD_SECS, 120);
assert_eq!(DEFAULT_MAX_FILES_THRESHOLD, 200_000);
assert_eq!(DEFAULT_STAT_TIMEOUT_SECS, 3);
assert_eq!(DEFAULT_SAMPLE_RATE, 200);
assert_eq!(DEFAULT_CAPACITY_METRICS_INTERVAL_SECS, 600);
}
}
+2 -2
View File
@@ -198,11 +198,11 @@ pub const ENV_SCANNER_IDLE_MODE: &str = "RUSTFS_SCANNER_IDLE_MODE";
/// Environment variable that controls scanner cache save timeout in seconds.
/// The scanner enforces a minimum value of `1`.
/// - Unit: seconds (u64).
/// - Example: `export RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS=30`
/// - Example: `export RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS=14`
pub const ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS: &str = "RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS";
/// Default scanner cache save timeout in seconds.
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 30;
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 14;
/// Environment variable that caps concurrent scanner set tasks.
/// A value of `0` keeps the existing topology-based concurrency.
+3 -1
View File
@@ -100,7 +100,8 @@ aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a",
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
aws-config = { workspace = true }
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
aws-smithy-types.workspace = true
async-compression = { workspace = true, features = ["tokio", "bzip2", "lz4", "xz"] }
async-trait = { workspace = true }
flate2.workspace = true
http.workspace = true
@@ -114,6 +115,7 @@ rustfs-signer.workspace = true
# server's implementation: a shared helper could agree with a bug on both sides.
data-encoding = { workspace = true }
hmac = { workspace = true }
minlz.workspace = true
sha1 = { workspace = true }
serde_urlencoded = { workspace = true }
tracing = { workspace = true }
+8 -8
View File
@@ -169,7 +169,7 @@ the same profile for membership and execution with one nightly worker.
| `s3s-e2e` black-box | `e2e-tests` + `e2e-tests-rio-v2` jobs | **Active** (external conformance tool) |
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
| Direct upgrade from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
| Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
@@ -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:
+112 -1
View File
@@ -22,7 +22,7 @@ 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, 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 +260,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]
@@ -27,8 +27,10 @@
//! Readiness is established by the harness's `start()` handshake (TCP reachability
//! plus an S3 `ListBuckets` poll) — there are no fixed sleeps.
//!
//! Out of scope for this block (tracked separately): network fault injection
//! (toxiproxy / socket proxy) and 5GiB large-object budgets.
//! The volume-proxy smoke below also proves that the socket-level fault proxy
//! can be installed before startup without changing the client-facing node URL.
//! A full lock-plane partition matrix and 5GiB large-object budget remain
//! tracked separately.
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
@@ -76,6 +78,28 @@ async fn cluster_multidrive_single_pool_smoke() -> TestResult {
Ok(())
}
/// 4 nodes x 4 drives, single pool: exercise the maximum local erasure layout
/// supported by the cluster harness. This remains in the nightly lane because
/// it starts four real server processes and sixteen data directories.
#[tokio::test]
async fn cluster_four_node_four_drive_single_pool_smoke() -> TestResult {
crate::common::init_logging();
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 4)).await?;
let volumes = cluster.rustfs_volumes_arg();
assert_eq!(volumes.split(' ').count(), 16, "expected 16 explicit endpoints, got: {volumes}");
assert!(!volumes.contains('{'), "single-pool layout must not use ellipses: {volumes}");
assert!(cluster.nodes.iter().all(|node| node.data_dirs.len() == 4));
cluster.start().await?;
cluster.create_test_bucket(BUCKET).await?;
let payload = vec![0x3Cu8; 1024 * 1024];
put_get_roundtrip(&cluster, "multidrive-4/object", &payload).await?;
Ok(())
}
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
#[tokio::test]
@@ -103,3 +127,27 @@ async fn cluster_two_pool_smoke() -> TestResult {
put_get_roundtrip(&cluster, "twopool/object", &payload).await?;
Ok(())
}
/// A real cluster smoke for the volume FaultProxy wiring. The proxy target is
/// not listening yet when it is created; cluster startup must still converge
/// once the target node starts, and peer disk/RPC traffic must traverse it.
#[tokio::test]
async fn cluster_volume_fault_proxy_pass_smoke() -> TestResult {
crate::common::init_logging();
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2)).await?;
let proxy = cluster.start_volume_proxy_for_node(0).await?;
let proxied = proxy.local_addr().to_string();
assert!(cluster.rustfs_volumes_arg().contains(&proxied));
let result: TestResult = async {
cluster.start().await?;
cluster.create_test_bucket(BUCKET).await?;
let payload = vec![0x6Du8; 256 * 1024];
put_get_roundtrip(&cluster, "volume-proxy/object", &payload).await
}
.await;
proxy.shutdown().await;
result
}
+207 -44
View File
@@ -34,6 +34,7 @@ use serde_json;
use std::ffi::OsStr;
use std::fs as stdfs;
use std::io::ErrorKind;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Once;
@@ -217,7 +218,37 @@ pub(crate) async fn signed_s3_request(
access_key: &str,
secret_key: &str,
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
signed_s3_request_with_session_token(method, url, body, content_type, access_key, secret_key, None).await
signed_s3_request_with_headers(method, url, body, content_type, access_key, secret_key, &http::HeaderMap::new()).await
}
pub(crate) async fn signed_s3_request_with_headers(
method: http::Method,
url: &str,
body: Option<String>,
content_type: Option<&str>,
access_key: &str,
secret_key: &str,
extra_headers: &http::HeaderMap,
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
signed_s3_request_with_session_token(
method,
url,
body,
content_type,
SigningCredentials {
access_key,
secret_key,
session_token: None,
},
extra_headers,
)
.await
}
struct SigningCredentials<'a> {
access_key: &'a str,
secret_key: &'a str,
session_token: Option<&'a str>,
}
async fn signed_s3_request_with_session_token(
@@ -225,9 +256,8 @@ async fn signed_s3_request_with_session_token(
url: &str,
body: Option<String>,
content_type: Option<&str>,
access_key: &str,
secret_key: &str,
session_token: Option<&str>,
credentials: SigningCredentials<'_>,
extra_headers: &http::HeaderMap,
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("S3 URL missing authority")?.to_string();
@@ -239,14 +269,17 @@ async fn signed_s3_request_with_session_token(
if let Some(content_type) = content_type {
request = request.header(CONTENT_TYPE, content_type);
}
for (name, value) in extra_headers {
request = request.header(name, value);
}
let content_length = i64::try_from(body.as_ref().map_or(0, String::len)).map_err(|_| "S3 request body is too large")?;
let signed = sign_v4(
request.body(Body::empty())?,
content_length,
access_key,
secret_key,
session_token.unwrap_or_default(),
credentials.access_key,
credentials.secret_key,
credentials.session_token.unwrap_or_default(),
"us-east-1",
);
@@ -283,8 +316,19 @@ pub(crate) async fn admin_request_with_session_token(
) -> Result<(StatusCode, String), Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{base_url}{path_and_query}");
let content_type = body.as_ref().map(|_| "application/json");
let response =
signed_s3_request_with_session_token(method, &url, body, content_type, access_key, secret_key, session_token).await?;
let response = signed_s3_request_with_session_token(
method,
&url,
body,
content_type,
SigningCredentials {
access_key,
secret_key,
session_token,
},
&http::HeaderMap::new(),
)
.await?;
let status = response.status();
let body = response.text().await?;
Ok((status, body))
@@ -1171,6 +1215,9 @@ pub struct RustFSTestClusterEnvironment {
pub node_extra_env: Vec<Vec<(String, String)>>,
pub node_capture_log_paths: Vec<Option<String>>,
pub topology: ClusterTopology,
/// Optional socket proxies used for the corresponding node's volume
/// endpoints. Proxies must be installed before [`Self::start`].
volume_proxy_addresses: Vec<Option<SocketAddr>>,
}
impl RustFSTestClusterEnvironment {
@@ -1262,6 +1309,7 @@ impl RustFSTestClusterEnvironment {
extra_env.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
}
let node_count = topology.node_count;
Ok(Self {
nodes,
temp_dir,
@@ -1271,6 +1319,7 @@ impl RustFSTestClusterEnvironment {
node_extra_env: vec![Vec::new(); topology.node_count],
node_capture_log_paths: vec![None; topology.node_count],
topology,
volume_proxy_addresses: vec![None; node_count],
})
}
@@ -1338,6 +1387,34 @@ impl RustFSTestClusterEnvironment {
self.build_volumes_arg()
}
/// Start a socket proxy for one node's volume endpoints and route all
/// subsequent `RUSTFS_VOLUMES` references for that node through it.
///
/// Call this before [`Self::start`], then use the returned proxy's
/// [`crate::fault_proxy::FaultProxy::set_mode`] to inject latency,
/// blackhole, or one-way partition faults. The node's own listen address
/// remains direct, so S3 clients can still reach it while peer disk/RPC
/// traffic is steered through the proxy.
pub async fn start_volume_proxy_for_node(
&mut self,
node_idx: usize,
) -> Result<crate::fault_proxy::FaultProxy, Box<dyn std::error::Error + Send + Sync>> {
self.ensure_node_index(node_idx)?;
if self.volume_proxy_addresses[node_idx].is_some() {
return Err(format!("a volume proxy is already configured for node {node_idx}").into());
}
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
Ok(proxy)
}
fn volume_address(&self, node_idx: usize) -> String {
self.volume_proxy_addresses[node_idx]
.map(|address| address.to_string())
.unwrap_or_else(|| self.nodes[node_idx].address.clone())
}
fn build_volumes_arg(&self) -> String {
let pools = self.topology.normalized_pools();
@@ -1346,7 +1423,11 @@ impl RustFSTestClusterEnvironment {
return self
.nodes
.iter()
.flat_map(|n| n.data_dirs.iter().map(move |dir| format!("http://{}{}", n.address, dir)))
.enumerate()
.flat_map(|(node_idx, n)| {
let address = self.volume_address(node_idx);
n.data_dirs.iter().map(move |dir| format!("http://{}{}", address, dir))
})
.collect::<Vec<_>>()
.join(" ");
}
@@ -1357,13 +1438,19 @@ impl RustFSTestClusterEnvironment {
pools
.iter()
.map(|nodes| {
let node = &self.nodes[nodes[0]];
let node_idx = nodes[0];
let node = &self.nodes[node_idx];
let base = node
.data_dirs
.first()
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
.unwrap_or(&node.data_dir);
format!("http://{}{}/drive{{0...{}}}", node.address, base, self.topology.drives_per_node - 1)
format!(
"http://{}{}/drive{{0...{}}}",
self.volume_address(node_idx),
base,
self.topology.drives_per_node - 1
)
})
.collect::<Vec<_>>()
.join(" ")
@@ -1382,31 +1469,18 @@ impl RustFSTestClusterEnvironment {
/// times out, or cluster service readiness times out.
pub async fn start(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = rustfs_binary_path();
self.start_with_binary(&binary_path).await
}
/// Start every cluster node with a specific RustFS binary.
///
/// Upgrade compatibility tests use this to initialize a cluster with a
/// pinned previous release before replacing nodes with the workspace build.
pub async fn start_with_binary(&mut self, binary_path: &Path) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let volumes_arg = self.build_volumes_arg();
for (i, node) in self.nodes.iter_mut().enumerate() {
info!("Starting cluster node {} on {}", i, node.address);
let mut command = Command::new(&binary_path);
command
.env("RUSTFS_VOLUMES", &volumes_arg)
.env("RUSTFS_ADDRESS", &node.address)
.env("RUSTFS_ACCESS_KEY", &self.access_key)
.env("RUSTFS_SECRET_KEY", &self.secret_key)
.env("RUSTFS_CONSOLE_ENABLE", "false")
.env("RUST_LOG", "rustfs=info,rustfs_notify=debug");
for (key, value) in &self.extra_env {
command.env(key, value);
}
for (key, value) in &self.node_extra_env[i] {
command.env(key, value);
}
capture_command_logs(&mut command, self.node_capture_log_paths[i].as_deref())?;
let process = command.current_dir(&node.data_dir).spawn()?;
node.process = Some(process);
for node_idx in 0..self.nodes.len() {
self.spawn_node(node_idx, binary_path, &volumes_arg)?;
}
for (i, node) in self.nodes.iter().enumerate() {
@@ -1422,20 +1496,46 @@ impl RustFSTestClusterEnvironment {
/// Start one node process using the cluster's existing volume layout.
pub async fn start_node(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = rustfs_binary_path();
self.start_node_from_binary(node_idx, &binary_path).await
}
/// Start one stopped cluster node with a specific RustFS binary while
/// preserving the cluster's volume layout and that node's data directory.
pub async fn start_node_from_binary(
&mut self,
node_idx: usize,
binary_path: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let volumes_arg = self.build_volumes_arg();
self.spawn_node(node_idx, binary_path, &volumes_arg)?;
self.wait_for_node_ready(&self.nodes[node_idx].address, node_idx).await?;
self.wait_for_node_service_ready(node_idx).await?;
Ok(())
}
fn spawn_node(
&mut self,
node_idx: usize,
binary_path: &Path,
volumes_arg: &str,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
self.ensure_node_index(node_idx)?;
if self.nodes[node_idx].process.is_some() {
return Err(format!("cluster node {node_idx} is already running").into());
}
if !binary_path.is_file() {
return Err(format!("RustFS binary does not exist: {}", binary_path.display()).into());
}
let binary_path = rustfs_binary_path();
let volumes_arg = self.build_volumes_arg();
let log_path = self.node_capture_log_paths[node_idx].clone();
let node = &mut self.nodes[node_idx];
info!("Starting cluster node {} on {}", node_idx, node.address);
info!("Starting cluster node {} on {} with {}", node_idx, node.address, binary_path.display());
let mut command = Command::new(&binary_path);
let mut command = Command::new(binary_path);
command
.env("RUSTFS_VOLUMES", &volumes_arg)
.env("RUSTFS_VOLUMES", volumes_arg)
.env("RUSTFS_ADDRESS", &node.address)
.env("RUSTFS_ACCESS_KEY", &self.access_key)
.env("RUSTFS_SECRET_KEY", &self.secret_key)
@@ -1452,9 +1552,6 @@ impl RustFSTestClusterEnvironment {
let process = command.current_dir(&node.data_dir).spawn()?;
node.process = Some(process);
self.wait_for_node_ready(&self.nodes[node_idx].address, node_idx).await?;
self.wait_for_node_service_ready(node_idx).await?;
Ok(())
}
@@ -1602,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 {
@@ -1957,7 +2099,7 @@ mod tests {
}
let multidrive = topology.drives_per_node > 1;
let nodes = (0..topology.node_count)
let nodes: Vec<ClusterNode> = (0..topology.node_count)
.map(|i| {
let address = format!("127.0.0.1:{}", 9000 + i);
let data_dirs: Vec<String> = if multidrive {
@@ -1978,6 +2120,7 @@ mod tests {
})
.collect();
let node_count = nodes.len();
RustFSTestClusterEnvironment {
nodes,
temp_dir,
@@ -1987,6 +2130,7 @@ mod tests {
node_extra_env: vec![Vec::new(); topology.node_count],
node_capture_log_paths: vec![None; topology.node_count],
topology,
volume_proxy_addresses: vec![None; node_count],
}
}
@@ -2071,6 +2215,25 @@ mod tests {
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
}
#[tokio::test]
async fn volume_proxy_rewrites_cluster_volume_endpoint() {
let mut env = RustFSTestClusterEnvironment::new(1)
.await
.expect("cluster environment should allocate a node");
let direct = env.nodes[0].address.clone();
let proxy = env
.start_volume_proxy_for_node(0)
.await
.expect("volume proxy should bind before the target server starts");
let proxied = proxy.local_addr().to_string();
let volumes = env.rustfs_volumes_arg();
assert!(volumes.contains(&proxied), "volumes must use the proxy address: {volumes}");
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
proxy.shutdown().await;
}
#[test]
fn cluster_node_env_supports_per_node_overrides() {
let mut env = fake_cluster(ClusterTopology::single_pool(4));
@@ -0,0 +1,174 @@
// 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.
//! Regression: an object legally committed at degraded write quorum must stay
//! listable while a *different* drive is offline.
//!
//! On a 4-drive EC 2+2 set, a PUT made while one drive is down persists
//! `xl.meta` on 3 of 4 drives (write quorum). If a different drive later goes
//! offline before heal converges, a strict latest-listing quorum of 3 can only
//! ever observe 2 copies, so ListObjectsV2 silently dropped the object even
//! though GetObject (read quorum 2) still succeeded. Exposed by the flaky
//! "Mixed-version rolling upgrade from rc.2" CI lane (run 33478999853); the
//! product fix relaxes the listing's required object quorum by the number of
//! set drives the listing could not consult (see
//! `latest_listing_required_object_quorum` in
//! `crates/ecstore/src/store/list_objects.rs`).
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestClusterEnvironment, init_logging};
use aws_sdk_s3::Client;
use bytes::Bytes;
use std::collections::HashSet;
use std::error::Error;
use std::time::{Duration, Instant};
use tracing::info;
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
const BUCKET: &str = "degraded-listing-availability";
const OBJECT_COUNT: usize = 8;
/// Well under the observed heal-convergence gap (~50s in the CI incident),
/// so a listing that only completes after heal restores the missing copy
/// still fails this deadline on a regressed build.
const LISTING_DEADLINE: Duration = Duration::from_secs(25);
const GET_RETRY_DEADLINE: Duration = Duration::from_secs(15);
const PUT_RETRY_DEADLINE: Duration = Duration::from_secs(15);
fn object_key(idx: usize) -> String {
format!("degraded-object-{idx:02}")
}
async fn list_all_keys(client: &Client) -> Result<HashSet<String>, Box<dyn Error + Send + Sync>> {
let mut keys = HashSet::new();
let mut continuation_token: Option<String> = None;
loop {
let response = client
.list_objects_v2()
.bucket(BUCKET)
.set_continuation_token(continuation_token.clone())
.send()
.await?;
keys.extend(
response
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_owned)),
);
match response.next_continuation_token() {
Some(token) => continuation_token = Some(token.to_owned()),
None => break,
}
}
Ok(keys)
}
/// 4-node single-drive cluster (EC 2+2, write quorum 3):
/// 1. Stop node 1 and PUT objects — each commits on nodes {0, 2, 3} only.
/// 2. Stop node 3 (a holder drive), then bring node 1 back before heal can
/// recreate the missing copies there.
/// 3. Every object still satisfies read quorum (nodes 0 and 2), so GET
/// must succeed AND ListObjectsV2 must report every key well before
/// heal converges.
#[tokio::test]
async fn degraded_write_remains_listable_while_a_different_drive_is_offline() -> TestResult {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
// Listing availability must not depend on heal convergence: disable
// the background healers so the degraded objects keep their metadata
// on exactly 3 of 4 drives for the whole test.
cluster.set_env("RUSTFS_HEAL_ENABLED", "false");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
cluster.start().await?;
cluster.create_test_bucket(BUCKET).await?;
let client = cluster.create_s3_client(0)?;
info!("stopping node 1 so the uploads commit at degraded write quorum (3 of 4)");
cluster.stop_node(1)?;
// The first writes after a node drops can see transient 503s while the
// survivors notice the dead peer; retry briefly (overwrites of the same
// unversioned key are idempotent).
for idx in 0..OBJECT_COUNT {
let key = object_key(idx);
let body = format!("degraded listing payload {idx}");
let deadline = Instant::now() + PUT_RETRY_DEADLINE;
loop {
let request = client
.put_object()
.bucket(BUCKET)
.key(&key)
.body(Bytes::from(body.clone()).into());
match request.send().await {
Ok(_) => break,
Err(error) if Instant::now() < deadline => {
info!("retrying degraded PUT for {key}: {error}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(error) => return Err(format!("degraded PUT for {key} failed: {error}").into()),
}
}
}
info!("stopping node 3 (holds a copy) and restoring node 1 (holds none)");
cluster.stop_node(3)?;
cluster.start_node(1).await?;
// The first requests after a node drops can see transient 503s while
// the survivors notice the dead peer; retry briefly before asserting.
for idx in 0..OBJECT_COUNT {
let key = object_key(idx);
let deadline = Instant::now() + GET_RETRY_DEADLINE;
let body = loop {
match client.get_object().bucket(BUCKET).key(&key).send().await {
Ok(response) => break response.body.collect().await?.into_bytes(),
Err(error) if Instant::now() < deadline => {
info!("retrying degraded GET for {key}: {error}");
tokio::time::sleep(Duration::from_millis(500)).await;
}
Err(error) => return Err(format!("degraded object {key} failed read quorum GET: {error}").into()),
}
};
assert!(!body.is_empty(), "degraded object {key} should read back at read quorum");
}
let expected: HashSet<String> = (0..OBJECT_COUNT).map(object_key).collect();
let deadline = Instant::now() + LISTING_DEADLINE;
let listed = loop {
let listed = match list_all_keys(&client).await {
Ok(keys) => keys,
Err(error) if Instant::now() < deadline => {
info!("retrying degraded listing: {error}");
tokio::time::sleep(Duration::from_millis(500)).await;
continue;
}
Err(error) => return Err(error),
};
if expected.is_subset(&listed) {
break listed;
}
assert!(
Instant::now() < deadline,
"objects readable at read quorum stayed missing from ListObjectsV2 for {LISTING_DEADLINE:?}: \
missing={:?} listed={listed:?}",
expected.difference(&listed).collect::<Vec<_>>(),
);
tokio::time::sleep(Duration::from_millis(500)).await;
};
info!(listed = listed.len(), "degraded objects are listable while node 3 is offline");
Ok(())
}
}
+8 -4
View File
@@ -1,11 +1,15 @@
# 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_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.
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.
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`), 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, 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
File diff suppressed because it is too large Load Diff
@@ -17,6 +17,7 @@
use super::common::{
LocalKMSTestEnvironment, VAULT_KEY_NAME, VaultTestEnvironment, configure_kms, get_kms_status, kms_admin_request, start_kms,
test_sse_kms_encryption,
};
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{BucketVersioningStatus, ServerSideEncryption, VersioningConfiguration};
@@ -431,6 +432,38 @@ async fn test_configured_local_kms_admin_and_versioned_cleanup() -> TestResult {
Ok(())
}
#[tokio::test]
async fn test_admin_configured_local_kms_is_restored_after_restart() -> TestResult {
let mut env = LocalKMSTestEnvironment::new().await?;
env.base_env.start_rustfs_server(Vec::new()).await?;
let default_key_id = env.configure_local_kms().await?;
start_kms(&env.base_env.url, &env.base_env.access_key, &env.base_env.secret_key).await?;
env.base_env.restart_server_preserving_data(Vec::new(), &[]).await?;
assert_configured_status(
&env.base_env.url,
&env.base_env.access_key,
&env.base_env.secret_key,
"local",
&default_key_id,
)
.await?;
let bucket = format!("kms-restart-{}", Uuid::new_v4());
env.base_env.create_test_bucket(&bucket).await?;
let client = env.base_env.create_s3_client();
test_sse_kms_encryption(&client, &bucket).await?;
client
.delete_object()
.bucket(&bucket)
.key("test-sse-kms-object")
.send()
.await?;
env.base_env.delete_test_bucket(&bucket).await?;
Ok(())
}
#[tokio::test]
async fn test_configured_vault_kms_admin_and_versioned_cleanup() -> TestResult {
let mut env = VaultTestEnvironment::new().await?;
+3
View File
@@ -57,6 +57,9 @@ mod copy_object_version_restore_sse_test;
#[cfg(test)]
mod configured_roundtrip_test;
#[cfg(test)]
mod select_sse_response_test;
#[cfg(test)]
mod kms_anonymous_enforcement_test;
@@ -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
@@ -0,0 +1,241 @@
// 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.
//! SelectObjectContent SSE response-header compatibility (backlog#1625).
use super::common::{LocalKMSTestEnvironment, sse_customer_key_md5_base64, start_kms};
use crate::common::signed_s3_request_with_headers;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ServerSideEncryption;
use base64_simd::STANDARD as BASE64;
use http::{HeaderMap, Method};
use std::error::Error;
use uuid::Uuid;
type TestResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;
const CSV_BODY: &[u8] = b"name\nalice\n";
const SELECT_BODY: &str = r#"<SelectObjectContentRequest xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Expression>SELECT * FROM S3Object</Expression>
<ExpressionType>SQL</ExpressionType>
<InputSerialization><CSV><FileHeaderInfo>USE</FileHeaderInfo></CSV></InputSerialization>
<OutputSerialization><CSV/></OutputSerialization>
</SelectObjectContentRequest>"#;
const KMS_CONTEXT: &str = "eyJ0ZW5hbnQiOiJzMy1zZWxlY3QifQ==";
const SSE_ALGORITHM: &str = "x-amz-server-side-encryption";
const SSE_KMS_KEY_ID: &str = "x-amz-server-side-encryption-aws-kms-key-id";
const SSE_KMS_CONTEXT: &str = "x-amz-server-side-encryption-context";
const SSE_C_ALGORITHM: &str = "x-amz-server-side-encryption-customer-algorithm";
const SSE_C_KEY: &str = "x-amz-server-side-encryption-customer-key";
const SSE_C_KEY_MD5: &str = "x-amz-server-side-encryption-customer-key-md5";
const LOG_FLUSH_SENTINEL: &str = "select-sse-log-flush-sentinel.csv";
async fn raw_select(
env: &crate::common::RustFSTestEnvironment,
bucket: &str,
object: &str,
request_headers: &HeaderMap,
) -> TestResult<reqwest::Response> {
let url = format!("{}/{bucket}/{object}?select&select-type=2", env.url);
signed_s3_request_with_headers(
Method::POST,
&url,
Some(SELECT_BODY.to_string()),
Some("application/xml"),
&env.access_key,
&env.secret_key,
request_headers,
)
.await
}
async fn assert_success_headers(response: reqwest::Response, expected: &[(&str, &str)], absent: &[&str]) -> TestResult {
if response.status() != reqwest::StatusCode::OK {
let status = response.status();
let url = response.url().clone();
let body = response.text().await?;
panic!("Select request to {url} failed with {status}: {body}");
}
for (name, value) in expected {
assert_eq!(response.headers().get(*name).and_then(|header| header.to_str().ok()), Some(*value));
}
for name in absent {
assert!(response.headers().get(*name).is_none(), "successful Select response must omit {name}");
}
let body = response.bytes().await?;
assert!(
body.windows(b"alice".len()).any(|window| window == b"alice"),
"successful Select response must contain a Records event with the selected row"
);
assert!(
body.windows(b"End".len()).any(|window| window == b"End"),
"successful Select response must contain the terminal End event"
);
Ok(())
}
async fn assert_pre_stream_failure(response: reqwest::Response) -> TestResult {
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
let body = response.text().await?;
assert!(body.contains("<Error>"), "pre-stream failure must return an S3 XML error: {body}");
assert!(
body.contains("<Code>InvalidRequest</Code>"),
"invalid SSE-C parameters must preserve the S3 error code: {body}"
);
Ok(())
}
fn put_object(
client: &aws_sdk_s3::Client,
bucket: &str,
object: &str,
) -> aws_sdk_s3::operation::put_object::builders::PutObjectFluentBuilder {
client
.put_object()
.bucket(bucket)
.key(object)
.body(ByteStream::from_static(CSV_BODY))
}
#[tokio::test]
async fn select_projects_encryption_headers_and_rejects_invalid_sse_c_before_streaming() -> TestResult {
let mut kms = LocalKMSTestEnvironment::new().await?;
let log_path = format!("{}/server.log", kms.base_env.temp_dir);
kms.base_env.capture_log_path = Some(log_path.clone());
kms.base_env
.start_rustfs_server_with_env(Vec::new(), &[("RUST_LOG", "s3s=debug,rustfs=info")])
.await?;
let key_id = kms.configure_local_kms().await?;
start_kms(&kms.base_env.url, &kms.base_env.access_key, &kms.base_env.secret_key).await?;
let client = kms.base_env.create_s3_client();
let bucket = format!("select-sse-{}", Uuid::new_v4().simple());
client.create_bucket().bucket(&bucket).send().await?;
put_object(&client, &bucket, "plain.csv").send().await?;
put_object(&client, &bucket, "sse-s3.csv")
.server_side_encryption(ServerSideEncryption::Aes256)
.send()
.await?;
put_object(&client, &bucket, "sse-kms.csv")
.server_side_encryption(ServerSideEncryption::AwsKms)
.ssekms_key_id(&key_id)
.ssekms_encryption_context(KMS_CONTEXT)
.send()
.await?;
let customer_key = "01234567890123456789012345678901";
let customer_key_b64 = BASE64.encode_to_string(customer_key);
let customer_key_md5 = sse_customer_key_md5_base64(customer_key);
put_object(&client, &bucket, "sse-c.csv")
.sse_customer_algorithm("AES256")
.sse_customer_key(&customer_key_b64)
.sse_customer_key_md5(&customer_key_md5)
.send()
.await?;
assert_success_headers(
raw_select(&kms.base_env, &bucket, "plain.csv", &HeaderMap::new()).await?,
&[],
&[
SSE_ALGORITHM,
SSE_KMS_KEY_ID,
SSE_KMS_CONTEXT,
SSE_C_ALGORITHM,
SSE_C_KEY,
SSE_C_KEY_MD5,
],
)
.await?;
assert_success_headers(
raw_select(&kms.base_env, &bucket, "sse-s3.csv", &HeaderMap::new()).await?,
&[(SSE_ALGORITHM, "AES256")],
&[SSE_KMS_KEY_ID, SSE_KMS_CONTEXT, SSE_C_ALGORITHM, SSE_C_KEY, SSE_C_KEY_MD5],
)
.await?;
assert_success_headers(
raw_select(&kms.base_env, &bucket, "sse-kms.csv", &HeaderMap::new()).await?,
&[
(SSE_ALGORITHM, "aws:kms"),
(SSE_KMS_KEY_ID, &key_id),
(SSE_KMS_CONTEXT, KMS_CONTEXT),
],
&[SSE_C_ALGORITHM, SSE_C_KEY, SSE_C_KEY_MD5],
)
.await?;
let mut sse_c_headers = HeaderMap::new();
sse_c_headers.insert(SSE_C_ALGORITHM, "AES256".parse()?);
sse_c_headers.insert(SSE_C_KEY, customer_key_b64.parse()?);
sse_c_headers.insert(SSE_C_KEY_MD5, customer_key_md5.parse()?);
assert_success_headers(
raw_select(&kms.base_env, &bucket, "sse-c.csv", &sse_c_headers).await?,
&[(SSE_C_ALGORITHM, "AES256"), (SSE_C_KEY_MD5, &customer_key_md5)],
&[SSE_ALGORITHM, SSE_KMS_KEY_ID, SSE_KMS_CONTEXT, SSE_C_KEY],
)
.await?;
assert_pre_stream_failure(raw_select(&kms.base_env, &bucket, "sse-c.csv", &HeaderMap::new()).await?).await?;
let mut missing_algorithm_headers = HeaderMap::new();
missing_algorithm_headers.insert(SSE_C_KEY, customer_key_b64.parse()?);
missing_algorithm_headers.insert(SSE_C_KEY_MD5, customer_key_md5.parse()?);
assert_pre_stream_failure(raw_select(&kms.base_env, &bucket, "sse-c.csv", &missing_algorithm_headers).await?).await?;
let mut wrong_algorithm_headers = sse_c_headers.clone();
wrong_algorithm_headers.insert(SSE_C_ALGORITHM, "AES128".parse()?);
assert_pre_stream_failure(raw_select(&kms.base_env, &bucket, "sse-c.csv", &wrong_algorithm_headers).await?).await?;
let wrong_md5 = sse_customer_key_md5_base64("99999999999999999999999999999999");
let mut wrong_md5_headers = sse_c_headers.clone();
wrong_md5_headers.insert(SSE_C_KEY_MD5, wrong_md5.parse()?);
assert_pre_stream_failure(raw_select(&kms.base_env, &bucket, "sse-c.csv", &wrong_md5_headers).await?).await?;
let wrong_key = "99999999999999999999999999999999";
let wrong_key_b64 = BASE64.encode_to_string(wrong_key);
let mut wrong_key_headers = HeaderMap::new();
wrong_key_headers.insert(SSE_C_ALGORITHM, "AES256".parse()?);
wrong_key_headers.insert(SSE_C_KEY, wrong_key_b64.parse()?);
wrong_key_headers.insert(SSE_C_KEY_MD5, wrong_md5.parse()?);
assert_pre_stream_failure(raw_select(&kms.base_env, &bucket, "sse-c.csv", &wrong_key_headers).await?).await?;
put_object(&client, &bucket, LOG_FLUSH_SENTINEL).send().await?;
assert_success_headers(
raw_select(&kms.base_env, &bucket, LOG_FLUSH_SENTINEL, &HeaderMap::new()).await?,
&[],
&[
SSE_ALGORITHM,
SSE_KMS_KEY_ID,
SSE_KMS_CONTEXT,
SSE_C_ALGORITHM,
SSE_C_KEY,
SSE_C_KEY_MD5,
],
)
.await?;
let mut logs = String::new();
for _ in 0..100 {
logs = tokio::fs::read_to_string(&log_path).await?;
if logs.contains(LOG_FLUSH_SENTINEL) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(logs.contains(LOG_FLUSH_SENTINEL), "timed out waiting for the log sink to flush");
for secret in [customer_key, customer_key_b64.as_str(), wrong_key, wrong_key_b64.as_str()] {
assert!(!logs.contains(secret), "Select request logging leaked SSE-C customer key material");
}
Ok(())
}
+20 -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
@@ -61,6 +68,9 @@ mod get_codec_streaming_compat_test;
#[cfg(test)]
mod version_id_regression_test;
#[cfg(test)]
mod select_request_root_alias_test;
// Pinned previous-release -> current-build on-disk compatibility.
#[cfg(test)]
mod upgrade_compatibility_test;
@@ -164,6 +174,10 @@ mod delete_objects_versioning_test;
#[cfg(test)]
mod delete_object_no_content_length_test;
// Regression test for signed empty PutObject requests without Content-Length.
#[cfg(test)]
mod put_object_no_content_length_test;
// Delete-marker visibility baseline for data-movement migration proof.
#[cfg(test)]
mod delete_marker_migration_semantics_test;
@@ -341,6 +355,11 @@ mod delete_regression_test;
#[cfg(test)]
mod listing_regression_test;
// Cluster regression: objects committed at degraded write quorum must stay
// listable while a different drive is offline (CI run 33478999853).
#[cfg(test)]
mod degraded_listing_availability_test;
// P1 regression: bucket statistics accuracy (rustfs#5615, #5008, #5116, #5055, #3898, #1012)
#[cfg(test)]
mod bucket_stats_regression_test;
+366 -11
View File
@@ -15,7 +15,7 @@
//! Regression coverage for anonymous access on multipart control APIs.
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use async_compression::tokio::write::{BzEncoder, XzEncoder};
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
use aws_sdk_s3::primitives::ByteStream;
@@ -23,7 +23,10 @@ use aws_sdk_s3::types::{
ServerSideEncryption, ServerSideEncryptionByDefault, ServerSideEncryptionConfiguration, ServerSideEncryptionRule,
};
use chrono::{Duration as ChronoDuration, Utc};
use flate2::{Compression, write::GzEncoder};
use flate2::{
Compression,
write::{GzEncoder, ZlibEncoder},
};
use http::HeaderValue;
use http::header::{CONTENT_TYPE, HOST};
use md5::{Digest as Md5Digest, Md5};
@@ -187,6 +190,12 @@ fn gzip_bytes(data: &[u8]) -> Vec<u8> {
encoder.finish().expect("gzip encoder should finish")
}
fn zlib_bytes(data: &[u8]) -> Vec<u8> {
let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data).expect("zlib encoder should accept input");
encoder.finish().expect("zlib encoder should finish")
}
fn zstd_bytes(data: &[u8]) -> Vec<u8> {
let mut encoder = zstd::Encoder::new(Vec::new(), 0).expect("zstd encoder should initialize");
encoder.write_all(data).expect("zstd encoder should accept input");
@@ -209,6 +218,45 @@ async fn xz_bytes(data: &[u8]) -> Vec<u8> {
encoder.into_inner().into_inner()
}
async fn lz4_bytes(data: &[u8]) -> Vec<u8> {
let cursor = Cursor::new(Vec::new());
let mut encoder = Lz4Encoder::new(cursor);
encoder.write_all(data).await.expect("LZ4 encoder should accept input");
encoder.shutdown().await.expect("LZ4 encoder should finish");
encoder.into_inner().into_inner()
}
/// Encode the S2 framed stream shape emitted by minio-go PutObjectsSnowball
/// with `Compress: true`: 1 MiB independent blocks, better compression,
/// masked CRC-32C, and the `S2sTwO` stream identifier.
fn minio_go_snowball_s2_bytes(data: &[u8]) -> Vec<u8> {
const BLOCK_SIZE: usize = 1 << 20;
const CHECKSUM_SIZE: usize = 4;
let mut output = b"\xff\x06\x00\x00S2sTwO".to_vec();
let mut encoder = minlz::Encoder::new();
for block in data.chunks(BLOCK_SIZE) {
let compressed = encoder.encode_better(block);
let compressed_limit = block.len().saturating_sub(block.len() / 32).saturating_sub(5);
let (chunk_type, payload) = if compressed.len() <= compressed_limit {
(0x00, compressed.as_slice())
} else {
(0x01, block)
};
let chunk_len = payload.len() + CHECKSUM_SIZE;
assert!(chunk_len < 1 << 24, "S2 fixture chunk must fit the 24-bit frame length");
output.extend_from_slice(&[
chunk_type,
(chunk_len & 0xff) as u8,
((chunk_len >> 8) & 0xff) as u8,
((chunk_len >> 16) & 0xff) as u8,
]);
output.extend_from_slice(&minlz::crc::crc(block).to_le_bytes());
output.extend_from_slice(payload);
}
output
}
fn assert_s3_error_code<T, E>(result: Result<T, SdkError<E>>, code: &str)
where
T: std::fmt::Debug,
@@ -3456,6 +3504,62 @@ async fn test_signed_put_object_extract_expands_tar_entries_with_prefix_headers(
Ok(())
}
#[tokio::test]
async fn test_signed_put_object_extract_ignore_dirs_skips_unauthorized_directory()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "signed-extract-ignore-dirs-auth";
let archive_key = "bundle.tar";
let allowed_member = "allowed/member.txt";
let denied_directory = "denied/";
let username = "snowball-ignore-dirs";
let secret_key = "snowball-ignore-dirs-secret";
let expected_body = b"allowed-body";
let admin_client = env.create_s3_client();
admin_client.create_bucket().bucket(bucket).send().await?;
create_restricted_user(&env, username, secret_key).await?;
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": [username] },
"Action": ["s3:PutObject"],
"Resource": [
format!("arn:aws:s3:::{bucket}/{archive_key}"),
format!("arn:aws:s3:::{bucket}/{allowed_member}")
]
}]
})
.to_string();
admin_client.put_bucket_policy().bucket(bucket).policy(policy).send().await?;
let restricted_client = restricted_user_client(&env, username, secret_key);
let tar_bytes = make_tar(&[(allowed_member, expected_body)], &[denied_directory]).await;
restricted_client
.put_object()
.bucket(bucket)
.key(archive_key)
.body(ByteStream::from(tar_bytes))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
req.headers_mut().insert("x-amz-meta-snowball-ignore-dirs", "true");
})
.send()
.await?;
let stored = admin_client.get_object().bucket(bucket).key(allowed_member).send().await?;
assert_eq!(stored.body.collect().await?.into_bytes().as_ref(), expected_body);
Ok(())
}
#[tokio::test]
async fn test_signed_put_object_extract_preserves_request_metadata_on_extracted_objects()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
@@ -4185,6 +4289,60 @@ async fn test_signed_put_object_extract_returns_archive_etag() -> Result<(), Box
Ok(())
}
#[tokio::test]
async fn test_signed_put_object_extract_expands_s2_and_lz4_by_magic_with_raw_etags()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let bucket = "signed-extract-magic-codecs";
let client = env.create_s3_client();
client.create_bucket().bucket(bucket).send().await?;
let s2_tar = make_tar(&[("s2/object.txt", b"s2-body")], &[]).await;
let s2_archive = minio_go_snowball_s2_bytes(&s2_tar);
let expected_s2_etag = format!("\"{}\"", md5_hex(&s2_archive));
let s2_response = client
.put_object()
.bucket(bucket)
// minio-go intentionally uploads a compressed S2 stream with a .tar key.
.key("snowball-upload-0123456789abcdef.tar")
.body(ByteStream::from(s2_archive))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
})
.send()
.await?;
assert_eq!(s2_response.e_tag(), Some(expected_s2_etag.as_str()));
let s2_object = client.get_object().bucket(bucket).key("s2/object.txt").send().await?;
assert_eq!(s2_object.body.collect().await?.into_bytes().as_ref(), b"s2-body");
let lz4_tar = make_tar(&[("lz4/object.txt", b"lz4-body")], &[]).await;
let lz4_archive = lz4_bytes(&lz4_tar).await;
let expected_lz4_etag = format!("\"{}\"", md5_hex(&lz4_archive));
let lz4_response = client
.put_object()
.bucket(bucket)
.key("also-looks-like-a-plain.tar")
.body(ByteStream::from(lz4_archive))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
})
.send()
.await?;
assert_eq!(lz4_response.e_tag(), Some(expected_lz4_etag.as_str()));
let lz4_object = client.get_object().bucket(bucket).key("lz4/object.txt").send().await?;
assert_eq!(lz4_object.body.collect().await?.into_bytes().as_ref(), b"lz4-body");
Ok(())
}
#[tokio::test]
async fn test_signed_put_object_extract_preserves_entry_mtime() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
@@ -4309,9 +4467,15 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
let context_archive_resources = [
format!("arn:aws:s3:::{bucket}/tag-context.tar"),
format!("arn:aws:s3:::{bucket}/lock-context.tar"),
format!("arn:aws:s3:::{bucket}/legal-hold-context.tar"),
format!("arn:aws:s3:::{bucket}/user-agent-bypass.tar"),
format!("arn:aws:s3:::{bucket}/sse-bypass.tar"),
];
let tag_entry_resource = format!("arn:aws:s3:::{bucket}/tag-context-entry.txt");
let lock_entry_resource = format!("arn:aws:s3:::{bucket}/lock-context-entry.txt");
let legal_hold_entry_resource = format!("arn:aws:s3:::{bucket}/legal-hold-context-entry.txt");
let user_agent_entry_resource = format!("arn:aws:s3:::{bucket}/user-agent-bypass-entry.txt");
let sse_entry_resource = format!("arn:aws:s3:::{bucket}/sse-bypass-entry.txt");
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
@@ -4371,7 +4535,7 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
"Sid": "PaxContextArchives",
"Effect": "Allow",
"Principal": { "AWS": [pax_context_user] },
"Action": ["s3:PutObject", "s3:PutObjectRetention", "s3:PutObjectTagging"],
"Action": ["s3:PutObject", "s3:PutObjectRetention", "s3:PutObjectLegalHold", "s3:PutObjectTagging"],
"Resource": context_archive_resources
},
{
@@ -4411,6 +4575,49 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
"Principal": { "AWS": [pax_context_user] },
"Action": ["s3:PutObjectRetention"],
"Resource": [lock_entry_resource]
},
{
"Sid": "PaxLegalHoldContextPut",
"Effect": "Allow",
"Principal": { "AWS": [pax_context_user] },
"Action": ["s3:PutObject"],
"Resource": [legal_hold_entry_resource.clone()]
},
{
"Sid": "PaxLegalHoldContextAction",
"Effect": "Allow",
"Principal": { "AWS": [pax_context_user] },
"Action": ["s3:PutObjectLegalHold"],
"Resource": [legal_hold_entry_resource],
"Condition": {
"StringEquals": {
"s3:object-lock-legal-hold": "OFF"
}
}
},
{
"Sid": "MemberUserAgentCondition",
"Effect": "Allow",
"Principal": { "AWS": [pax_context_user] },
"Action": ["s3:PutObject"],
"Resource": [user_agent_entry_resource],
"Condition": {
"StringEquals": {
"aws:UserAgent": "trusted"
}
}
},
{
"Sid": "MemberSseCondition",
"Effect": "Allow",
"Principal": { "AWS": [pax_context_user] },
"Action": ["s3:PutObject"],
"Resource": [sse_entry_resource],
"Condition": {
"StringEquals": {
"s3:x-amz-server-side-encryption": "AES256"
}
}
}
]
})
@@ -4423,9 +4630,14 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
let cases = [
(
"legal-hold.tar",
put_only_client,
put_only_client.clone(),
HashMap::from([("minio.metadata.x-amz-object-lock-legal-hold", "ON".to_string())]),
),
(
"tagging.tar",
put_only_client,
HashMap::from([("minio.metadata.x-amz-tagging", "classification=restricted".to_string())]),
),
(
"retention-condition.tar",
conditional_client,
@@ -4512,6 +4724,57 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
assert_eq!(stored.body.collect().await?.into_bytes().as_ref(), b"condition-body");
let pax_context_client = restricted_user_client(&env, pax_context_user, pax_context_secret);
for (archive_key, entry_key, pax_key, injected_value, outer_user_agent) in [
(
"user-agent-bypass.tar",
"user-agent-bypass-entry.txt",
"minio.metadata.user-agent",
"trusted",
Some("untrusted"),
),
(
"sse-bypass.tar",
"sse-bypass-entry.txt",
"minio.metadata.x-amz-server-side-encryption",
"AES256",
None,
),
] {
let pax = HashMap::from([(pax_key, injected_value.to_string())]);
let archive = make_tar_with_pax_entry(entry_key, b"must-not-write", None, &pax).await;
let err = pax_context_client
.put_object()
.bucket(bucket)
.key(archive_key)
.body(ByteStream::from(archive))
.customize()
.mutate_request(move |req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
if let Some(user_agent) = outer_user_agent {
req.headers_mut().insert("user-agent", user_agent);
}
})
.send()
.await
.expect_err("PAX metadata must not satisfy unrelated IAM request conditions");
assert_eq!(
err.as_service_error().and_then(|error| error.meta().code()),
Some("AccessDenied"),
"{archive_key}"
);
let err = admin_client
.head_object()
.bucket(bucket)
.key(entry_key)
.send()
.await
.expect_err("a denied PAX member must not be written");
assert!(matches!(
err.as_service_error().and_then(|error| error.meta().code()),
Some("NoSuchKey" | "NotFound")
));
}
let tag_pax = HashMap::from([("minio.metadata.x-amz-tagging", "classification=public".to_string())]);
let archive = make_tar_with_pax_entry("tag-context-entry.txt", b"tag-context-body", None, &tag_pax).await;
pax_context_client
@@ -4575,6 +4838,34 @@ async fn test_signed_put_object_extract_authorizes_each_pax_privilege_and_retent
pax_retain_until
);
let legal_hold_pax = HashMap::from([("minio.metadata.x-amz-object-lock-legal-hold", "ON".to_string())]);
let archive = make_tar_with_pax_entry("legal-hold-context-entry.txt", b"must-not-write", None, &legal_hold_pax).await;
let err = pax_context_client
.put_object()
.bucket(bucket)
.key("legal-hold-context.tar")
.object_lock_legal_hold_status(aws_sdk_s3::types::ObjectLockLegalHoldStatus::Off)
.body(ByteStream::from(archive))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
})
.send()
.await
.expect_err("PAX legal hold must replace the outer value in the member IAM condition context");
assert_eq!(err.as_service_error().and_then(|error| error.meta().code()), Some("AccessDenied"));
let err = admin_client
.head_object()
.bucket(bucket)
.key("legal-hold-context-entry.txt")
.send()
.await
.expect_err("a denied PAX legal-hold member must not be written");
assert!(matches!(
err.as_service_error().and_then(|error| error.meta().code()),
Some("NoSuchKey" | "NotFound")
));
Ok(())
}
@@ -5050,8 +5341,8 @@ async fn test_signed_put_object_extract_expands_tzst_archive() -> Result<(), Box
}
#[tokio::test]
async fn test_signed_put_object_extract_rejects_missing_archive_extension() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
async fn test_signed_put_object_extract_uses_magic_without_requiring_or_trusting_extension()
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
@@ -5064,8 +5355,7 @@ async fn test_signed_put_object_extract_rejects_missing_archive_extension() -> R
admin_client.create_bucket().bucket(bucket).send().await?;
let tar_bytes = make_tar(&[("plain.txt", b"plain-body")], &[]).await;
let result = admin_client
admin_client
.put_object()
.bucket(bucket)
.key(archive_key)
@@ -5075,15 +5365,80 @@ async fn test_signed_put_object_extract_rejects_missing_archive_extension() -> R
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
})
.send()
.await;
.await?;
assert_s3_error_code(result, "InvalidArgument");
let plain = admin_client.get_object().bucket(bucket).key("plain.txt").send().await?;
assert_eq!(plain.body.collect().await?.into_bytes().as_ref(), b"plain-body");
let raw_with_gzip_suffix = make_tar(&[("raw-with-wrong-suffix.txt", b"raw-body")], &[]).await;
admin_client
.put_object()
.bucket(bucket)
.key("raw-but-named.tar.gz")
.body(ByteStream::from(raw_with_gzip_suffix))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
})
.send()
.await?;
let raw = admin_client
.get_object()
.bucket(bucket)
.key("raw-with-wrong-suffix.txt")
.send()
.await?;
assert_eq!(raw.body.collect().await?.into_bytes().as_ref(), b"raw-body");
let gzip_with_tar_suffix = gzip_bytes(&make_tar(&[("gzip-with-wrong-suffix.txt", b"gzip-body")], &[]).await);
admin_client
.put_object()
.bucket(bucket)
.key("gzip-but-named.tar")
.body(ByteStream::from(gzip_with_tar_suffix))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
})
.send()
.await?;
let gzip = admin_client
.get_object()
.bucket(bucket)
.key("gzip-with-wrong-suffix.txt")
.send()
.await?;
assert_eq!(gzip.body.collect().await?.into_bytes().as_ref(), b"gzip-body");
let zlib_archive = zlib_bytes(&make_tar(&[("zlib-extension.txt", b"zlib-body")], &[]).await);
admin_client
.put_object()
.bucket(bucket)
.key("bundle.zlib")
.body(ByteStream::from(zlib_archive))
.customize()
.mutate_request(|req| {
req.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
})
.send()
.await?;
let zlib = admin_client
.get_object()
.bucket(bucket)
.key("zlib-extension.txt")
.send()
.await?;
assert_eq!(zlib.body.collect().await?.into_bytes().as_ref(), b"zlib-body");
Ok(())
}
#[tokio::test]
async fn test_signed_put_object_extract_rejects_invalid_tar_gz_payload() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
async fn test_signed_put_object_extract_rejects_invalid_archive_payload() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
+244 -9
View File
@@ -36,9 +36,10 @@
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
use rustfs_signer::constants::{UNSIGNED_PAYLOAD, UNSIGNED_PAYLOAD_TRAILER};
use rustfs_signer::request_signature_v4::{SIGN_V4_ALGORITHM, get_scope, get_signature, get_signing_key};
use std::fmt::Write as _;
use std::io::Cursor;
use time::macros::format_description;
use time::{Duration, OffsetDateTime};
use tracing::info;
@@ -98,15 +99,37 @@ impl SigV4 {
/// header AND folded into the canonical request — pass the hash of the
/// body you *claim* to send, which may differ from what you actually send.
fn sign(&self, method: &str, path: &str, canonical_query: &str, content_sha256: &str) -> SignedHeaders {
let amz_date = amz_datetime(self.time);
let signed_headers = "host;x-amz-content-sha256;x-amz-date";
self.sign_with_extra_headers(method, path, canonical_query, content_sha256, &[])
}
let canonical_headers = format!(
"host:{host}\nx-amz-content-sha256:{sha}\nx-amz-date:{date}\n",
host = self.host,
sha = content_sha256,
date = amz_date,
);
/// Sign additional request headers while preserving SigV4's lowercase,
/// lexicographically sorted canonical-header representation.
fn sign_with_extra_headers(
&self,
method: &str,
path: &str,
canonical_query: &str,
content_sha256: &str,
extra_signed_headers: &[(&str, &str)],
) -> SignedHeaders {
let amz_date = amz_datetime(self.time);
let mut canonical_header_values = vec![
("host", self.host.as_str()),
("x-amz-content-sha256", content_sha256),
("x-amz-date", amz_date.as_str()),
];
canonical_header_values.extend(extra_signed_headers.iter().copied());
canonical_header_values.sort_unstable_by(|left, right| left.0.cmp(right.0));
let signed_headers = canonical_header_values
.iter()
.map(|(name, _)| *name)
.collect::<Vec<_>>()
.join(";");
let mut canonical_headers = String::new();
for (name, value) in canonical_header_values {
let _ = writeln!(canonical_headers, "{name}:{value}");
}
let canonical_request =
format!("{method}\n{path}\n{canonical_query}\n{canonical_headers}\n{signed_headers}\n{content_sha256}");
@@ -179,6 +202,34 @@ async fn setup(env: &mut RustFSTestEnvironment) -> Result<(), Box<dyn std::error
Ok(())
}
async fn build_single_member_archive(
member_key: &str,
member_body: &[u8],
) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
let mut header = tokio_tar::Header::new_gnu();
header.set_size(member_body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, member_key, Cursor::new(member_body)).await?;
Ok(builder.into_inner().await?.into_inner())
}
fn sha256_base64(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
base64_simd::STANDARD.encode_to_string(Sha256::digest(data))
}
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");
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}\r\n\r\n").as_bytes());
encoded
}
/// Positive control: a correctly hand-signed request must succeed. Without
/// this, every negative assertion below could pass for the wrong reason (a
/// broken signer that never produces a valid signature).
@@ -249,6 +300,128 @@ async fn tampered_signature_returns_signature_does_not_match() -> Result<(), Box
Ok(())
}
/// `STREAMING-UNSIGNED-PAYLOAD-TRAILER` disables per-chunk signatures, not the
/// seed/header SigV4 signature. A forged request must be rejected before the
/// Snowball handler can publish any archive member.
#[tokio::test]
async fn snowball_streaming_unsigned_trailer_rejects_forged_signature() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let archive_key = "forged-streaming-snowball.tar";
let member_key = "must-not-be-published.txt";
let archive = build_single_member_archive(member_key, b"forged request payload").await?;
let decoded_content_length = archive.len().to_string();
let encoded_body = encode_unsigned_aws_chunked_with_sha256_trailer(&archive);
let path = format!("/{BUCKET}/{archive_key}");
let mut signer = SigV4::new(&env);
signer.secret_key = "wrong-secret-for-forged-streaming-request".to_string();
let extra_signed_headers = [
("content-encoding", "aws-chunked"),
("x-amz-decoded-content-length", decoded_content_length.as_str()),
("x-amz-meta-snowball-auto-extract", "true"),
("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-meta-snowball-auto-extract", "true")
.header("x-amz-trailer", "x-amz-checksum-sha256")
.body(encoded_body)
.send()
.await?;
let status = response.status();
let body = response.text().await?;
assert_eq!(status.as_u16(), 403, "forged streaming signature must be 403, body:\n{body}");
assert_error_code(&body, "SignatureDoesNotMatch");
let absent = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(member_key)
.send()
.await
.expect_err("a forged streaming request must not publish a Snowball member");
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(())
}
/// Snowball must consume the complete aws-chunked body before reading the
/// trailing checksum exported by s3s into the PutObject response.
#[tokio::test]
async fn snowball_streaming_unsigned_trailer_returns_sha256_checksum() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
let archive_key = "valid-streaming-snowball.tar";
let member_key = "streaming-checksum-member.txt";
let member_body = b"valid streaming Snowball payload";
let archive = build_single_member_archive(member_key, member_body).await?;
let expected_checksum = sha256_base64(&archive);
let decoded_content_length = archive.len().to_string();
let encoded_body = encode_unsigned_aws_chunked_with_sha256_trailer(&archive);
let path = format!("/{BUCKET}/{archive_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-meta-snowball-auto-extract", "true"),
("x-amz-sdk-checksum-algorithm", "SHA256"),
("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-meta-snowball-auto-extract", "true")
.header("x-amz-sdk-checksum-algorithm", "SHA256")
.header("x-amz-trailer", "x-amz-checksum-sha256")
.body(encoded_body)
.send()
.await?;
let status = response.status();
let response_checksum = response
.headers()
.get("x-amz-checksum-sha256")
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let response_body = response.text().await?;
assert_eq!(status.as_u16(), 200, "valid streaming Snowball PUT failed, body:\n{response_body}");
assert_eq!(response_checksum.as_deref(), Some(expected_checksum.as_str()));
let member = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(member_key)
.send()
.await?;
let stored = member.body.collect().await?.into_bytes();
assert_eq!(stored.as_ref(), member_body);
env.stop_server();
Ok(())
}
/// (b) A valid AccessKeyId paired with the wrong secret key must be rejected
/// with SignatureDoesNotMatch / 403.
#[tokio::test]
@@ -376,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,452 @@
// 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 environment for on-demand migration (ODM) end-to-end tests.
//!
//! [`OdmTestEnv`] pairs one RustFS server under test with one in-process
//! programmable S3 source ([`FakeS3Target`]). Admin calls target the route
//! convention fixed by the tracking plan
//! (`/rustfs/admin/v3/on-demand-migration/{bucket}`, JSON bodies); the
//! server side lands with ODM-07, so until then the wrappers compile but are
//! not exercised by the harness self-test.
use crate::common::{RustFSTestEnvironment, signed_request};
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, FakeS3TargetOptions, SeedMetadata};
use aws_config::retry::RetryConfig;
use aws_sdk_s3::Client;
use aws_sdk_s3::config::{Credentials, Region};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use bytes::Bytes;
use serde::Serialize;
use std::fmt;
pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
/// Module switch the server reads at startup (`false` before GA). The harness
/// turns it on so scenario tests exercise the feature without repeating it.
pub const ODM_MODULE_SWITCH_ENV: &str = "RUSTFS_ON_DEMAND_MIGRATION_ENABLED";
/// Admin route prefix; the bucket name is appended as one path segment.
pub const ODM_ADMIN_ROUTE: &str = "/rustfs/admin/v3/on-demand-migration";
/// Region the fake source is addressed with (it accepts any SigV4 region).
pub const FAKE_SOURCE_REGION: &str = "us-east-1";
/// Wire form of the bucket-level ODM configuration (ODM-01 model). Every
/// field is public so a scenario can tweak one knob and serialize the rest
/// with the documented defaults.
#[derive(Debug, Clone, Serialize)]
pub struct OdmSourceSpec {
pub version: u32,
pub enabled: bool,
pub source: OdmSource,
pub filter: OdmFilter,
pub policy: OdmPolicy,
}
#[derive(Debug, Clone, Serialize)]
pub struct OdmSource {
pub provider: String,
pub endpoint: String,
pub region: String,
pub bucket: String,
pub path_style: String,
pub credentials: Option<OdmCredentials>,
pub tls: OdmTls,
}
#[derive(Clone, Serialize)]
pub struct OdmCredentials {
pub access_key: String,
pub secret_key: String,
pub session_token: Option<String>,
}
impl fmt::Debug for OdmCredentials {
/// Test logs are captured into CI artifacts; keep the secret out of them.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OdmCredentials")
.field("access_key", &self.access_key)
.field("secret_key", &"REDACTED")
.field("session_token", &self.session_token.as_ref().map(|_| "REDACTED"))
.finish()
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct OdmTls {
pub skip_verify: bool,
pub ca_cert_pem: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct OdmFilter {
pub prefix: Option<String>,
pub source_prefix: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OdmPolicy {
pub head: String,
pub range_get: String,
pub source_error: String,
pub respect_local_delete_marker: bool,
pub preserve_etag: bool,
pub copy_tags: bool,
pub emit_events: bool,
pub negative_cache_ttl_secs: u64,
pub inline_max_bytes: u64,
pub multipart_part_size_bytes: u64,
pub max_concurrent_pulls: u32,
pub pull_queue_capacity: u32,
pub source_timeout: OdmSourceTimeout,
pub bandwidth_limit_bytes_per_sec: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OdmSourceTimeout {
pub connect_ms: u64,
pub first_byte_ms: u64,
pub idle_ms: u64,
}
impl Default for OdmPolicy {
/// The ODM-01 defaults verbatim.
fn default() -> Self {
Self {
head: "proxy".to_string(),
range_get: "serve_and_backfill".to_string(),
source_error: "propagate".to_string(),
respect_local_delete_marker: true,
preserve_etag: true,
copy_tags: false,
emit_events: true,
negative_cache_ttl_secs: 30,
inline_max_bytes: 16 * 1024 * 1024,
multipart_part_size_bytes: 64 * 1024 * 1024,
max_concurrent_pulls: 8,
pull_queue_capacity: 1024,
source_timeout: OdmSourceTimeout {
connect_ms: 5_000,
first_byte_ms: 15_000,
idle_ms: 30_000,
},
bandwidth_limit_bytes_per_sec: None,
}
}
}
impl OdmSourceSpec {
/// Enabled configuration pointing at a bucket on the fake source with the
/// fixture credentials, path-style addressing, and default policy.
pub fn for_fake_source(source: &FakeS3Target, source_bucket: impl Into<String>) -> Self {
Self::new(
"s3",
source.endpoint(),
FAKE_SOURCE_REGION,
source_bucket,
FAKE_ACCESS_KEY,
FAKE_SECRET_KEY,
)
}
/// Enabled configuration pointing at a bucket on a second RustFS server
/// (see [`start_source_rustfs`]).
pub fn for_rustfs_source(source: &RustFSTestEnvironment, source_bucket: impl Into<String>) -> Self {
Self::new(
"rustfs",
&source.url,
FAKE_SOURCE_REGION,
source_bucket,
&source.access_key,
&source.secret_key,
)
}
fn new(
provider: &str,
endpoint: &str,
region: &str,
source_bucket: impl Into<String>,
access_key: &str,
secret_key: &str,
) -> Self {
Self {
version: 1,
enabled: true,
source: OdmSource {
provider: provider.to_string(),
endpoint: endpoint.to_string(),
region: region.to_string(),
bucket: source_bucket.into(),
path_style: "path".to_string(),
credentials: Some(OdmCredentials {
access_key: access_key.to_string(),
secret_key: secret_key.to_string(),
session_token: None,
}),
tls: OdmTls::default(),
},
filter: OdmFilter::default(),
policy: OdmPolicy::default(),
}
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::to_value(self).expect("ODM source spec serializes")
}
}
/// Backfill job control (ODM-12 route shape).
#[derive(Debug, Clone)]
pub enum BackfillOp {
Start(BackfillRequest),
Cancel,
Status,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct BackfillRequest {
pub prefix: Option<String>,
pub skip_existing: Option<String>,
pub dry_run: bool,
}
/// Status plus raw body of an admin call, so a scenario can assert on the
/// HTTP status first and only then parse the JSON.
#[derive(Debug, Clone)]
pub struct AdminResponse {
pub status: u16,
pub body: String,
}
impl AdminResponse {
pub fn json(&self) -> Result<serde_json::Value, BoxError> {
Ok(serde_json::from_str(&self.body)?)
}
}
/// One object to seed into the source.
#[derive(Clone)]
pub struct SeedObject {
pub key: String,
pub body: Bytes,
pub metadata: SeedMetadata,
}
impl SeedObject {
pub fn new(key: impl Into<String>, body: impl Into<Bytes>) -> Self {
Self {
key: key.into(),
body: body.into(),
metadata: SeedMetadata::new(),
}
}
pub fn with_metadata(mut self, metadata: SeedMetadata) -> Self {
self.metadata = metadata;
self
}
}
/// RustFS under test plus its fake S3 source.
pub struct OdmTestEnv {
pub rustfs: RustFSTestEnvironment,
pub source: FakeS3Target,
/// S3 client for the RustFS under test.
pub client: Client,
}
impl OdmTestEnv {
/// Start a fake source with default limits and a RustFS server with the
/// ODM module switch enabled.
pub async fn start() -> Result<Self, BoxError> {
Self::start_with_options(FakeS3TargetOptions::default()).await
}
pub async fn start_with_options(options: FakeS3TargetOptions) -> Result<Self, BoxError> {
let source = FakeS3Target::start_with_options(options).await?;
let mut rustfs = RustFSTestEnvironment::new().await?;
rustfs
.start_rustfs_server_with_env(vec![], &[(ODM_MODULE_SWITCH_ENV, "true")])
.await?;
let client = rustfs.create_s3_client();
Ok(Self { rustfs, source, client })
}
/// S3 client addressing the fake source directly, for assertions on the
/// source's own state. Retries are off so a scripted fault is consumed by
/// exactly the request the test issued.
pub fn source_client(&self) -> Client {
fake_source_client(&self.source)
}
/// Enabled ODM configuration for `source_bucket` on the fake source.
pub fn fake_source_spec(&self, source_bucket: impl Into<String>) -> OdmSourceSpec {
OdmSourceSpec::for_fake_source(&self.source, source_bucket)
}
/// `PUT /rustfs/admin/v3/on-demand-migration/{bucket}` with the JSON spec.
pub async fn configure_source(&self, bucket: &str, spec: &OdmSourceSpec) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::PUT, &format!("/{bucket}"), Some(spec.to_json()))
.await
}
/// Same as [`Self::configure_source`] with `dry-run=true`: validate and
/// probe without persisting.
pub async fn validate_source(&self, bucket: &str, spec: &OdmSourceSpec) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::PUT, &format!("/{bucket}?dry-run=true"), Some(spec.to_json()))
.await
}
/// `GET .../{bucket}`: redacted configuration, 404 when unconfigured.
pub async fn get_config(&self, bucket: &str) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::GET, &format!("/{bucket}"), None).await
}
/// `DELETE .../{bucket}`: remove the configuration (idempotent).
pub async fn disable(&self, bucket: &str) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::DELETE, &format!("/{bucket}"), None).await
}
/// `GET .../{bucket}/status`: runtime snapshot.
pub async fn status(&self, bucket: &str) -> Result<AdminResponse, BoxError> {
self.admin(http::Method::GET, &format!("/{bucket}/status"), None).await
}
/// Backfill control: `POST .../{bucket}/backfill?op=start|cancel` or
/// `GET .../{bucket}/backfill` for the checkpoint.
pub async fn backfill(&self, bucket: &str, op: BackfillOp) -> Result<AdminResponse, BoxError> {
match op {
BackfillOp::Start(request) => {
self.admin(
http::Method::POST,
&format!("/{bucket}/backfill?op=start"),
Some(serde_json::to_value(request)?),
)
.await
}
BackfillOp::Cancel => {
self.admin(http::Method::POST, &format!("/{bucket}/backfill?op=cancel"), None)
.await
}
BackfillOp::Status => self.admin(http::Method::GET, &format!("/{bucket}/backfill"), None).await,
}
}
async fn admin(
&self,
method: http::Method,
path_and_query: &str,
body: Option<serde_json::Value>,
) -> Result<AdminResponse, BoxError> {
let url = format!("{}{ODM_ADMIN_ROUTE}{path_and_query}", self.rustfs.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, &self.rustfs.access_key, &self.rustfs.secret_key, body, content_type).await?;
Ok(AdminResponse {
status: response.status().as_u16(),
body: response.text().await?,
})
}
/// Store objects directly in the fake source (no wire traffic, no journal
/// entries). Returns the ETags in input order.
pub fn seed_source(&self, source_bucket: &str, objects: &[SeedObject]) -> Vec<String> {
objects
.iter()
.map(|object| {
self.source
.put_seed_object(source_bucket, object.key.clone(), object.body.clone(), &object.metadata)
})
.collect()
}
/// Whether `key` is listed by the RustFS under test. Listing is served from
/// local state only, so this does not trigger a migration the way GET or
/// HEAD would.
pub async fn local_key_listed(&self, bucket: &str, key: &str) -> Result<bool, BoxError> {
let listed = self
.client
.list_objects_v2()
.bucket(bucket)
.prefix(key)
.max_keys(1)
.send()
.await?;
Ok(listed.contents().iter().any(|object| object.key() == Some(key)))
}
/// Panics unless `key` is stored locally with exactly `expected` bytes.
/// Presence is checked through listing first so a missing object fails
/// here instead of being pulled from the source by the GET.
pub async fn assert_local_present(&self, bucket: &str, key: &str, expected: &[u8]) {
assert!(
self.local_key_listed(bucket, key)
.await
.unwrap_or_else(|error| panic!("listing {bucket}/{key} failed: {error}")),
"{bucket}/{key} must be present locally"
);
let body = self
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.unwrap_or_else(|error| panic!("GET {bucket}/{key} failed: {error}"))
.body
.collect()
.await
.unwrap_or_else(|error| panic!("reading {bucket}/{key} failed: {error}"))
.into_bytes();
assert_eq!(body.as_ref(), expected, "{bucket}/{key} local content mismatch");
}
/// Panics if `key` is listed locally.
pub async fn assert_local_absent(&self, bucket: &str, key: &str) {
assert!(
!self
.local_key_listed(bucket, key)
.await
.unwrap_or_else(|error| panic!("listing {bucket}/{key} failed: {error}")),
"{bucket}/{key} must be absent locally"
);
}
}
/// S3 client for the fake source with retries disabled (see
/// [`OdmTestEnv::source_client`]).
pub fn fake_source_client(source: &FakeS3Target) -> Client {
let credentials = Credentials::new(FAKE_ACCESS_KEY, FAKE_SECRET_KEY, None, None, "odm-fake-source");
Client::from_conf(
aws_sdk_s3::Config::builder()
.credentials_provider(credentials)
.region(Region::new(FAKE_SOURCE_REGION))
.endpoint_url(source.endpoint())
.force_path_style(true)
.behavior_version_latest()
.retry_config(RetryConfig::standard().with_max_attempts(1))
.http_client(SmithyHttpClientBuilder::new().build_http())
.build(),
)
}
/// Start a second, fully independent RustFS process (own port, data
/// directory, and default credentials) to act as a real S3 source. It is
/// spawned the same way `reliant::tiering` starts its cold tier; the process
/// is stopped and its directory removed when the returned environment drops.
pub async fn start_source_rustfs() -> Result<RustFSTestEnvironment, BoxError> {
let mut source = RustFSTestEnvironment::new().await?;
source.start_rustfs_server_without_cleanup(vec![]).await?;
Ok(source)
}
@@ -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 unstalled_started = Instant::now();
client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert!(unstalled_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,24 @@
// 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; ODM behavior scenarios are
//! separate modules wired by later tasks.
pub mod common;
mod harness_self_test;
@@ -0,0 +1,152 @@
// 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.
//! Regression coverage for rustfs#6830: a signed empty `PutObject` request
//! without `Content-Length` and without `Transfer-Encoding` is still a
//! zero-length object upload.
#[cfg(test)]
mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use http::header::{CONTENT_LENGTH, HOST, TRANSFER_ENCODING};
use rustfs_signer::sign_v4;
use rustfs_utils::hash::EMPTY_STRING_SHA256_HASH;
use s3s::Body;
use std::error::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{Duration, timeout};
use tracing::info;
const RAW_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
fn parse_status(raw_response: &str) -> Option<u16> {
raw_response.lines().next()?.split_whitespace().nth(1)?.parse().ok()
}
async fn send_raw_signed_put(
url: &str,
access_key: &str,
secret_key: &str,
transfer_encoding: Option<&str>,
raw_body: &[u8],
) -> Result<String, Box<dyn Error + Send + Sync>> {
let uri = url.parse::<http::Uri>()?;
let authority = uri.authority().ok_or("request URL missing authority")?.to_string();
let path_and_query = uri.path_and_query().ok_or("request URL missing path")?.as_str().to_string();
let mut request = http::Request::builder()
.method(http::Method::PUT)
.uri(uri)
.header(HOST, authority.clone())
.header("x-amz-content-sha256", EMPTY_STRING_SHA256_HASH);
if let Some(value) = transfer_encoding {
request = request.header(TRANSFER_ENCODING, value);
}
let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1");
let mut raw_request = format!("PUT {path_and_query} HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\n");
for (name, value) in signed.headers() {
if name == HOST || name == CONTENT_LENGTH {
continue;
}
raw_request.push_str(name.as_str());
raw_request.push_str(": ");
raw_request.push_str(value.to_str()?);
raw_request.push_str("\r\n");
}
raw_request.push_str("\r\n");
assert!(
!raw_request.to_ascii_lowercase().contains("\r\ncontent-length:"),
"raw regression request must omit Content-Length; request was:\n{raw_request}"
);
let mut stream = TcpStream::connect(&authority).await?;
stream.write_all(raw_request.as_bytes()).await?;
stream.write_all(raw_body).await?;
stream.flush().await?;
let mut response = Vec::new();
timeout(RAW_RESPONSE_TIMEOUT, stream.read_to_end(&mut response))
.await
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out reading raw PUT response"))??;
Ok(String::from_utf8_lossy(&response).into_owned())
}
#[tokio::test]
async fn test_put_object_without_content_length_boundaries() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
info!("TEST: PutObject without Content-Length boundaries");
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let empty_bucket = "put-no-content-length";
let empty_key = "empty.bin";
let chunked_bucket = "put-chunked-no-length";
let chunked_key = "chunked.bin";
client.create_bucket().bucket(empty_bucket).send().await?;
client.create_bucket().bucket(chunked_bucket).send().await?;
let url = format!("{}/{}/{}", env.url, empty_bucket, empty_key);
let raw_response = send_raw_signed_put(&url, &env.access_key, &env.secret_key, None, b"").await?;
info!("raw empty PUT response:\n{}", raw_response);
assert_eq!(
parse_status(&raw_response),
Some(200),
"empty PutObject without Content-Length should succeed, got:\n{raw_response}"
);
assert!(
raw_response.to_ascii_lowercase().contains("\r\netag:"),
"successful PutObject should return an ETag header: {raw_response}"
);
let head = client.head_object().bucket(empty_bucket).key(empty_key).send().await?;
assert_eq!(head.content_length(), Some(0), "stored object must be zero length");
let url = format!("{}/{}/{}", env.url, chunked_bucket, chunked_key);
let raw_response = send_raw_signed_put(&url, &env.access_key, &env.secret_key, Some("chunked"), b"0\r\n\r\n").await?;
info!("raw chunked PUT response:\n{}", raw_response);
assert_eq!(
parse_status(&raw_response),
Some(411),
"unknown-length chunked PutObject must stay rejected, got:\n{raw_response}"
);
assert!(
raw_response.contains("<Code>MissingContentLength</Code>"),
"expected MissingContentLength, got:\n{raw_response}"
);
let missing = client
.head_object()
.bucket(chunked_bucket)
.key(chunked_key)
.send()
.await
.expect_err("rejected unknown-length PUT must not create an object");
assert_eq!(
missing.raw_response().map(|response| response.status().as_u16()),
Some(404),
"rejected unknown-length PUT absence probe must return HTTP 404, got {missing:?}"
);
Ok(())
}
}
+1
View File
@@ -21,5 +21,6 @@ mod head_tls_bodyless_test;
mod lifecycle;
mod lock;
mod node_interact_test;
mod s3_select_compression;
mod sql;
mod tiering;
@@ -0,0 +1,351 @@
#![cfg(test)]
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::common::{RustFSTestEnvironment, init_logging};
use async_compression::tokio::write::BzEncoder;
use aws_sdk_s3::{
Client,
error::ProvideErrorMetadata,
operation::select_object_content::{SelectObjectContentOutput, builders::SelectObjectContentFluentBuilder},
types::{
CompressionType, CsvInput, CsvOutput, ExpressionType, FileHeaderInfo, InputSerialization, JsonInput, JsonOutput,
JsonType, OutputSerialization, SelectObjectContentEventStream,
},
};
use aws_smithy_types::event_stream::RawMessage;
use bytes::Bytes;
use flate2::{Compression, write::GzEncoder};
use std::{error::Error, io::Cursor, time::Duration};
use tokio::io::AsyncWriteExt;
const BUCKET: &str = "s3-select-compression";
const SELECT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
type TestResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
async fn create_test_environment(extra_env: &[(&str, &str)]) -> TestResult<(RustFSTestEnvironment, Client)> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], extra_env).await?;
let client = env.create_s3_client();
client.create_bucket().bucket(BUCKET).send().await?;
Ok((env, client))
}
async fn put_object(client: &Client, key: &str, body: &[u8]) -> TestResult<()> {
client
.put_object()
.bucket(BUCKET)
.key(key)
.body(Bytes::copy_from_slice(body).into())
.send()
.await?;
Ok(())
}
fn gzip(input: &[u8]) -> TestResult<Vec<u8>> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
std::io::Write::write_all(&mut encoder, input)?;
Ok(encoder.finish()?)
}
async fn bzip2(input: &[u8]) -> TestResult<Vec<u8>> {
let mut encoder = BzEncoder::new(Cursor::new(Vec::new()));
encoder.write_all(input).await?;
encoder.shutdown().await?;
Ok(encoder.into_inner().into_inner())
}
fn csv_select_request(
client: &Client,
key: &str,
compression: CompressionType,
expression: &str,
) -> SelectObjectContentFluentBuilder {
client
.select_object_content()
.bucket(BUCKET)
.key(key)
.expression(expression)
.expression_type(ExpressionType::Sql)
.input_serialization(
InputSerialization::builder()
.compression_type(compression)
.csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build())
.build(),
)
.output_serialization(OutputSerialization::builder().csv(CsvOutput::builder().build()).build())
}
fn json_select_request(
client: &Client,
key: &str,
compression: CompressionType,
json_type: JsonType,
) -> SelectObjectContentFluentBuilder {
client
.select_object_content()
.bucket(BUCKET)
.key(key)
.expression("SELECT name FROM S3Object")
.expression_type(ExpressionType::Sql)
.input_serialization(
InputSerialization::builder()
.compression_type(compression)
.json(JsonInput::builder().set_type(Some(json_type)).build())
.build(),
)
.output_serialization(OutputSerialization::builder().json(JsonOutput::builder().build()).build())
}
async fn collect_success(
mut response: SelectObjectContentOutput,
compressed_bytes: usize,
processed_bytes: usize,
) -> TestResult<Vec<u8>> {
tokio::time::timeout(SELECT_RESPONSE_TIMEOUT, async move {
let mut records = Vec::new();
let mut stats = None;
let mut saw_end = false;
while let Some(event) = response.payload.recv().await? {
assert!(!saw_end, "Select emitted an event after End");
match event {
SelectObjectContentEventStream::Records(event) => {
assert!(stats.is_none(), "Select emitted Records after Stats");
if let Some(payload) = event.payload {
records.extend_from_slice(payload.as_ref());
}
}
SelectObjectContentEventStream::Stats(event) => {
assert!(stats.is_none(), "Select emitted more than one Stats event");
stats = event.details;
}
SelectObjectContentEventStream::End(_) => {
assert!(stats.is_some(), "Select emitted End before Stats");
saw_end = true;
}
_ => assert!(stats.is_none(), "Select emitted a non-terminal event after Stats"),
}
}
let stats = stats.ok_or("Select response ended without a Stats event")?;
assert_eq!(stats.bytes_scanned(), Some(i64::try_from(compressed_bytes)?));
assert_eq!(stats.bytes_processed(), Some(i64::try_from(processed_bytes)?));
assert_eq!(stats.bytes_returned(), Some(i64::try_from(records.len())?));
assert!(saw_end, "Select response ended without an End event");
Ok::<_, Box<dyn Error + Send + Sync>>(records)
})
.await
.map_err(|_| -> Box<dyn Error + Send + Sync> { "Select response timed out".into() })?
}
async fn assert_truncated_stream_failure(mut response: SelectObjectContentOutput) -> TestResult<()> {
tokio::time::timeout(SELECT_RESPONSE_TIMEOUT, async move {
loop {
match response.payload.recv().await {
Err(error) => {
// S3 Select request-level errors use `error` frames, which this SDK version exposes as raw response errors.
if let Some(code) = error.code() {
assert_eq!(code, "TruncatedInput", "unexpected modeled event-stream error: {error:?}");
} else if let aws_sdk_s3::error::SdkError::ResponseError(context) = &error
&& let RawMessage::Decoded(message) = context.raw()
{
let header = |name: &str| {
message
.headers()
.iter()
.find(|header| header.name().as_str() == name)
.and_then(|header| header.value().as_string().ok())
.map(|value| value.as_str())
};
assert_eq!(header(":message-type"), Some("error"));
assert_eq!(header(":error-code"), Some("TruncatedInput"));
} else {
panic!("unexpected event-stream error: {error:?}");
}
return Ok(());
}
Ok(Some(SelectObjectContentEventStream::Stats(_))) | Ok(Some(SelectObjectContentEventStream::End(_))) => {
return Err("truncated compressed input reached a success terminal event".into());
}
Ok(Some(_)) => {}
Ok(None) => return Err("truncated compressed input ended without an error event".into()),
}
}
})
.await
.map_err(|_| -> Box<dyn Error + Send + Sync> { "truncated Select response timed out".into() })?
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_select_object_content_compressed_csv_and_json() -> TestResult<()> {
const CSV: &[u8] = b"name,age\nAlice,30\nBob,25\n";
const JSON_LINES: &[u8] = b"{\"name\":\"Alice\"}\n{\"name\":\"Bob\"}\n";
const JSON_DOCUMENT: &[u8] = br#"[{"name":"Alice"},{"name":"Bob"}]"#;
let (_env, client) = create_test_environment(&[]).await?;
let gzip_csv = gzip(CSV)?;
put_object(&client, "records.csv.gz", &gzip_csv).await?;
let gzip_csv_records = collect_success(
csv_select_request(&client, "records.csv.gz", CompressionType::Gzip, "SELECT * FROM S3Object")
.send()
.await?,
gzip_csv.len(),
CSV.len(),
)
.await?;
assert_eq!(gzip_csv_records, b"Alice,30\nBob,25\n");
let bzip_csv = bzip2(CSV).await?;
put_object(&client, "records.csv.bz2", &bzip_csv).await?;
let bzip_csv_records = collect_success(
csv_select_request(&client, "records.csv.bz2", CompressionType::Bzip2, "SELECT * FROM S3Object")
.send()
.await?,
bzip_csv.len(),
CSV.len(),
)
.await?;
assert_eq!(bzip_csv_records, gzip_csv_records);
let gzip_json_lines = gzip(JSON_LINES)?;
put_object(&client, "json-lines", &gzip_json_lines).await?;
let gzip_json_records = collect_success(
json_select_request(&client, "json-lines", CompressionType::Gzip, JsonType::Lines)
.send()
.await?,
gzip_json_lines.len(),
JSON_LINES.len(),
)
.await?;
assert_eq!(gzip_json_records, JSON_LINES);
let bzip_json_lines = bzip2(JSON_LINES).await?;
put_object(&client, "records.jsonl.bz2", &bzip_json_lines).await?;
let bzip_json_records = collect_success(
json_select_request(&client, "records.jsonl.bz2", CompressionType::Bzip2, JsonType::Lines)
.send()
.await?,
bzip_json_lines.len(),
JSON_LINES.len(),
)
.await?;
assert_eq!(bzip_json_records, gzip_json_records);
let gzip_json_document = gzip(JSON_DOCUMENT)?;
put_object(&client, "document.json.gz", &gzip_json_document).await?;
let document_records = collect_success(
json_select_request(&client, "document.json.gz", CompressionType::Gzip, JsonType::Document)
.send()
.await?,
gzip_json_document.len(),
JSON_DOCUMENT.len(),
)
.await?;
assert_eq!(document_records, JSON_LINES);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_select_object_content_invalid_compressed_stream_fails() -> TestResult<()> {
const CSV: &[u8] = b"name\nAlice\n";
let (_env, client) = create_test_environment(&[]).await?;
put_object(&client, "invalid.csv.gz", CSV).await?;
let invalid = csv_select_request(&client, "invalid.csv.gz", CompressionType::Gzip, "SELECT * FROM S3Object")
.send()
.await
.expect_err("invalid GZIP header must fail before streaming");
assert_eq!(
invalid.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidCompressionFormat")
);
put_object(&client, "empty.csv.gz", b"").await?;
let empty = csv_select_request(&client, "empty.csv.gz", CompressionType::Gzip, "SELECT * FROM S3Object")
.send()
.await
.expect_err("empty GZIP input must fail as truncated");
assert_eq!(empty.as_service_error().and_then(ProvideErrorMetadata::code), Some("TruncatedInput"));
let mut truncated = bzip2(CSV).await?;
truncated.pop();
put_object(&client, "truncated.csv.bz2", &truncated).await?;
let truncated = csv_select_request(&client, "truncated.csv.bz2", CompressionType::Bzip2, "SELECT * FROM S3Object")
.send()
.await?;
assert_truncated_stream_failure(truncated).await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_select_object_content_compressed_disconnect_releases_query() -> TestResult<()> {
const OBJECT: &str = "disconnect.csv.gz";
const ROWS: usize = 16 * 1024;
const RELEASE_ATTEMPTS: usize = 20;
const RELEASE_BACKOFF: Duration = Duration::from_millis(25);
let (_env, client) = create_test_environment(&[("RUSTFS_S3SELECT_MAX_CONCURRENT_QUERIES", "1")]).await?;
let row = format!("{}\n", "x".repeat(1023));
let mut body = Vec::with_capacity("value\n".len() + ROWS * row.len());
body.extend_from_slice(b"value\n");
for _ in 0..ROWS {
body.extend_from_slice(row.as_bytes());
}
let compressed = gzip(&body)?;
put_object(&client, OBJECT, &compressed).await?;
let first = csv_select_request(&client, OBJECT, CompressionType::Gzip, "SELECT * FROM S3Object")
.send()
.await?;
let saturated = csv_select_request(&client, OBJECT, CompressionType::Gzip, "SELECT * FROM S3Object")
.send()
.await
.expect_err("the unread compressed response should retain the only query permit");
assert_eq!(saturated.as_service_error().and_then(ProvideErrorMetadata::code), Some("SlowDown"));
drop(first);
let second = tokio::time::timeout(Duration::from_secs(5), async {
for attempt in 0..RELEASE_ATTEMPTS {
match csv_select_request(&client, OBJECT, CompressionType::Gzip, "SELECT * FROM S3Object")
.send()
.await
{
Ok(response) => return Ok::<_, Box<dyn Error + Send + Sync>>(response),
Err(error)
if error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("SlowDown")
&& attempt + 1 < RELEASE_ATTEMPTS =>
{
tokio::time::sleep(RELEASE_BACKOFF).await;
}
Err(error) if error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("SlowDown") => {
return Err("disconnected compressed Select retained its query permit".into());
}
Err(error) => return Err(format!("unexpected Select error after disconnect: {error}").into()),
}
}
Err("query permit release retry loop ended unexpectedly".into())
})
.await
.map_err(|_| -> Box<dyn Error + Send + Sync> { "compressed Select did not release its query permit".into() })??;
drop(second);
Ok(())
}
+372 -1
View File
@@ -17,7 +17,8 @@ use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
CsvInput, CsvOutput, ExpressionType, FileHeaderInfo, InputSerialization, JsonInput, JsonOutput, JsonType, OutputSerialization,
CsvInput, CsvOutput, ExpressionType, FileHeaderInfo, InputSerialization, JsonInput, JsonOutput, JsonType,
OutputSerialization, RequestProgress,
};
use bytes::Bytes;
use std::error::Error;
@@ -26,6 +27,9 @@ use std::time::Duration;
const BUCKET: &str = "test-sql-bucket";
const CSV_OBJECT: &str = "test-data.csv";
const JSON_OBJECT: &str = "test-data.json";
const JSON_DOCUMENT_OBJECT: &str = "nested-data.json";
const JSON_ROOT_ARRAY_OBJECT: &str = "root-array.json";
const JSON_ROOT_SCALAR_ARRAY_OBJECT: &str = "root-scalars.json";
const SELECT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
type TestResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
@@ -73,6 +77,69 @@ async fn upload_test_json(client: &Client) -> TestResult<()> {
Ok(())
}
async fn upload_nested_json_document(client: &Client) -> TestResult<()> {
let json_data = r#"{"departments":[{"employees":[{"name":"Alice","active":true},{"name":"Bob","active":false}]},{"employees":[{"name":"Charlie","active":true}]}]}"#;
client
.put_object()
.bucket(BUCKET)
.key(JSON_DOCUMENT_OBJECT)
.body(Bytes::from_static(json_data.as_bytes()).into())
.send()
.await?;
client
.put_object()
.bucket(BUCKET)
.key(JSON_ROOT_ARRAY_OBJECT)
.body(Bytes::from_static(br#"[{"name":"Alice"},{"name":"Bob"}]"#).into())
.send()
.await?;
client
.put_object()
.bucket(BUCKET)
.key(JSON_ROOT_SCALAR_ARRAY_OBJECT)
.body(Bytes::from_static(b"[1,2]").into())
.send()
.await?;
Ok(())
}
async fn select_json_document(client: &Client, key: &str, expression: &str) -> TestResult<String> {
let response = client
.select_object_content()
.bucket(BUCKET)
.key(key)
.expression(expression)
.expression_type(ExpressionType::Sql)
.input_serialization(
InputSerialization::builder()
.json(JsonInput::builder().set_type(Some(JsonType::Document)).build())
.build(),
)
.output_serialization(OutputSerialization::builder().json(JsonOutput::builder().build()).build())
.send()
.await?;
process_select_response(response).await
}
fn csv_select_request(
client: &Client,
key: &str,
) -> aws_sdk_s3::operation::select_object_content::builders::SelectObjectContentFluentBuilder {
client
.select_object_content()
.bucket(BUCKET)
.key(key)
.expression("SELECT * FROM S3Object")
.expression_type(ExpressionType::Sql)
.input_serialization(
InputSerialization::builder()
.csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build())
.build(),
)
.output_serialization(OutputSerialization::builder().csv(CsvOutput::builder().build()).build())
}
async fn process_select_response(
mut event_stream: aws_sdk_s3::operation::select_object_content::SelectObjectContentOutput,
) -> TestResult<String> {
@@ -104,6 +171,209 @@ async fn process_select_response(
.map_err(|_| -> Box<dyn Error + Send + Sync> { "Select response timed out".into() })?
}
async fn assert_input_byte_stats(
client: &Client,
object: &str,
body: &[u8],
expression: &str,
input_serialization: InputSerialization,
output_serialization: OutputSerialization,
progress_enabled: bool,
) -> TestResult<()> {
client
.put_object()
.bucket(BUCKET)
.key(object)
.body(Bytes::copy_from_slice(body).into())
.send()
.await?;
let mut request = client
.select_object_content()
.bucket(BUCKET)
.key(object)
.expression(expression)
.expression_type(ExpressionType::Sql)
.input_serialization(input_serialization)
.output_serialization(output_serialization);
if progress_enabled {
request = request.request_progress(RequestProgress::builder().enabled(true).build());
}
let response = request.send().await?;
let mut payload = response.payload;
let mut records_len = 0_u64;
let mut last_progress: Option<aws_sdk_s3::types::Progress> = None;
let mut stats = None;
let mut saw_end = false;
tokio::time::timeout(SELECT_RESPONSE_TIMEOUT, async {
// The AWS SDK validates both event-stream CRCs before yielding an event.
while let Some(event) = payload.recv().await? {
assert!(!saw_end, "Select emitted an event after End");
match event {
aws_sdk_s3::types::SelectObjectContentEventStream::Records(records) => {
assert!(stats.is_none(), "Select emitted Records after Stats");
if let Some(bytes) = records.payload {
records_len = records_len.saturating_add(u64::try_from(bytes.as_ref().len())?);
}
}
aws_sdk_s3::types::SelectObjectContentEventStream::Progress(event) => {
assert!(stats.is_none(), "Select emitted Progress after Stats");
let details = event.details.ok_or("Progress event did not contain details")?;
if let Some(previous) = last_progress.as_ref() {
assert!(details.bytes_scanned() >= previous.bytes_scanned());
assert!(details.bytes_processed() >= previous.bytes_processed());
assert!(details.bytes_returned() >= previous.bytes_returned());
}
last_progress = Some(details);
}
aws_sdk_s3::types::SelectObjectContentEventStream::Stats(event) => {
assert!(stats.is_none(), "Select emitted more than one Stats event");
stats = event.details;
}
aws_sdk_s3::types::SelectObjectContentEventStream::End(_) => {
assert!(stats.is_some(), "Select emitted End before Stats");
saw_end = true;
}
_ => assert!(stats.is_none(), "Select emitted a non-terminal event after Stats"),
}
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
})
.await
.map_err(|_| -> Box<dyn Error + Send + Sync> { "Select response timed out".into() })??;
let stats = stats.ok_or("Select response ended without a Stats event")?;
let input_len = i64::try_from(body.len())?;
assert_eq!(stats.bytes_scanned(), Some(input_len));
assert_eq!(stats.bytes_processed(), Some(input_len));
assert_eq!(stats.bytes_returned(), Some(i64::try_from(records_len)?));
if progress_enabled {
if let Some(progress) = last_progress {
assert!(stats.bytes_scanned() >= progress.bytes_scanned());
assert!(stats.bytes_processed() >= progress.bytes_processed());
assert!(stats.bytes_returned() >= progress.bytes_returned());
}
} else {
assert!(last_progress.is_none(), "disabled request progress emitted a Progress event");
}
assert!(saw_end, "Select response ended without an End event");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_select_object_content_http_event_order_crc_and_input_byte_stats() -> TestResult<()> {
const CSV_BODY: &[u8] = b"name,age\nAlice,30\nBob,25\n";
const JSON_LINES_BODY: &[u8] = b"{\"name\":\"Alice\"}\n{\"name\":\"Bob\"}\n";
const JSON_DOCUMENT_BODY: &[u8] = b"[{\"name\":\"Alice\"},{\"name\":\"Bob\"}]";
let (_env, client) = create_test_environment().await?;
setup_test_bucket(&client).await?;
assert_input_byte_stats(
&client,
"input-metrics.csv",
CSV_BODY,
"SELECT name FROM S3Object",
InputSerialization::builder()
.csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build())
.build(),
OutputSerialization::builder().csv(CsvOutput::builder().build()).build(),
true,
)
.await?;
assert_input_byte_stats(
&client,
"input-metrics.jsonl",
JSON_LINES_BODY,
"SELECT name FROM S3Object",
InputSerialization::builder()
.json(JsonInput::builder().set_type(Some(JsonType::Lines)).build())
.build(),
OutputSerialization::builder().json(JsonOutput::builder().build()).build(),
true,
)
.await?;
assert_input_byte_stats(
&client,
"input-metrics.json",
JSON_DOCUMENT_BODY,
"SELECT name FROM S3Object",
InputSerialization::builder()
.json(JsonInput::builder().set_type(Some(JsonType::Document)).build())
.build(),
OutputSerialization::builder().json(JsonOutput::builder().build()).build(),
true,
)
.await?;
assert_input_byte_stats(
&client,
"input-metrics-without-progress.csv",
CSV_BODY,
"SELECT name FROM S3Object",
InputSerialization::builder()
.csv(CsvInput::builder().file_header_info(FileHeaderInfo::Use).build())
.build(),
OutputSerialization::builder().csv(CsvOutput::builder().build()).build(),
false,
)
.await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_select_object_content_http_disconnect_releases_query() -> TestResult<()> {
const OBJECT: &str = "disconnect.csv";
const ROWS: usize = 16 * 1024;
const RELEASE_BACKOFF: Duration = Duration::from_millis(25);
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_S3SELECT_MAX_CONCURRENT_QUERIES", "1")])
.await?;
let client = env.create_s3_client();
setup_test_bucket(&client).await?;
let row = format!("{}\n", "x".repeat(1023));
let mut body = Vec::with_capacity("value\n".len() + ROWS * row.len());
body.extend_from_slice(b"value\n");
for _ in 0..ROWS {
body.extend_from_slice(row.as_bytes());
}
client
.put_object()
.bucket(BUCKET)
.key(OBJECT)
.body(Bytes::from(body).into())
.send()
.await?;
// Leaving this response body unread fills the bounded HTTP/event channels before the query can finish.
let first = csv_select_request(&client, OBJECT).send().await?;
let saturated = csv_select_request(&client, OBJECT)
.send()
.await
.expect_err("the first HTTP stream should retain the only query permit");
assert_eq!(saturated.as_service_error().and_then(ProvideErrorMetadata::code), Some("SlowDown"));
drop(first);
let second = tokio::time::timeout(Duration::from_secs(5), async {
loop {
match csv_select_request(&client, OBJECT).send().await {
Ok(response) => return Ok::<_, Box<dyn Error + Send + Sync>>(response),
Err(error) if error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("SlowDown") => {
tokio::time::sleep(RELEASE_BACKOFF).await;
}
Err(error) => return Err(format!("unexpected Select error after disconnect: {error}").into()),
}
}
})
.await
.map_err(|_| -> Box<dyn Error + Send + Sync> { "disconnected Select did not release its query permit".into() })??;
drop(second);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_select_object_content_csv_basic() -> TestResult<()> {
let (_env, client) = create_test_environment().await?;
@@ -228,6 +498,107 @@ async fn test_select_object_content_json_basic() -> TestResult<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_select_object_content_nested_json_source_path() -> TestResult<()> {
let (_env, client) = create_test_environment().await?;
setup_test_bucket(&client).await?;
upload_nested_json_document(&client).await?;
let result = select_json_document(
&client,
JSON_DOCUMENT_OBJECT,
"SELECT e.name FROM S3Object[*].departments[*].employees[*] AS e WHERE e.active = true",
)
.await?;
let names: Vec<String> = result
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| -> TestResult<String> {
let value: serde_json::Value = serde_json::from_str(line)?;
Ok(value["name"].as_str().ok_or("missing name field")?.to_string())
})
.collect::<TestResult<_>>()?;
assert_eq!(names, vec!["Alice", "Charlie"]);
let terminal_scalars = select_json_document(
&client,
JSON_DOCUMENT_OBJECT,
"SELECT NAME FROM S3Object[*].DEPARTMENTS[*].employees[*].NAME",
)
.await?;
let scalar_names: Vec<String> = terminal_scalars
.lines()
.map(|line| -> TestResult<String> {
let value: serde_json::Value = serde_json::from_str(line)?;
Ok(value["name"].as_str().ok_or("missing scalar name field")?.to_string())
})
.collect::<TestResult<_>>()?;
assert_eq!(scalar_names, vec!["Alice", "Bob", "Charlie"]);
let aliased_scalars = select_json_document(
&client,
JSON_DOCUMENT_OBJECT,
"SELECT v FROM S3Object[*].departments[*].employees[*].name AS v",
)
.await?;
let aliased_names: Vec<String> = aliased_scalars
.lines()
.map(|line| -> TestResult<String> {
let value: serde_json::Value = serde_json::from_str(line)?;
Ok(value["v"].as_str().ok_or("missing aliased scalar field")?.to_string())
})
.collect::<TestResult<_>>()?;
assert_eq!(aliased_names, vec!["Alice", "Bob", "Charlie"]);
let root_array = select_json_document(&client, JSON_ROOT_ARRAY_OBJECT, "SELECT c.name FROM S3Object[*][*] AS c").await?;
let root_names: Vec<String> = root_array
.lines()
.map(|line| -> TestResult<String> {
let value: serde_json::Value = serde_json::from_str(line)?;
Ok(value["name"].as_str().ok_or("missing root-array name field")?.to_string())
})
.collect::<TestResult<_>>()?;
assert_eq!(root_names, vec!["Alice", "Bob"]);
let root_index = select_json_document(&client, JSON_ROOT_ARRAY_OBJECT, "SELECT c.name FROM S3Object[*][0] AS c").await?;
let root_index_value: serde_json::Value = serde_json::from_str(root_index.trim())?;
assert_eq!(root_index_value["name"], "Alice");
let root_scalars = select_json_document(&client, JSON_ROOT_SCALAR_ARRAY_OBJECT, "SELECT V FROM S3Object AS V").await?;
let scalar_values: Vec<i64> = root_scalars
.lines()
.map(|line| -> TestResult<i64> {
let value: serde_json::Value = serde_json::from_str(line)?;
Ok(value["v"].as_i64().ok_or("missing root scalar value")?)
})
.collect::<TestResult<_>>()?;
assert_eq!(scalar_values, vec![1, 2]);
let implicit_root_scalars =
select_json_document(&client, JSON_ROOT_SCALAR_ARRAY_OBJECT, "SELECT S3Object FROM S3Object").await?;
let implicit_scalar_values: Vec<i64> = implicit_root_scalars
.lines()
.map(|line| -> TestResult<i64> {
let value: serde_json::Value = serde_json::from_str(line)?;
Ok(value["s3object"].as_i64().ok_or("missing implicit root scalar value")?)
})
.collect::<TestResult<_>>()?;
assert_eq!(implicit_scalar_values, vec![1, 2]);
let quoted_root_scalars =
select_json_document(&client, JSON_ROOT_SCALAR_ARRAY_OBJECT, "SELECT \"S3Object\" FROM \"S3Object\"").await?;
let quoted_scalar_values: Vec<i64> = quoted_root_scalars
.lines()
.map(|line| -> TestResult<i64> {
let value: serde_json::Value = serde_json::from_str(line)?;
Ok(value["S3Object"].as_i64().ok_or("missing quoted root scalar value")?)
})
.collect::<TestResult<_>>()?;
assert_eq!(quoted_scalar_values, vec![1, 2]);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_select_object_content_csv_limit() -> TestResult<()> {
let (_env, client) = create_test_environment().await?;
+14 -6
View File
@@ -210,19 +210,27 @@ async fn add_rustfs_tier(hot: &RustFSTestEnvironment, cold: &RustFSTestEnvironme
}
}
async fn remove_rustfs_tier_force(hot: &RustFSTestEnvironment) -> TestResult {
let path = format!("/rustfs/admin/v3/tier/{TIER_NAME}?force=true");
fn clear_tiers_confirmation_token(now: OffsetDateTime) -> String {
let mut rand = "AGD1R25GI3I1GJGUGJFD7FBS4DFAASDF".to_string();
rand.insert_str(3, &now.day().to_string());
rand.insert_str(17, &now.month().to_string());
rand.insert_str(23, &now.year().to_string());
rand
}
async fn clear_rustfs_tiers_force(hot: &RustFSTestEnvironment) -> TestResult {
let deadline = Instant::now() + StdDuration::from_secs(30);
loop {
let (status, resp) =
signed_admin_request(&hot.url, Method::DELETE, &path, None, &hot.access_key, &hot.secret_key).await?;
let rand = clear_tiers_confirmation_token(OffsetDateTime::now_utc());
let path = format!("/rustfs/admin/v3/tier/clear?rand={rand}&force=true");
let (status, resp) = signed_admin_request(&hot.url, Method::POST, &path, None, &hot.access_key, &hot.secret_key).await?;
if status.is_success() {
return Ok(());
}
if (!resp.contains("TierNameBackendInUse") && !resp.contains(TIER_MUTATION_RECOVERY_CHANGED))
|| Instant::now() >= deadline
{
return Err(format!("RemoveTier(RustFS) failed: status={status}, body={resp}").into());
return Err(format!("ClearTier(RustFS) failed: status={status}, body={resp}").into());
}
// Tier mutation cleanup and startup recovery are asynchronous.
tokio::time::sleep(StdDuration::from_millis(100)).await;
@@ -1708,7 +1716,7 @@ async fn test_manual_transition_async_tier_failure_reports_terminal_partial() ->
0,
)
.await?;
remove_rustfs_tier_force(&hot).await?;
clear_rustfs_tiers_force(&hot).await?;
let due_mtime = OffsetDateTime::now_utc() - time::Duration::hours(25);
put_backdated_single_part_object(
@@ -40,10 +40,10 @@ mod tests {
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";
@@ -142,6 +142,23 @@ mod tests {
run_command("dmsetup", &["resume", &self.dm_name])
}
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_available(&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);
@@ -194,6 +211,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 +381,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)
@@ -472,8 +567,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 +689,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 +739,13 @@ mod tests {
.collect()
}
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 +753,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));
}
@@ -822,13 +875,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 +900,7 @@ mod tests {
}
}
let mut target_mount = target_mount.ok_or("target drive was not mounted with the faultable block fixture")?;
let mut replacement_mount = ZramBlockMount::reserve(&target_disk)?;
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "true");
@@ -852,28 +908,34 @@ 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")?;
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 versions = seed_baseline(&clients[0], &target_disk)
.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"))?;
replacement_mount.mount_target()?;
let missing_before_restart = incomplete_versions(&target_disk, &versions)?;
assert_eq!(
missing_before_restart.len(),
@@ -882,46 +944,26 @@ 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 {
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
verify_bodies(&clients[0], &versions).await
}
.await;
let stop_result = cluster.stop_node_gracefully(TARGET_NODE).await;
let replacement_cleanup_result = replacement_mount.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 +996,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()));
@@ -0,0 +1,84 @@
// 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.
//! Raw HTTP regression coverage for the Select request root alias (backlog#1626).
use crate::common::{RustFSTestEnvironment, signed_s3_request};
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use std::error::Error;
use uuid::Uuid;
type TestResult<T = ()> = Result<T, Box<dyn Error + Send + Sync>>;
const CSV_BODY: &[u8] = b"name\nGatewayJ-root-alias\nignored\n";
const EXPECTED_RECORD: &[u8] = b"GatewayJ-root-alias";
fn select_request(root: &str) -> String {
format!(
r#"<{root} xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Expression>SELECT s.name FROM S3Object s WHERE s.name = 'GatewayJ-root-alias'</Expression>
<ExpressionType>SQL</ExpressionType>
<InputSerialization><CSV><FileHeaderInfo>USE</FileHeaderInfo></CSV></InputSerialization>
<OutputSerialization><CSV/></OutputSerialization>
</{root}>"#
)
}
async fn raw_select(env: &RustFSTestEnvironment, bucket: &str, object: &str, root: &str) -> TestResult {
let response = signed_s3_request(
Method::POST,
&format!("{}/{bucket}/{object}?select&select-type=2", env.url),
Some(select_request(root)),
Some("application/xml"),
&env.access_key,
&env.secret_key,
)
.await?;
let status = response.status();
let body = response.bytes().await?.to_vec();
assert_eq!(
status,
reqwest::StatusCode::OK,
"{root} root was rejected: {}",
String::from_utf8_lossy(&body)
);
assert!(
body.windows(EXPECTED_RECORD.len()).any(|window| window == EXPECTED_RECORD),
"{root} root did not return the projected record"
);
Ok(())
}
#[tokio::test]
async fn select_request_root_alias_reaches_select_endpoint() -> TestResult {
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(Vec::new()).await?;
let client = env.create_s3_client();
let bucket = format!("select-root-{}", Uuid::new_v4().simple());
let object = "input.csv";
client.create_bucket().bucket(&bucket).send().await?;
client
.put_object()
.bucket(&bucket)
.key(object)
.body(ByteStream::from_static(CSV_BODY))
.send()
.await?;
raw_select(&env, &bucket, object, "SelectObjectContentRequest").await?;
raw_select(&env, &bucket, object, "SelectRequest").await?;
Ok(())
}
@@ -17,8 +17,56 @@ mod tests {
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use flate2::{Compression, write::GzEncoder};
use std::error::Error;
use std::io::Cursor;
use std::io::{Cursor, Write};
fn pax_record(key: &str, value: &str) -> Vec<u8> {
let payload = format!("{key}={value}\n");
let mut len = payload.len() + 3;
loop {
let record = format!("{len} {payload}");
if record.len() == len {
return record.into_bytes();
}
len = record.len();
}
}
async fn append_pax_header(
builder: &mut tokio_tar::Builder<Cursor<Vec<u8>>>,
entry_type: tokio_tar::EntryType,
records: &[(&str, &str)],
) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut payload = Vec::new();
for (key, value) in records {
payload.extend(pax_record(key, value));
}
let mut header = tokio_tar::Header::new_ustar();
header.set_entry_type(entry_type);
header.set_size(u64::try_from(payload.len()).expect("PAX payload length should fit in u64"));
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, "PaxHeaders.X/snowball", Cursor::new(payload))
.await?;
Ok(())
}
async fn append_typed_entry(
builder: &mut tokio_tar::Builder<Cursor<Vec<u8>>>,
path: &str,
entry_type: tokio_tar::EntryType,
body: &[u8],
) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut header = tokio_tar::Header::new_gnu();
header.set_entry_type(entry_type);
header.set_size(u64::try_from(body.len()).expect("TAR member length should fit in u64"));
header.set_mode(0o644);
header.set_cksum();
builder.append_data(&mut header, path, Cursor::new(body)).await?;
Ok(())
}
async fn build_test_archive() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
@@ -69,12 +117,50 @@ mod tests {
Ok(builder.into_inner().await?.into_inner())
}
fn build_archive_with_parent_dir_entry(victim_bucket: &str) -> Vec<u8> {
let path = format!("../{victim_bucket}/evil-injected.txt");
let data = b"injected-body";
async fn build_archive_with_invalid_checksum() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let mut archive = build_test_archive().await?;
archive[0] ^= 1;
Ok(archive)
}
async fn build_archive_with_negative_gnu_mtime() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
let mut header = tokio_tar::Header::new_gnu();
header.set_size(b"negative-mtime-body".len() as u64);
header.set_mode(0o644);
header.as_old_mut().mtime.fill(0xff);
builder
.append_data(&mut header, "negative-mtime.txt", Cursor::new(b"negative-mtime-body".as_slice()))
.await?;
Ok(builder.into_inner().await?.into_inner())
}
fn gzip_member(payload: &[u8]) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(payload)?;
Ok(encoder.finish()?)
}
async fn build_concatenated_gzip_archive() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let archive = build_test_archive().await?;
let split_at = archive.len() / 2;
let mut encoded = gzip_member(&archive[..split_at])?;
encoded.extend(gzip_member(&archive[split_at..])?);
Ok(encoded)
}
async fn build_gzip_archive_with_invalid_crc() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let mut encoded = gzip_member(&build_test_archive().await?)?;
let crc_offset = encoded.len().checked_sub(8).expect("gzip fixture must contain a trailer");
encoded[crc_offset] ^= 1;
Ok(encoded)
}
fn append_raw_tar_entry_with_type(archive: &mut Vec<u8>, path: &[u8], data: &[u8], entry_type: u8) {
assert!(path.len() <= 100, "raw TAR fixture path must fit in the name field");
let mut header = [0u8; 512];
header[..path.len()].copy_from_slice(path.as_bytes());
header[..path.len()].copy_from_slice(path);
header[100..108].copy_from_slice(b"0000644\0");
header[108..116].copy_from_slice(b"0000000\0");
header[116..124].copy_from_slice(b"0000000\0");
@@ -82,7 +168,7 @@ mod tests {
header[124..136].copy_from_slice(size.as_bytes());
header[136..148].copy_from_slice(b"00000000000\0");
header[148..156].fill(b' ');
header[156] = b'0';
header[156] = entry_type;
header[257..263].copy_from_slice(b"ustar\0");
header[263..265].copy_from_slice(b"00");
@@ -90,11 +176,87 @@ mod tests {
let checksum = format!("{:06o}\0 ", checksum);
header[148..156].copy_from_slice(checksum.as_bytes());
let mut archive = Vec::new();
archive.extend_from_slice(&header);
archive.extend_from_slice(data);
let padding = (512 - (data.len() % 512)) % 512;
archive.extend(std::iter::repeat_n(0, padding));
}
fn append_raw_tar_entry(archive: &mut Vec<u8>, path: &[u8], data: &[u8]) {
append_raw_tar_entry_with_type(archive, path, data, b'0');
}
fn build_archive_with_parent_dir_entry(victim_bucket: &str) -> Vec<u8> {
let path = format!("../{victim_bucket}/evil-injected.txt");
let mut archive = Vec::new();
append_raw_tar_entry(&mut archive, path.as_bytes(), b"injected-body");
archive.extend_from_slice(&[0u8; 1024]);
archive
}
async fn build_member_semantics_archive() -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
append_pax_header(
&mut builder,
tokio_tar::EntryType::XGlobalHeader,
&[
("minio.metadata.x-amz-meta-owner", "global"),
("minio.metadata.x-amz-meta-snowball-auto-extract", "true"),
],
)
.await?;
append_pax_header(
&mut builder,
tokio_tar::EntryType::XHeader,
&[("minio.metadata.x-amz-meta-owner", "local")],
)
.await?;
append_typed_entry(&mut builder, "regular.txt", tokio_tar::EntryType::Regular, b"regular-body").await?;
for (path, entry_type) in [
("char", tokio_tar::EntryType::Char),
("block", tokio_tar::EntryType::Block),
("fifo", tokio_tar::EntryType::Fifo),
] {
append_typed_entry(&mut builder, path, entry_type, b"").await?;
}
let mut directory = tokio_tar::Header::new_gnu();
directory.set_entry_type(tokio_tar::EntryType::Directory);
directory.set_size(0);
directory.set_mode(0o755);
directory.set_cksum();
builder
.append_data(&mut directory, "directory/", Cursor::new(Vec::new()))
.await?;
for (path, entry_type) in [
("hard-link", tokio_tar::EntryType::Link),
("symlink", tokio_tar::EntryType::Symlink),
("continuous", tokio_tar::EntryType::Continuous),
("unknown", tokio_tar::EntryType::Other(b'9')),
] {
append_typed_entry(&mut builder, path, entry_type, b"").await?;
}
Ok(builder.into_inner().await?.into_inner())
}
async fn build_versioned_member_archive(path: &str, version_id: &str) -> Result<Vec<u8>, Box<dyn Error + Send + Sync>> {
let mut builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
append_pax_header(&mut builder, tokio_tar::EntryType::XHeader, &[("minio.versionId", version_id)]).await?;
append_typed_entry(&mut builder, path, tokio_tar::EntryType::Regular, b"versioned-body").await?;
Ok(builder.into_inner().await?.into_inner())
}
fn build_archive_with_invalid_utf8_entry() -> Vec<u8> {
let mut archive = Vec::new();
append_raw_tar_entry(&mut archive, b"invalid-\xff.txt", b"ignored-body");
append_raw_tar_entry(&mut archive, b"valid.txt", b"valid-body");
archive.extend_from_slice(&[0u8; 1024]);
archive
}
fn build_archive_with_invalid_utf8_symlink() -> Vec<u8> {
let mut archive = Vec::new();
append_raw_tar_entry_with_type(&mut archive, b"invalid-\xff-link", b"", b'2');
append_raw_tar_entry(&mut archive, b"valid.txt", b"valid-body");
archive.extend_from_slice(&[0u8; 1024]);
archive
}
@@ -135,6 +297,147 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_applies_member_semantics_and_metadata_precedence() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "snowball-member-semantics";
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("fixture.tar")
.metadata("Snowball-Auto-Extract", "true")
.metadata("Minio-Snowball-Prefix", "members")
.metadata("owner", "outer")
.body(ByteStream::from(build_member_semantics_archive().await?))
.send()
.await?;
let regular = client.head_object().bucket(bucket).key("members/regular.txt").send().await?;
let regular_metadata = regular.metadata().expect("regular member should expose metadata");
assert_eq!(regular_metadata.get("owner").map(String::as_str), Some("local"));
assert!(!regular_metadata.contains_key("snowball-auto-extract"));
assert!(!regular_metadata.contains_key("minio-snowball-prefix"));
for key in ["char", "block", "fifo"] {
let head = client
.head_object()
.bucket(bucket)
.key(format!("members/{key}"))
.send()
.await?;
assert_eq!(head.content_length(), Some(0), "{key} should be materialized as an empty object");
assert_eq!(
head.metadata().and_then(|metadata| metadata.get("owner")).map(String::as_str),
Some("outer"),
"{key} should not inherit global PAX metadata"
);
}
let directory = client.head_object().bucket(bucket).key("members/directory/").send().await?;
assert_eq!(directory.content_length(), Some(0));
for key in ["hard-link", "symlink", "continuous", "unknown"] {
let error = client
.head_object()
.bucket(bucket)
.key(format!("members/{key}"))
.send()
.await
.expect_err("unsupported TAR entry type must be skipped");
assert_eq!(error.into_service_error().code(), Some("NotFound"), "{key}");
}
env.stop_server();
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_validates_pax_version_id_against_bucket_state() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "snowball-version-semantics";
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("null.tar")
.metadata("Snowball-Auto-Extract", "true")
.body(ByteStream::from(build_versioned_member_archive("null.txt", "null").await?))
.send()
.await?;
let null_member = client.get_object().bucket(bucket).key("null.txt").send().await?;
assert_eq!(null_member.body.collect().await?.into_bytes().as_ref(), b"versioned-body");
for (archive_key, member_key, version_id) in [
("uuid.tar", "uuid.txt", uuid::Uuid::new_v4().to_string()),
("uppercase-null.tar", "uppercase-null.txt", "NULL".to_string()),
] {
let error = client
.put_object()
.bucket(bucket)
.key(archive_key)
.metadata("Snowball-Auto-Extract", "true")
.body(ByteStream::from(build_versioned_member_archive(member_key, &version_id).await?))
.send()
.await
.expect_err("invalid or unversioned UUID import must be rejected");
assert_eq!(error.into_service_error().code(), Some("InvalidArgument"), "{archive_key}");
let missing = client
.head_object()
.bucket(bucket)
.key(member_key)
.send()
.await
.expect_err("rejected version import must not create an object");
assert_eq!(missing.into_service_error().code(), Some("NotFound"), "{member_key}");
}
client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
aws_sdk_s3::types::VersioningConfiguration::builder()
.status(aws_sdk_s3::types::BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let imported_version_id = uuid::Uuid::new_v4().to_string();
client
.put_object()
.bucket(bucket)
.key("versioned-uuid.tar")
.metadata("Snowball-Auto-Extract", "true")
.body(ByteStream::from(
build_versioned_member_archive("versioned-uuid.txt", &imported_version_id).await?,
))
.send()
.await?;
let imported = client
.get_object()
.bucket(bucket)
.key("versioned-uuid.txt")
.version_id(&imported_version_id)
.send()
.await?;
assert_eq!(imported.version_id(), Some(imported_version_id.as_str()));
assert_eq!(imported.body.collect().await?.into_bytes().as_ref(), b"versioned-body");
env.stop_server();
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_supports_standard_headers_with_combined_extract_options()
-> Result<(), Box<dyn Error + Send + Sync>> {
@@ -263,6 +566,113 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_accepts_negative_gnu_mtime() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "snowball-negative-mtime";
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("fixture.tar")
.metadata("Snowball-Auto-Extract", "true")
.body(ByteStream::from(build_archive_with_negative_gnu_mtime().await?))
.send()
.await?;
let object = client.get_object().bucket(bucket).key("negative-mtime.txt").send().await?;
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), b"negative-mtime-body");
env.stop_server();
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_consumes_concatenated_gzip_members() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "snowball-concatenated-gzip";
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("fixture.tar.gz")
.metadata("Snowball-Auto-Extract", "true")
.body(ByteStream::from(build_concatenated_gzip_archive().await?))
.send()
.await?;
let object = client.get_object().bucket(bucket).key("root.txt").send().await?;
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), b"root payload\n");
env.stop_server();
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_rejects_gzip_crc_error_when_ignore_errors_enabled() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "snowball-gzip-crc-ignore-errors";
client.create_bucket().bucket(bucket).send().await?;
let err = client
.put_object()
.bucket(bucket)
.key("fixture.tar.gz")
.metadata("Snowball-Auto-Extract", "true")
.metadata("Minio-Snowball-Ignore-Errors", "true")
.body(ByteStream::from(build_gzip_archive_with_invalid_crc().await?))
.send()
.await
.expect_err("gzip integrity failures must remain fatal under ignore-errors");
assert_eq!(err.into_service_error().code(), Some("InvalidArgument"));
env.stop_server();
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_rejects_mismatched_content_md5() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "snowball-content-md5";
client.create_bucket().bucket(bucket).send().await?;
let err = client
.put_object()
.bucket(bucket)
.key("fixture.tar")
.metadata("Snowball-Auto-Extract", "true")
.content_md5("AAAAAAAAAAAAAAAAAAAAAA==")
.body(ByteStream::from(build_test_archive().await?))
.send()
.await
.expect_err("mismatched Content-MD5 must fail after the raw body reaches EOF");
assert_eq!(err.into_service_error().code(), Some("BadDigest"));
env.stop_server();
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_ignores_invalid_entries_when_requested() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -299,7 +709,100 @@ mod tests {
}
#[tokio::test]
async fn snowball_auto_extract_rejects_parent_dir_entry_without_cross_bucket_write()
async fn snowball_auto_extract_skips_non_utf8_symlink_without_ignore_errors() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "snowball-invalid-utf8-link";
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("fixture.tar")
.metadata("Snowball-Auto-Extract", "true")
.body(ByteStream::from(build_archive_with_invalid_utf8_symlink()))
.send()
.await?;
let valid = client.get_object().bucket(bucket).key("valid.txt").send().await?;
assert_eq!(valid.body.collect().await?.into_bytes().as_ref(), b"valid-body");
let listed = client.list_objects_v2().bucket(bucket).send().await?;
let keys: Vec<_> = listed.contents().iter().filter_map(|entry| entry.key()).collect();
assert_eq!(keys, vec!["valid.txt"]);
env.stop_server();
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_skips_non_utf8_member_without_lossy_key_collision() -> Result<(), Box<dyn Error + Send + Sync>>
{
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "snowball-invalid-utf8";
client.create_bucket().bucket(bucket).send().await?;
client
.put_object()
.bucket(bucket)
.key("fixture.tar")
.metadata("Snowball-Auto-Extract", "true")
.metadata("Minio-Snowball-Ignore-Errors", "true")
.body(ByteStream::from(build_archive_with_invalid_utf8_entry()))
.send()
.await?;
let valid = client.get_object().bucket(bucket).key("valid.txt").send().await?;
assert_eq!(valid.body.collect().await?.into_bytes().as_ref(), b"valid-body");
let listed = client.list_objects_v2().bucket(bucket).send().await?;
let keys: Vec<_> = listed.contents().iter().filter_map(|entry| entry.key()).collect();
assert_eq!(keys, vec!["valid.txt"]);
env.stop_server();
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_rejects_corrupt_tar_when_ignore_errors_enabled() -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server(vec![]).await?;
let client = env.create_s3_client();
let bucket = "snowball-corrupt-ignore-errors";
let archive = build_archive_with_invalid_checksum().await?;
client.create_bucket().bucket(bucket).send().await?;
let err = client
.put_object()
.bucket(bucket)
.key("fixture.tar")
.metadata("Snowball-Auto-Extract", "true")
.metadata("Minio-Snowball-Ignore-Errors", "true")
.body(ByteStream::from(archive))
.send()
.await
.expect_err("corrupt TAR structure must remain fatal under ignore-errors");
assert_eq!(err.into_service_error().code(), Some("InvalidArgument"));
let listed = client.list_objects_v2().bucket(bucket).send().await?;
assert!(listed.contents().is_empty(), "corrupt archive must not produce objects");
env.stop_server();
Ok(())
}
#[tokio::test]
async fn snowball_auto_extract_rejects_parent_dir_entry_even_when_ignore_errors_enabled()
-> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
@@ -319,6 +822,7 @@ mod tests {
.bucket(attacker_bucket)
.key("fixture.tar")
.metadata("Snowball-Auto-Extract", "true")
.metadata("Minio-Snowball-Ignore-Errors", "true")
.body(ByteStream::from(archive))
.send()
.await
+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::{
@@ -12,14 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::common::{RustFSTestEnvironment, init_logging};
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, rustfs_binary_path};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, ServerSideEncryption, VersioningConfiguration,
};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::task::JoinSet;
use tokio::time::{Instant, sleep};
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
@@ -28,6 +31,14 @@ const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
const PLAIN_BUCKET: &str = "upgrade-plain-data";
const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
const MIXED_NODE_COUNT: usize = 4;
const MULTIPART_WORKERS: usize = 16;
const MULTIPART_UPLOADS_PER_WORKER: usize = 16;
// Peers keep a restarted node's drive in Suspect/Returning for roughly
// probe_interval (2s) x success_threshold (3) after it comes back; 30s
// comfortably covers that window plus CI scheduling jitter.
const LISTING_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let path = std::env::var_os(SOURCE_BINARY_ENV)
@@ -103,6 +114,132 @@ async fn write_multipart(client: &Client, bucket: &str, key: &str, parts: &[Vec<
Ok(())
}
fn configure_cluster_logs(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
return Ok(());
};
std::fs::create_dir_all(&log_dir)?;
for node_idx in 0..cluster.nodes.len() {
let path = Path::new(&log_dir).join(format!("mixed-upgrade-node-{node_idx}.log"));
cluster.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
}
Ok(())
}
async fn write_multipart_load(clients: &[Client], phase: &str) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let mut tasks = JoinSet::new();
for worker in 0..MULTIPART_WORKERS {
let client = clients[worker % clients.len()].clone();
let phase = phase.to_string();
tasks.spawn(async move {
let mut keys = Vec::with_capacity(MULTIPART_UPLOADS_PER_WORKER);
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
let key = format!("{phase}/multipart/{worker:02}/{upload:02}");
let part = vec![u8::try_from(worker)?; 64 * 1024];
write_multipart(&client, MIXED_BUCKET, &key, &[part]).await?;
keys.push(key);
}
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(keys)
});
}
let mut keys = Vec::with_capacity(MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER);
while let Some(result) = tasks.join_next().await {
keys.extend(result??);
}
Ok(keys)
}
/// Assert that `client` eventually lists exactly `expected` objects under
/// `{phase}/`, polling until [`LISTING_CONVERGENCE_TIMEOUT`].
///
/// A single-snapshot assertion here is racy by construction: each phase both
/// writes and lists within seconds of a node restart. While a peer still holds
/// the restarted node's drive in Suspect/Returning, strict-quorum listing
/// consults only the remaining three drives and drops any object that was
/// itself legally written at write quorum (3/4 drives) during an earlier
/// node's identical post-restart window — its xl.meta is then visible on only
/// two of the three consulted drives, below the required object quorum of
/// three. GET still succeeds for such objects; only the listing under-counts
/// until drive health converges. A genuine upgrade data-loss regression still
/// fails after the deadline.
async fn wait_for_phase_listing(client: &Client, phase: &str, expected: usize, context: &str) -> TestResult {
let deadline = Instant::now() + LISTING_CONVERGENCE_TIMEOUT;
loop {
let listed = client
.list_objects_v2()
.bucket(MIXED_BUCKET)
.prefix(format!("{phase}/"))
.send()
.await?;
let count = listed.contents().len();
if count == expected {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"{context}: listing under {phase}/ returned {count} of {expected} objects even after {}s of post-restart convergence",
LISTING_CONVERGENCE_TIMEOUT.as_secs()
)
.into());
}
sleep(Duration::from_millis(500)).await;
}
}
async fn exercise_mixed_cluster(
cluster: &RustFSTestClusterEnvironment,
phase: &str,
current_node: usize,
previous_node: usize,
) -> TestResult {
let clients = cluster.create_all_clients()?;
let current_client = &clients[current_node];
let previous_client = &clients[previous_node];
let current_key = format!("{phase}/written-by-current");
let current_body = format!("{phase}: current RustFS build").into_bytes();
current_client
.put_object()
.bucket(MIXED_BUCKET)
.key(&current_key)
.body(ByteStream::from(current_body.clone()))
.send()
.await?;
assert_eq!(read_object(previous_client, MIXED_BUCKET, &current_key, None).await?.1, current_body);
let previous_key = format!("{phase}/written-by-previous");
let previous_body = format!("{phase}: previous RustFS release").into_bytes();
previous_client
.put_object()
.bucket(MIXED_BUCKET)
.key(&previous_key)
.body(ByteStream::from(previous_body.clone()))
.send()
.await?;
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
let multipart_keys = write_multipart_load(&clients, phase).await?;
let expected_count = multipart_keys.len() + 2;
for (label, client) in [("current", current_client), ("previous", previous_client)] {
wait_for_phase_listing(
client,
phase,
expected_count,
&format!("the {label} RustFS version must stream the complete mixed-version listing"),
)
.await?;
}
let last_multipart_key = format!("{phase}/multipart/{:02}/{:02}", MULTIPART_WORKERS - 1, MULTIPART_UPLOADS_PER_WORKER - 1);
assert_eq!(
read_object(previous_client, MIXED_BUCKET, &last_multipart_key, None).await?.1,
vec![u8::try_from(MULTIPART_WORKERS - 1)?; 64 * 1024]
);
Ok(())
}
#[tokio::test]
#[ignore = "requires a pinned previous RustFS release binary"]
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
@@ -252,3 +389,43 @@ async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
Ok(())
}
#[tokio::test]
#[ignore = "requires a pinned previous RustFS release binary"]
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let current_binary = rustfs_binary_path();
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
configure_cluster_logs(&mut cluster)?;
cluster.start_with_binary(&previous_binary).await?;
cluster.create_test_bucket(MIXED_BUCKET).await?;
cluster.stop_node(0)?;
cluster.start_node_from_binary(0, &current_binary).await?;
exercise_mixed_cluster(&cluster, "one-current-node", 0, 1).await?;
for node_idx in [1, 2] {
cluster.stop_node(node_idx)?;
cluster.start_node_from_binary(node_idx, &current_binary).await?;
}
exercise_mixed_cluster(&cluster, "one-previous-node", 0, 3).await?;
cluster.stop_node(3)?;
cluster.start_node_from_binary(3, &current_binary).await?;
for (node_idx, client) in cluster.create_all_clients()?.iter().enumerate() {
for phase in ["one-current-node", "one-previous-node"] {
wait_for_phase_listing(
client,
phase,
MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2,
&format!("node {node_idx}: the homogeneous current cluster must preserve every object"),
)
.await?;
}
}
Ok(())
}
+51 -12
View File
@@ -75,6 +75,10 @@ pub mod bucket {
delete_transition_candidate_for_operator, finalize_missing_transition_transaction_for_operator,
inspect_transition_transaction_for_operator,
};
#[cfg(feature = "test-util")]
pub use crate::bucket::lifecycle::transition_transaction::{
TransitionTransactionRecoveryStats, recover_transition_transaction_records,
};
}
pub mod evaluator {
@@ -99,6 +103,17 @@ pub mod bucket {
pub use crate::bucket::lifecycle::tier_delete_journal::{
persist_tier_delete_journal_entry, record_tier_delete_journal_backend_identity,
};
#[cfg(feature = "test-util")]
pub mod test_util {
/// Model a single-node, all-v6 fleet after its capability probe has completed.
///
/// Call this only once while constructing an isolated test store, before any
/// tier-delete journal permit or background worker can be active.
pub fn install_all_v6_fleet_capability_proof() {
crate::services::notification_sys::install_cross_pool_fence_fleet_proof_for_test();
}
}
}
pub mod tier_last_day_stats {
@@ -151,6 +166,16 @@ pub mod bucket {
pub use crate::bucket::migration::{LegacyBlobDecryptFn, try_migrate_bucket_metadata, try_migrate_iam_config};
}
pub mod on_demand_migration {
pub mod source_client {
pub use crate::bucket::on_demand_migration::source_client::{
SourceClient, SourceClientSpec, SourceError, SourceGet, SourceHead, SourceObject, SourcePage, SourceProbe,
SourceProvider, SourceSse, SourceTimeouts, USER_AGENT_SUFFIX, is_multipart_etag, range_header_value,
resolve_path_style,
};
}
}
pub mod object_lock {
pub use crate::bucket::object_lock::{ObjectLockApi, ObjectLockStatusExt};
@@ -184,6 +209,13 @@ pub mod bucket {
}
}
pub mod remote_s3_client {
pub use crate::bucket::remote_s3_client::{
PathStyle, RemoteCredentials, RemoteS3ClientError, RemoteS3EndpointSpec, build_remote_s3_client,
validate_remote_endpoint,
};
}
pub mod replication {
pub use crate::bucket::replication::replication_pool::{
DurableMrfBacklogSummary, DurableMrfBucketBacklog, DurableMrfTargetBacklog, MrfBacklogObservabilitySummary,
@@ -194,9 +226,10 @@ pub mod bucket {
BucketReplicationResyncStatus, BucketReplicationStat, BucketReplicationStats, BucketStats,
DeleteReplicationConfigSnapshot, DeletedObjectReplicationInfo, DurableMrfBacklog, DynReplicationPool, InQueueMetric,
MrfOpKind, MrfReplicateEntry, MustReplicateOptions, ObjectOpts, OperatorRuleContract,
REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS,
REPLICATE_INCOMING_DELETE, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS,
REPLICATION_WRITABLE_FIELDS, ReplicateDecision, ReplicateObjectInfo, ReplicationBatchAdmission, ReplicationConfig,
REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS,
REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATE_INCOMING_DELETE,
REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS,
ReplicateDecision, ReplicateObjectInfo, ReplicationBatchAdmission, ReplicationConfig,
ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationDeleteScheduleInput,
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
@@ -405,7 +438,7 @@ pub mod notification {
pub use crate::services::notification_sys::{
CrossPoolFenceFleetProofToken, NotificationPeerErr, NotificationSys, ScannerPublicationLeaseGrant,
acquire_cross_pool_fence_fleet_proof, cross_pool_fence_fleet_proof_matches, get_global_notification_sys,
new_global_notification_sys, start_remote_version_state_fleet_probe,
new_global_notification_sys, scanner_peer_transport_error_message_is_retryable, start_remote_version_state_fleet_probe,
};
}
@@ -415,14 +448,20 @@ pub mod object {
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, NamespaceLockFence, ObjectEncryptionResolver,
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, StreamConsumer, get_object_body_cache_plaintext_len,
lookup_get_object_body_cache_hook, register_get_object_body_cache_hook, register_object_mutation_hook,
unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
ScannerPublicationCommitState, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
unregister_object_mutation_hook,
};
pub use crate::store::{
PrepareSelectObjectSnapshotError, PreparedGetObjectReader, SelectObjectSnapshot, SelectObjectSnapshotReadError,
SnapshotConsistencyError,
};
#[cfg(feature = "test-util")]
pub mod test_util {
pub use crate::store::DeleteAfterObjectLockSnapshotBarrier;
}
}
pub mod rebalance {
@@ -459,8 +498,8 @@ pub mod rpc {
tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer,
verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
verify_tonic_rpc_signature_with_bootstrap,
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
};
}
@@ -487,9 +526,9 @@ pub mod storage {
pub use crate::core::pools::HealLifecycleExpiryContext;
pub use crate::store::HealWalkVersion;
pub use crate::store::{
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, all_local_disk, all_local_disk_path, find_local_disk_by_ref, init_local_disks,
init_local_disks_with_instance_ctx, init_lock_clients, prewarm_local_disk_id_map,
prewarm_local_disk_id_map_with_instance_ctx,
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk, all_local_disk_path,
find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
prewarm_local_disk_id_map, prewarm_local_disk_id_map_with_instance_ctx,
};
}
File diff suppressed because it is too large Load Diff
@@ -41,7 +41,7 @@ use crate::bucket::lifecycle::tier_free_version_recovery::{
DEFAULT_FREE_VERSION_RECOVERY_LIMIT, FreeVersionRecoveryStats, recover_tier_free_versions_with_cancel,
};
use crate::bucket::lifecycle::tier_last_day_stats::{DailyAllTierStats, LastDayTierStats};
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_idempotent_with_manager_and_identity};
use crate::bucket::lifecycle::tier_sweeper::{Jentry, delete_object_from_remote_tier_with_lease_idempotent};
use crate::bucket::lifecycle::transition_transaction::run_transition_transaction_recovery_loop;
use crate::bucket::object_lock::ObjectLockApi;
use crate::bucket::versioning::VersioningApi as _;
@@ -50,7 +50,10 @@ use crate::disk::error::DiskError;
use crate::disk::{DeleteOptions, Disk, DiskAPI, RUSTFS_META_BUCKET, RUSTFS_META_MULTIPART_BUCKET, STORAGE_FORMAT_FILE};
use crate::error::Error;
use crate::error::StorageError;
use crate::error::{is_err_object_not_found, is_err_read_quorum, is_err_version_not_found, is_network_or_host_down};
use crate::error::{
is_err_object_not_found, is_err_read_quorum, is_err_strict_volume_not_found, is_err_version_not_found,
is_network_or_host_down,
};
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions};
use crate::object_api::{ObjectEncryptionResolver, ReadPlan};
use crate::services::tier::{
@@ -490,7 +493,7 @@ impl ExpiryStats {
}
fn add_nonnegative(counter: &AtomicI64, delta: i64) {
let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| Some(current.saturating_add(delta).max(0)));
let _ = counter.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| Some(current.saturating_add(delta).max(0)));
}
fn increment_missed_expiry_tasks(&self) {
@@ -586,25 +589,217 @@ impl ExpiryOp for FreeVersionTask {
}
}
async fn delete_free_version_remote_object(
async fn acquire_free_version_tier_lease(
oi: &ObjectInfo,
tier_config_mgr: &Arc<RwLock<TierConfigMgr>>,
) -> Result<(), std::io::Error> {
) -> Result<(TierOperationLease, bool), std::io::Error> {
let version_id_exact = validate_transition_remote_version(oi)?;
let identity = tier_destination_id_from_metadata(&oi.user_defined)?
.ok_or_else(|| std::io::Error::other("tier free-version has no durable backend identity"))?;
delete_object_from_remote_tier_idempotent_with_manager_and_identity(
let lease =
TierConfigMgr::acquire_operation_lease_for_backend_identity(tier_config_mgr, &oi.transitioned_object.tier, identity)
.await
.map_err(std::io::Error::other)?;
Ok((lease, version_id_exact))
}
async fn delete_free_version_remote_object_with_lease(
oi: &ObjectInfo,
lease: &TierOperationLease,
version_id_exact: bool,
) -> Result<(), std::io::Error> {
delete_object_from_remote_tier_with_lease_idempotent(
&oi.transitioned_object.name,
&oi.transitioned_object.version_id,
&oi.transitioned_object.tier,
identity,
tier_config_mgr,
lease,
version_id_exact,
)
.await?;
Ok(())
}
fn free_version_physical_topology_generation(api: &ECStore) -> String {
let mut hasher = Sha256::new();
for pool in &api.pools {
hasher.update(pool.pool_idx.to_be_bytes());
hasher.update(pool.disk_set.len().to_be_bytes());
for set in &pool.disk_set {
hasher.update(set.set_index.to_be_bytes());
}
}
rustfs_utils::crypto::hex(hasher.finalize().as_slice())
}
fn free_version_remote_tuple_matches(candidate: &ObjectInfo, expected: &ObjectInfo) -> std::io::Result<bool> {
if candidate.transitioned_object.tier != expected.transitioned_object.tier
|| candidate.transitioned_object.name != expected.transitioned_object.name
{
return Ok(false);
}
let candidate_identity = tier_destination_id_from_metadata(&candidate.user_defined)?
.ok_or_else(|| std::io::Error::other("tier free-version is missing its backend identity"))?;
let expected_identity = tier_destination_id_from_metadata(&expected.user_defined)?
.ok_or_else(|| std::io::Error::other("tier free-version task is missing its backend identity"))?;
if candidate_identity != expected_identity {
return Ok(false);
}
if candidate.transition_version_state == rustfs_filemeta::TransitionVersionState::Unknown
|| expected.transition_version_state == rustfs_filemeta::TransitionVersionState::Unknown
{
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"tier free-version remote version state is unknown",
));
}
Ok(candidate.transition_version_state == expected.transition_version_state
&& candidate.transitioned_object.version_id == expected.transitioned_object.version_id)
}
async fn scan_exact_free_version_targets(
api: &ECStore,
oi: &ObjectInfo,
local_object: &str,
) -> std::io::Result<Vec<(Arc<SetDisks>, FileInfo)>> {
let mut targets = Vec::new();
for pool in &api.pools {
for set in &pool.disk_set {
let versions = match set.load_file_info_versions_exact(&oi.bucket, &oi.name).await {
Ok(Some(versions)) => versions,
Ok(None) => continue,
Err(err) if is_err_strict_volume_not_found(&err) => continue,
Err(err) => return Err(std::io::Error::other(err)),
};
for version in versions.versions.iter().chain(versions.free_versions.iter()) {
let candidate = ObjectInfo::from_file_info(version, &oi.bucket, &oi.name, true);
if free_version_remote_tuple_matches(&candidate, oi)? {
if candidate.transitioned_object.free_version {
// Data movement can leave the same remote tuple in
// several physical pools. Ordinary deletion assigns a
// fresh local free-version UUID to each copy, but all
// of those markers own the same idempotent remote
// DELETE. Consume them together while holding every
// physical object lock; treating their local UUIDs as
// conflicting would strand cleanup forever.
let mut actual = version.clone();
actual.name = local_object.to_string();
targets.push((Arc::clone(set), actual));
} else {
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"a live transitioned source still references the free-version remote tuple",
));
}
}
}
}
}
Ok(targets)
}
fn free_version_cleanup_fences_current(
topology_generation: &str,
api: &ECStore,
bucket_guard: &rustfs_lock::NamespaceLockGuard,
object_guards: &[crate::store::ObjectLockDiagGuard],
lease: &TierOperationLease,
cancel: &CancellationToken,
deadline: tokio::time::Instant,
) -> bool {
!cancel.is_cancelled()
&& tokio::time::Instant::now() < deadline
&& !bucket_guard.is_lock_lost()
&& object_guards.iter().all(|guard| !guard.is_lock_lost())
&& lease.is_current_generation()
&& free_version_physical_topology_generation(api) == topology_generation
}
async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel: &CancellationToken) -> std::io::Result<bool> {
const FREE_VERSION_REMOTE_DEADLINE: StdDuration = StdDuration::from_secs(30);
let topology_generation = free_version_physical_topology_generation(&api);
let bucket_guard = api
.acquire_bucket_lifecycle_read_lock(&oi.bucket)
.await
.map_err(std::io::Error::other)?;
let (lease, version_id_exact) = acquire_free_version_tier_lease(oi, &api.tier_config_mgr()).await?;
let local_object = encode_dir_object(&oi.name);
let object_guards = api
.acquire_all_physical_object_write_locks("tier_free_version_cleanup", &oi.bucket, &local_object)
.await
.map_err(std::io::Error::other)?;
let targets = scan_exact_free_version_targets(&api, oi, &local_object).await?;
if targets.is_empty() {
return Ok(false);
}
let deadline = tokio::time::Instant::now() + FREE_VERSION_REMOTE_DEADLINE;
if !free_version_cleanup_fences_current(&topology_generation, &api, &bucket_guard, &object_guards, &lease, cancel, deadline) {
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"tier free-version cleanup fence is invalid before remote delete",
));
}
tokio::select! {
_ = cancel.cancelled() => {
return Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "tier free-version cleanup was cancelled"));
}
result = tokio::time::timeout_at(
deadline,
delete_free_version_remote_object_with_lease(oi, &lease, version_id_exact),
) => {
result
.map_err(|_| std::io::Error::new(std::io::ErrorKind::TimedOut, "tier free-version remote delete timed out"))??;
}
}
if !free_version_cleanup_fences_current(&topology_generation, &api, &bucket_guard, &object_guards, &lease, cancel, deadline) {
// Remote DELETE is idempotent, but a changed fence makes the local
// outcome ambiguous. Keep every marker for a fully fenced retry.
return Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"tier free-version cleanup fence changed after remote delete",
));
}
let mut first_error = None;
for (set, actual) in &targets {
let mut delete_request = FileInfo {
name: local_object.clone(),
version_id: actual.version_id,
..Default::default()
};
delete_request.set_tier_free_version();
if let Err(err) = set
.delete_object_version(&oi.bucket, &local_object, &delete_request, false)
.await
&& first_error.is_none()
{
first_error = Some(std::io::Error::other(err));
}
}
let remaining = scan_exact_free_version_targets(&api, oi, &local_object).await?;
if !remaining.is_empty() {
return Err(first_error.unwrap_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"tier free-version cleanup remained on at least one physical set",
)
}));
}
if let Some(err) = first_error {
return Err(err);
}
Ok(true)
}
#[cfg(all(test, feature = "test-util"))]
async fn delete_free_version_remote_object(
oi: &ObjectInfo,
tier_config_mgr: &Arc<RwLock<TierConfigMgr>>,
) -> Result<(), std::io::Error> {
let (lease, version_id_exact) = acquire_free_version_tier_lease(oi, tier_config_mgr).await?;
delete_free_version_remote_object_with_lease(oi, &lease, version_id_exact).await
}
#[allow(
dead_code,
reason = "MinIO-parity tier/lifecycle entry point that this port never wired (backlog#1823)"
@@ -618,8 +813,11 @@ where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
delete_free_version_remote_object(oi, tier_config_mgr).await?;
Ok(delete_local().await)
let (lease, version_id_exact) = acquire_free_version_tier_lease(oi, tier_config_mgr).await?;
delete_free_version_remote_object_with_lease(oi, &lease, version_id_exact).await?;
let result = delete_local().await;
drop(lease);
Ok(result)
}
struct NewerNoncurrentTask {
@@ -690,6 +888,10 @@ impl ExpiryState {
usize::try_from(self.stats.pending_tasks().max(0)).unwrap_or(usize::MAX)
}
pub fn active_tasks(&self) -> usize {
usize::try_from(self.stats.active_tasks().max(0)).unwrap_or(usize::MAX)
}
fn send_expiry_task(&self, wrkr: Sender<Option<ExpiryOpType>>, task: ExpiryOpType) -> bool {
let queued = wrkr.try_send(Some(task)).is_ok();
if queued {
@@ -719,7 +921,7 @@ impl ExpiryState {
Ok(())
}
pub fn enqueue_free_version(&mut self, oi: ObjectInfo) -> bool {
pub fn enqueue_free_version(&self, oi: ObjectInfo) -> bool {
let task = FreeVersionTask(oi);
let wrkr = self.get_worker_ch(task.op_hash());
if wrkr.is_none() {
@@ -826,7 +1028,7 @@ impl ExpiryState {
}
pub async fn resize_workers(n: usize, api: Arc<ECStore>) {
let expiry_state = runtime_sources::expiry_state_handle();
let expiry_state = api.ctx.expiry_state();
if n == expiry_state.read().await.tasks_tx.len() || n < 1 {
return;
}
@@ -867,7 +1069,7 @@ impl ExpiryState {
stats: Arc<ExpiryStats>,
recovery_notify: Arc<Notify>,
) {
let cancel_token = runtime_sources::background_services_cancel_token().unwrap_or_else(|| {
let cancel_token = api.ctx.background_cancel_token().unwrap_or_else(|| {
static FALLBACK: std::sync::OnceLock<tokio_util::sync::CancellationToken> = std::sync::OnceLock::new();
FALLBACK.get_or_init(tokio_util::sync::CancellationToken::new).clone()
});
@@ -968,119 +1170,33 @@ impl ExpiryState {
else if v.as_any().is::<FreeVersionTask>() {
let v = v.as_any().downcast_ref::<FreeVersionTask>().expect("FreeVersionTask downcast failed");
let oi = v.0.clone();
if let Err(err) = delete_free_version_remote_object(&oi, &api.tier_config_mgr()).await {
recovery_notify.notify_one();
debug!(
bucket = %oi.bucket,
object = %oi.name,
remote_object = %oi.transitioned_object.name,
remote_version_id = %oi.transitioned_object.version_id,
tier = %oi.transitioned_object.tier,
error = ?err,
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
reason = "remote_tier_delete_failed",
"Lifecycle worker skipped remote tier delete"
);
continue;
}
let local_object = encode_dir_object(&oi.name);
let mut fi = FileInfo {
name: local_object.clone(),
version_id: oi.version_id,
..Default::default()
};
// This removes an existing internal cleanup marker. Keeping
// `deleted` false makes duplicate tasks return not-found
// instead of creating an ordinary delete marker.
fi.set_tier_free_version();
let mut deleted_locally = false;
for pool in &api.pools {
let set = pool.get_disks_by_key(&local_object);
let ns_lock = match set.new_ns_lock(&oi.bucket, &local_object).await {
Ok(lock) => lock,
Err(err) => {
recovery_notify.notify_one();
debug!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
bucket = %oi.bucket,
object = %oi.name,
pool_index = pool.pool_idx,
set_index = set.set_index,
error = ?err,
reason = "local_free_version_lock_failed",
"Lifecycle worker failed to create local free-version cleanup lock"
);
continue;
}
};
let _object_lock_guard =
match ns_lock.get_write_lock_quiet(get_lock_acquire_timeout()).await {
Ok(guard) => guard,
Err(err) => {
recovery_notify.notify_one();
debug!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
bucket = %oi.bucket,
object = %oi.name,
pool_index = pool.pool_idx,
set_index = set.set_index,
error = ?err,
reason = "local_free_version_lock_failed",
"Lifecycle worker failed to acquire local free-version cleanup lock"
);
continue;
}
};
match set
.delete_object_version(&oi.bucket, &local_object, &fi, false)
.await
{
Ok(()) => {
deleted_locally = true;
break;
}
Err(err) if is_err_version_not_found(&err) || is_err_object_not_found(&err) => continue,
Err(err) => {
recovery_notify.notify_one();
debug!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
bucket = %oi.bucket,
object = %oi.name,
remote_object = %oi.transitioned_object.name,
remote_version_id = %oi.transitioned_object.version_id,
tier = %oi.transitioned_object.tier,
error = ?err,
reason = "local_free_version_delete_failed",
"Lifecycle worker failed local free-version cleanup"
);
break;
}
}
}
if !deleted_locally {
debug!(
match cleanup_free_version_exact(api.clone(), &oi, &cancel_token).await {
Ok(true) => {}
Ok(false) => debug!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
bucket = %oi.bucket,
object = %oi.name,
remote_object = %oi.transitioned_object.name,
remote_version_id = %oi.transitioned_object.version_id,
tier = %oi.transitioned_object.tier,
reason = "local_free_version_missing",
"Lifecycle worker could not find transitioned free version locally"
);
"Lifecycle worker found that the exact free-version was already absent"
),
Err(err) => {
recovery_notify.notify_one();
debug!(
event = EVENT_LIFECYCLE_WORKER_STATE,
component = LOG_COMPONENT_ECSTORE,
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
bucket = %oi.bucket,
object = %oi.name,
remote_object = %oi.transitioned_object.name,
remote_version_id = %oi.transitioned_object.version_id,
tier = %oi.transitioned_object.tier,
error = ?err,
reason = "free_version_exact_cleanup_deferred",
"Lifecycle worker retained the exact free-version for a fenced retry"
);
}
}
}
else {
@@ -1099,6 +1215,22 @@ impl ExpiryState {
}
}
pub(crate) async fn enqueue_committed_free_versions(api: &ECStore, free_versions: Vec<ObjectInfo>) -> usize {
if free_versions.is_empty() {
return 0;
}
let expiry_state = api.ctx.expiry_state();
let state = expiry_state.read().await;
let mut queued = 0;
for free_version in free_versions {
if state.enqueue_free_version(free_version) {
queued += 1;
}
}
queued
}
async fn enqueue_recovered_free_version_with_state(state: &Arc<RwLock<ExpiryState>>, oi: ObjectInfo) -> bool {
let task = FreeVersionTask(oi);
let hash = task.op_hash();
@@ -1152,8 +1284,8 @@ fn set_recovered_free_version_enqueue_observer(
RecoveredFreeVersionEnqueueObserverGuard
}
pub async fn enqueue_recovered_free_version(oi: ObjectInfo) -> bool {
let expiry_state = runtime_sources::expiry_state_handle();
pub async fn enqueue_recovered_free_version(api: &ECStore, oi: ObjectInfo) -> bool {
let expiry_state = api.ctx.expiry_state();
let queued = enqueue_recovered_free_version_with_state(&expiry_state, oi).await;
#[cfg(test)]
@@ -2580,8 +2712,8 @@ fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>, started: &OnceLock<(
}
Some(tokio::spawn(async move {
let cancel_token = runtime_sources::background_services_cancel_token().unwrap_or_default();
let expiry_state = runtime_sources::expiry_state_handle();
let cancel_token = api.ctx.background_cancel_token().unwrap_or_default();
let expiry_state = api.ctx.expiry_state();
run_tier_free_version_recovery_loop(
cancel_token,
expiry_state,
@@ -6229,9 +6361,18 @@ mod tests {
rustfs_utils::crypto::hex(old_identity),
);
oi.user_defined = Arc::new(metadata.clone());
let lease_observed_during_local_delete = Arc::new(std::sync::atomic::AtomicBool::new(false));
delete_free_version_remote_object_then(&oi, &manager, {
let local_delete_calls = Arc::clone(&local_delete_calls);
let lease_observed_during_local_delete = Arc::clone(&lease_observed_during_local_delete);
let manager = manager.clone();
move || async move {
assert_eq!(
crate::services::tier::tier::TierConfigMgr::active_operation_lease_count(&manager, "WARM").await,
1,
"the identity-bound tier lease must span the exact local marker delete"
);
lease_observed_during_local_delete.store(true, Ordering::Relaxed);
local_delete_calls.fetch_add(1, Ordering::Relaxed);
}
})
@@ -6239,6 +6380,12 @@ mod tests {
.expect("matching destination identity should allow idempotent remote cleanup");
assert_eq!(old_backend.remove_count().await, 1);
assert_eq!(local_delete_calls.load(Ordering::Relaxed), 1);
assert!(lease_observed_during_local_delete.load(Ordering::Relaxed));
assert_eq!(
crate::services::tier::tier::TierConfigMgr::active_operation_lease_count(&manager, "WARM").await,
0,
"the tier lease should be released after the local marker delete completes"
);
let mut single_prefix_metadata = HashMap::new();
single_prefix_metadata.insert(
@@ -6472,6 +6619,7 @@ mod tests {
let state = ExpiryState::new();
let mut state = state.write().await;
let je = Jentry {
persisted_version: 0,
obj_name: "remote/object".to_string(),
version_id: "remote-version".to_string(),
tier_name: "WARM".to_string(),
@@ -6480,6 +6628,7 @@ mod tests {
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
source: None,
dispatch: None,
};
let err = state
@@ -6494,7 +6643,7 @@ mod tests {
async fn enqueue_free_version_reports_false_without_worker_channel() {
let state = ExpiryState::new();
let recovery_notify = Arc::clone(&state.read().await.recovery_notify);
let mut state = state.write().await;
let state = state.write().await;
let oi = ObjectInfo {
bucket: "bucket".to_string(),
name: "object".to_string(),
@@ -6620,6 +6769,7 @@ mod tests {
let state = ExpiryState::new_with_unconsumed_worker_channel(1);
let mut state = state.write().await;
let je = Jentry {
persisted_version: 0,
obj_name: "remote/object".to_string(),
version_id: "remote-version".to_string(),
tier_name: "WARM".to_string(),
@@ -6628,6 +6778,7 @@ mod tests {
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
source: None,
dispatch: None,
};
state
@@ -6684,7 +6835,7 @@ mod tests {
},
..Default::default()
};
let mut state = state.write().await;
let state = state.write().await;
assert!(state.enqueue_free_version(oi.clone()));
assert!(recovery_notify.notified().now_or_never().is_none());
@@ -6759,7 +6910,7 @@ mod tests {
};
assert!(
super::enqueue_recovered_free_version(oi).await,
super::enqueue_recovered_free_version(&ecstore, oi).await,
"the resized production worker queue should accept the task"
);
stop_tx.send(None).await.expect("worker stop signal should be delivered");
@@ -6875,12 +7026,12 @@ mod tests {
.await
.expect("free-version task should reach the worker");
tokio::time::timeout(StdDuration::from_secs(30), async {
while remote_backend.remove_count().await == 0 {
while stats.active_tasks() == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("worker should complete remote cleanup before taking the local lock");
.expect("worker should mark the cleanup task active before the lock assertion");
let completed_while_locked = tokio::time::timeout(StdDuration::from_millis(100), async {
while stats.active_tasks() != 0 {
tokio::task::yield_now().await;
@@ -6889,7 +7040,12 @@ mod tests {
.await;
assert!(
completed_while_locked.is_err(),
"local cleanup must wait while a competing object writer owns the namespace lock"
"the cleanup task must wait while a competing object writer owns the namespace lock"
);
assert_eq!(
remote_backend.remove_count().await,
0,
"the remote tuple must not be deleted before the all-physical namespace fence is acquired"
);
for disk_path in &disk_paths {
assert!(
@@ -6900,6 +7056,13 @@ mod tests {
}
drop(object_lock_guard);
tokio::time::timeout(StdDuration::from_secs(30), async {
while remote_backend.remove_count().await == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("worker should delete the remote tuple after acquiring the released namespace fence");
tx.send(None).await.expect("worker stop signal should be delivered");
worker.await.expect("free-version worker should stop cleanly");
@@ -6995,6 +7158,7 @@ mod tests {
.next()
.expect("seeded free version should be recoverable");
let stale_version_id = oi.version_id.expect("free version should have a concrete UUID");
let ordinary_marker_mod_time = OffsetDateTime::now_utc();
for disk_path in &disk_paths {
let metadata_path = disk_path.join(&bucket).join(object).join(STORAGE_FORMAT_FILE);
@@ -7017,7 +7181,7 @@ mod tests {
name: object.to_string(),
version_id: Some(stale_version_id),
deleted: true,
mod_time: Some(OffsetDateTime::now_utc()),
mod_time: Some(ordinary_marker_mod_time),
..Default::default()
})
.expect("same-ID ordinary marker should replace the stale free version");
@@ -7031,6 +7195,13 @@ mod tests {
.expect("same-ID ordinary marker metadata should be written");
}
assert!(
!super::cleanup_free_version_exact(Arc::clone(&ecstore), &oi, &CancellationToken::new())
.await
.expect("a stale task whose local UUID now names an ordinary marker should be an idempotent no-op"),
"the stale free-version task must not report local cleanup"
);
let state = ExpiryState::new();
let (stats, recovery_notify) = {
let state = state.read().await;
@@ -11522,7 +11693,7 @@ mod tests {
#[cfg(feature = "test-util")]
#[tokio::test]
async fn journal_replay_rejects_unknown_version_state_before_backend_io() {
async fn journal_replay_quarantines_legacy_unknown_version_state_before_backend_io() {
let (_disk_paths, ecstore) = setup_test_env().await;
let (backend, _) = register_recovery_mock_tier(&ecstore).await;
let identity = TierConfigMgr::acquire_operation_lease(&ecstore.tier_config_mgr(), "WARM")
@@ -11530,6 +11701,7 @@ mod tests {
.expect("mock tier lease should be available")
.backend_identity();
let je = Jentry {
persisted_version: 0,
obj_name: "remote/object".to_string(),
version_id: "legacy-version".to_string(),
tier_name: "WARM".to_string(),
@@ -11538,25 +11710,28 @@ mod tests {
version_state: rustfs_filemeta::TransitionVersionState::Unknown,
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
source: None,
dispatch: None,
};
crate::bucket::lifecycle::tier_delete_journal::persist_tier_delete_journal_entry(ecstore.clone(), &je)
.await
.expect("legacy unknown journal should remain byte-compatible and persistable");
let err = crate::bucket::lifecycle::tier_delete_journal::process_tier_delete_journal_entry(ecstore, &je)
.await
.expect_err("unknown journal state must fail before backend IO");
.expect_err("legacy unknown journal must be quarantined before backend IO");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert_eq!(err.kind(), std::io::ErrorKind::WouldBlock);
assert_eq!(backend.remove_count().await, 0);
}
#[cfg(feature = "test-util")]
#[tokio::test]
async fn journal_replay_deletes_confirmed_exact_provider_token() {
async fn rejected_upload_cleanup_retries_confirmed_exact_provider_token_without_legacy_journal() {
let (_disk_paths, ecstore) = setup_test_env().await;
let (backend, _) = register_recovery_mock_tier(&ecstore).await;
let lease = TierConfigMgr::acquire_operation_lease(&ecstore.tier_config_mgr(), "WARM")
.await
.expect("mock tier lease should be available");
let identity = lease.backend_identity();
backend
.set_put_remote_version(Some("provider-version-token".to_string()))
.await;
@@ -11570,34 +11745,30 @@ mod tests {
.expect("confirmed remote candidate should be seeded");
backend.set_remove_failure(true);
backend.set_reject_non_empty_remote_versions(true);
let je = Jentry {
obj_name: "remote/object".to_string(),
version_id: "provider-version-token".to_string(),
tier_name: "WARM".to_string(),
backend_identity: Some(identity),
version_id_exact: true,
version_state: rustfs_filemeta::TransitionVersionState::Exact,
state: crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::Committed,
source: None,
};
crate::set_disk::cleanup_rejected_transition_upload_durably(
let err = crate::set_disk::cleanup_rejected_transition_upload_durably(
&lease,
&je.obj_name,
&je.version_id,
"remote/object",
"provider-version-token",
true,
Some(ecstore.clone()),
)
.await
.expect("failed immediate cleanup should remain durable in the journal");
assert!(backend.contains(&je.obj_name).await);
.expect_err("a failed immediate cleanup must remain owned by the caller's transition transaction");
assert_eq!(err.kind(), std::io::ErrorKind::Other);
assert!(backend.contains("remote/object").await);
backend.set_remove_failure(false);
crate::bucket::lifecycle::tier_delete_journal::process_tier_delete_journal_entry(ecstore, &je)
.await
.expect("identity-bound exact journal must retry confirmed candidate cleanup");
crate::set_disk::cleanup_rejected_transition_upload_durably(
&lease,
"remote/object",
"provider-version-token",
true,
Some(ecstore),
)
.await
.expect("the transaction retry must delete the same confirmed candidate");
assert!(!backend.contains(&je.obj_name).await);
assert!(!backend.contains("remote/object").await);
assert_eq!(backend.exact_remove_count(), 2);
assert_eq!(
backend.remove_versions().await,
@@ -11760,11 +11931,14 @@ mod tests {
};
let mut recovery_rx = recovery_rx.lock().await;
assert!(
super::enqueue_recovered_free_version(ObjectInfo {
bucket: "prefill".to_string(),
name: "prefill".to_string(),
..Default::default()
})
super::enqueue_recovered_free_version(
&ecstore,
ObjectInfo {
bucket: "prefill".to_string(),
name: "prefill".to_string(),
..Default::default()
},
)
.await,
"the production recovery queue should accept its first task"
);
@@ -12195,7 +12369,7 @@ mod tests {
#[tokio::test]
#[serial]
async fn tier_free_version_recovery_continues_after_deleted_marker_bucket() {
let (_paths, ecstore) = setup_test_env().await;
let (disk_paths, ecstore) = setup_test_env().await;
let suffix = Uuid::new_v4().simple();
let earlier_bucket = format!("zzzz-recovery-{suffix}-a");
let deleted_marker = format!("zzzz-recovery-{suffix}-m");
@@ -12203,11 +12377,7 @@ mod tests {
let later_object = "a-before-stale-marker";
create_test_bucket(&ecstore, &earlier_bucket).await;
create_test_bucket(&ecstore, &later_bucket).await;
let mut reader = PutObjReader::from_vec(b"cursor reset probe".to_vec());
ecstore
.put_object(&later_bucket, later_object, &mut reader, &ObjectOptions::default())
.await
.expect("successor bucket object should be created");
seed_recoverable_free_version(&disk_paths, &later_bucket, later_object, None, None).await;
let page = list_tier_free_versions(
Arc::clone(&ecstore),
@@ -12220,14 +12390,10 @@ mod tests {
.expect("recovery should resume at the first bucket after a deleted marker bucket");
assert_eq!(page.buckets_scanned, 1, "the later bucket must not be skipped");
assert_eq!(
page.scanned_entries, 1,
"the deleted bucket's object marker must not skip objects in the successor bucket"
);
ecstore
.delete_object(&later_bucket, later_object, ObjectOptions::default())
.await
.expect("successor bucket object should be removed");
assert_eq!(page.items.len(), 1, "the successor bucket's recoverable object must be returned");
assert_eq!(page.items[0].bucket, later_bucket);
assert_eq!(page.items[0].name, later_object);
remove_seeded_free_version(&disk_paths, &later_bucket, later_object).await;
for bucket in [&earlier_bucket, &later_bucket] {
ecstore
.delete_bucket(bucket, &DeleteBucketOptions::default())
@@ -33,6 +33,7 @@ const MANUAL_TRANSITION_CURSOR_MARKER_PROOF_MAX_SIZE: usize = 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DurableIlmRecordKind {
TierDeleteJournal,
TierDeleteDispatchManifest,
TransitionTransaction,
ManualTransitionJob,
ManualTransitionScope,
@@ -54,6 +55,18 @@ pub(crate) const TIER_DELETE_JOURNAL_NAMESPACE: DurableIlmNamespace = DurableIlm
max_record_size: 64 * 1024,
kind: DurableIlmRecordKind::TierDeleteJournal,
};
pub(crate) const TIER_DELETE_JOURNAL_V6_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "tier-delete-journal-v6",
prefix: "ilm/tier-delete-journal-v6/",
max_record_size: 64 * 1024,
kind: DurableIlmRecordKind::TierDeleteJournal,
};
pub(crate) const TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "tier-delete-dispatch-manifest",
prefix: tier_delete_journal::TIER_DELETE_DISPATCH_MANIFEST_PREFIX,
max_record_size: tier_delete_journal::MAX_TIER_DELETE_DISPATCH_MANIFEST_SIZE,
kind: DurableIlmRecordKind::TierDeleteDispatchManifest,
};
pub(crate) const TRANSITION_TRANSACTION_NAMESPACE: DurableIlmNamespace = DurableIlmNamespace {
name: "transition-transaction",
prefix: "ilm/transition-transactions/records",
@@ -85,8 +98,10 @@ pub(crate) const MANUAL_TRANSITION_WORKER_RESULT_NAMESPACE: DurableIlmNamespace
kind: DurableIlmRecordKind::ManualTransitionWorkerResult,
};
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 6] = [
pub(crate) const DURABLE_ILM_NAMESPACES: [DurableIlmNamespace; 8] = [
TIER_DELETE_JOURNAL_NAMESPACE,
TIER_DELETE_JOURNAL_V6_NAMESPACE,
TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE,
TRANSITION_TRANSACTION_NAMESPACE,
MANUAL_TRANSITION_JOB_NAMESPACE,
MANUAL_TRANSITION_SCOPE_NAMESPACE,
@@ -157,6 +172,15 @@ pub(crate) enum DurableIlmRecordCheckpoint {
content_sha256: String,
identity_sha256: String,
committed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
dispatch_identity_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
state: Option<super::tier_sweeper::TierDeleteJournalState>,
},
TierDeleteDispatchManifest {
content_sha256: String,
identity_sha256: String,
state: tier_delete_journal::TierDeleteDispatchManifestState,
},
TransitionTransaction {
content_sha256: String,
@@ -195,6 +219,7 @@ impl DurableIlmRecordCheckpoint {
pub(crate) fn content_sha256(&self) -> &str {
match self {
Self::TierDeleteJournal { content_sha256, .. }
| Self::TierDeleteDispatchManifest { content_sha256, .. }
| Self::TransitionTransaction { content_sha256, .. }
| Self::ManualTransitionJob { content_sha256, .. }
| Self::ManualTransitionScope { content_sha256, .. }
@@ -228,6 +253,19 @@ impl DurableIlmRecordCheckpoint {
}
pub(crate) fn validate_successor(&self, next: &Self) -> Result<()> {
for checkpoint in [self, next] {
if let Self::TierDeleteJournal {
committed,
dispatch_identity_sha256,
state,
..
} = checkpoint
&& (state.is_some() != dispatch_identity_sha256.is_some()
|| state.is_some_and(|state| *committed != (state == super::tier_sweeper::TierDeleteJournalState::Committed)))
{
return Err(Error::other("durable ILM tier delete journal checkpoint is invalid"));
}
}
if self == next {
if let Self::ManualTransitionJob {
progress,
@@ -244,18 +282,64 @@ impl DurableIlmRecordCheckpoint {
let valid = match (self, next) {
(
Self::TierDeleteJournal {
content_sha256: previous_content,
identity_sha256: previous_identity,
committed: previous_committed,
dispatch_identity_sha256: previous_dispatch_identity,
state: previous_state,
..
},
Self::TierDeleteJournal {
content_sha256: next_content,
identity_sha256: next_identity,
committed: next_committed,
dispatch_identity_sha256: next_dispatch_identity,
state: next_state,
..
},
) => {
use super::tier_sweeper::TierDeleteJournalState::{Committed, Dispatched, Prepared};
let dispatch_identity_is_monotonic = match (previous_dispatch_identity, next_dispatch_identity) {
(Some(previous), Some(next)) => previous == next,
(None, None) => true,
// Old receipts did not record the v6 dispatch binding. A
// byte-identical observation may adopt the stronger proof,
// but an in-flight mutation must fail closed instead of
// guessing which operation owned the journal.
(None, Some(_)) => previous_content == next_content,
(Some(_), None) => false,
};
let state_is_monotonic = match (previous_state, next_state) {
(Some(previous), Some(next)) => {
previous == next || matches!((previous, next), (Prepared, Dispatched) | (Dispatched, Committed))
}
(None, None) => previous_committed == next_committed || (!previous_committed && *next_committed),
(None, Some(_)) => previous_content == next_content,
(Some(_), None) => false,
};
previous_identity == next_identity && dispatch_identity_is_monotonic && state_is_monotonic
}
(
Self::TierDeleteDispatchManifest {
identity_sha256: previous_identity,
state: previous_state,
..
},
Self::TierDeleteDispatchManifest {
identity_sha256: next_identity,
state: next_state,
..
},
) => {
use tier_delete_journal::TierDeleteDispatchManifestState::{
Aborted, Aborting, Completed, DispatchAuthorized, Preparing,
};
previous_identity == next_identity
&& (previous_committed == next_committed || (!previous_committed && *next_committed))
&& matches!(
(previous_state, next_state),
(Preparing, DispatchAuthorized | Aborting) | (Aborting, Aborted) | (DispatchAuthorized, Completed)
)
}
(
Self::TransitionTransaction {
@@ -351,6 +435,49 @@ impl DurableIlmRecordCheckpoint {
Err(Error::other("durable ILM record generation is not a monotonic successor"))
}
}
/// Whether `self` is an older generation of the same immutable record
/// that can reach `terminal` through one or more valid state transitions.
/// This is deliberately broader than `validate_successor`, which remains
/// adjacent-only for receipt advancement. Terminal cleanup uses this only
/// after the exact terminal ETag and terminal receipt were committed, to
/// purge older object versions exposed by that deletion.
pub(crate) fn is_predecessor_of_terminal(&self, terminal: &Self) -> bool {
if self == terminal || self.validate_successor(terminal).is_ok() {
return true;
}
match (self, terminal) {
(
Self::TierDeleteJournal {
identity_sha256: previous_identity,
dispatch_identity_sha256: previous_dispatch,
state: Some(super::tier_sweeper::TierDeleteJournalState::Prepared),
..
},
Self::TierDeleteJournal {
identity_sha256: terminal_identity,
dispatch_identity_sha256: terminal_dispatch,
state: Some(super::tier_sweeper::TierDeleteJournalState::Committed),
..
},
) => previous_identity == terminal_identity && previous_dispatch == terminal_dispatch,
(
Self::TierDeleteDispatchManifest {
identity_sha256: previous_identity,
state: tier_delete_journal::TierDeleteDispatchManifestState::Preparing,
..
},
Self::TierDeleteDispatchManifest {
identity_sha256: terminal_identity,
state:
tier_delete_journal::TierDeleteDispatchManifestState::Aborted
| tier_delete_journal::TierDeleteDispatchManifestState::Completed,
..
},
) => previous_identity == terminal_identity,
_ => false,
}
}
}
fn transition_state_distance(
@@ -750,10 +877,19 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
if tier_delete_journal::tier_delete_journal_object_name(&entry) != path {
return Err(Error::other("tier delete journal content does not match its path"));
}
let operation_id = path
let legacy_operation_id = path
.strip_prefix(namespace.prefix)
.and_then(|suffix| suffix.strip_suffix(".json"))
.ok_or_else(|| Error::other("tier delete journal path is invalid"))?;
// Legacy v1-v5 paths already expose a 64-hex operation id and
// must remain receipt-compatible. V6 uses an operation-scoped
// nested path, so derive a fixed, path-unique receipt id instead
// of embedding slashes in the receipt locator.
let operation_id = if entry.persisted_version == 6 {
hex_sha256(path.as_bytes(), ToOwned::to_owned)
} else {
legacy_operation_id.to_string()
};
let identity_sha256 = checkpoint_hash(&(
&entry.obj_name,
&entry.version_id,
@@ -763,13 +899,29 @@ pub(crate) fn validate_durable_ilm_record(path: &str, data: &[u8]) -> Result<Val
entry.version_state,
&entry.source,
))?;
let dispatch_identity_sha256 = entry.dispatch.as_ref().map(checkpoint_hash).transpose()?;
(
"operation_id",
operation_id.to_string(),
operation_id,
DurableIlmRecordCheckpoint::TierDeleteJournal {
content_sha256,
identity_sha256,
committed: entry.state == super::tier_sweeper::TierDeleteJournalState::Committed,
dispatch_identity_sha256,
state: (entry.persisted_version == 6).then_some(entry.state),
},
)
}
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,
identity_sha256,
state,
},
)
}
@@ -956,6 +1108,87 @@ mod tests {
}
}
#[test]
fn tier_delete_dispatch_manifest_namespace_validates_monotonic_branches() {
use tier_delete_journal::TierDeleteDispatchManifestState::{Aborted, Aborting, Completed, DispatchAuthorized, Preparing};
let operation_id = Uuid::new_v4();
let checkpoint = |state| {
let (path, data) = tier_delete_journal::test_tier_delete_dispatch_manifest_record(operation_id, state);
let namespace = classify_durable_ilm_record(&path)
.expect("dispatch manifest namespace should classify")
.expect("dispatch manifest should be durable");
assert_eq!(namespace, &TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE);
validate_durable_ilm_record(&path, &data)
.expect("dispatch manifest should validate")
.checkpoint
};
let preparing = checkpoint(Preparing);
let authorized = checkpoint(DispatchAuthorized);
let completed = checkpoint(Completed);
let aborting = checkpoint(Aborting);
let aborted = checkpoint(Aborted);
preparing
.validate_successor(&authorized)
.expect("Preparing may become DispatchAuthorized");
authorized
.validate_successor(&completed)
.expect("DispatchAuthorized may become Completed");
preparing.validate_successor(&aborting).expect("Preparing may enter rollback");
aborting.validate_successor(&aborted).expect("Aborting may become Aborted");
assert!(authorized.validate_successor(&aborting).is_err());
assert!(completed.validate_successor(&authorized).is_err());
assert!(aborted.validate_successor(&preparing).is_err());
}
#[test]
fn tier_delete_journal_checkpoint_binds_dispatch_and_full_state_monotonically() {
use crate::bucket::lifecycle::tier_sweeper::TierDeleteJournalState::{Committed, Dispatched, Prepared};
let checkpoint = |content: &str, dispatch: Option<&str>, state| DurableIlmRecordCheckpoint::TierDeleteJournal {
content_sha256: content.repeat(64),
identity_sha256: "i".repeat(64),
committed: state == Some(Committed),
dispatch_identity_sha256: dispatch.map(|value| value.repeat(64)),
state,
};
let prepared = checkpoint("a", Some("d"), Some(Prepared));
let dispatched = checkpoint("b", Some("d"), Some(Dispatched));
let committed = checkpoint("c", Some("d"), Some(Committed));
prepared
.validate_successor(&dispatched)
.expect("Prepared may advance to Dispatched");
dispatched
.validate_successor(&committed)
.expect("Dispatched may advance to Committed");
assert!(prepared.validate_successor(&committed).is_err());
assert!(dispatched.validate_successor(&prepared).is_err());
let rebound = checkpoint("b", Some("e"), Some(Dispatched));
assert!(dispatched.validate_successor(&rebound).is_err());
let legacy: DurableIlmRecordCheckpoint = serde_json::from_value(serde_json::json!({
"kind": "tier_delete_journal",
"content_sha256": "a".repeat(64),
"identity_sha256": "i".repeat(64),
"committed": false
}))
.expect("legacy tier-delete checkpoint should remain decodable");
legacy
.validate_successor(&prepared)
.expect("byte-identical legacy receipt may adopt the stronger v6 proof");
let changed_legacy = DurableIlmRecordCheckpoint::TierDeleteJournal {
content_sha256: "z".repeat(64),
identity_sha256: "i".repeat(64),
committed: false,
dispatch_identity_sha256: None,
state: None,
};
assert!(changed_legacy.validate_successor(&prepared).is_err());
}
#[test]
fn manual_transition_job_checkpoint_compacts_legacy_progress_compatibly() {
let options = super::super::bucket_lifecycle_ops::ManualTransitionRunOptions::default();
+2 -2
View File
@@ -34,6 +34,6 @@ pub mod tier_sweeper;
pub mod transition_transaction;
pub(crate) use durable_namespace::{
DurableIlmRecordCheckpoint, ILM_META_PREFIX, ValidatedDurableIlmRecord, classify_durable_ilm_record,
validate_durable_ilm_record,
DurableIlmRecordCheckpoint, ILM_META_PREFIX, TIER_DELETE_DISPATCH_MANIFEST_NAMESPACE, ValidatedDurableIlmRecord,
classify_durable_ilm_record, validate_durable_ilm_record,
};
File diff suppressed because it is too large Load Diff
@@ -172,7 +172,8 @@ pub(super) async fn recover_tier_free_versions_with_cancel(
return Err(std::io::Error::other("free-version recovery limit must be greater than zero").into());
}
let page = list_tier_free_versions(api, limit, bucket_marker.clone(), object_marker.clone(), cancel_token.clone()).await?;
let page =
list_tier_free_versions(api.clone(), limit, bucket_marker.clone(), object_marker.clone(), cancel_token.clone()).await?;
let mut stats = FreeVersionRecoveryStats {
scanned: 0,
enqueued: 0,
@@ -190,7 +191,7 @@ pub(super) async fn recover_tier_free_versions_with_cancel(
return Err(tier_free_version_recovery_cancelled());
}
retry_cursor.visit(&oi);
if !record_recovered_free_version_enqueue(&mut stats, enqueue_recovered_free_version(oi).await) {
if !record_recovered_free_version_enqueue(&mut stats, enqueue_recovered_free_version(&api, oi).await) {
let (bucket_marker, object_marker) = retry_cursor.retry_markers();
stats.truncated = true;
stats.next_bucket_marker = bucket_marker;
@@ -255,6 +255,7 @@ impl ObjSweeper {
}
if del_tier {
return Some(Jentry {
persisted_version: 0,
obj_name: self.remote_object.clone(),
version_id: self.transition_version_id.clone(),
tier_name: self.transition_tier.clone(),
@@ -266,6 +267,7 @@ impl ObjSweeper {
version_state: self.transition_version_state,
state: TierDeleteJournalState::Committed,
source: None,
dispatch: None,
});
}
None
@@ -298,9 +300,19 @@ impl ObjSweeper {
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) enum TierDeleteJournalState {
Prepared,
Dispatched,
Committed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct TierDeleteDispatchBinding {
pub(crate) operation_id: Uuid,
pub(crate) manifest_object: String,
pub(crate) journal_set_sha256: String,
pub(crate) topology_generation: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct TierDeleteSourceIdentity {
@@ -342,6 +354,10 @@ impl TierDeleteSourceIdentity {
#[derive(Debug, Clone)]
#[allow(unused_assignments)]
pub struct Jentry {
/// On-disk format version when decoded. Newly constructed entries use 0;
/// the encoder chooses their format from the durable ownership fields.
/// Recovery uses this value to quarantine v1-v5 without rewriting them.
pub(crate) persisted_version: u8,
pub(crate) obj_name: String,
pub(crate) version_id: String,
pub(crate) tier_name: String,
@@ -350,6 +366,23 @@ pub struct Jentry {
pub(crate) version_state: rustfs_filemeta::TransitionVersionState,
pub(crate) state: TierDeleteJournalState,
pub(crate) source: Option<TierDeleteSourceIdentity>,
pub(crate) dispatch: Option<TierDeleteDispatchBinding>,
}
impl Jentry {
/// Whether this prepared transaction is eligible to become the sole
/// cleanup owner for its transitioned source. The caller may use this to
/// decide whether to persist it, but must not set `skip_free_version`
/// until persistence succeeds.
pub(crate) fn can_replace_tier_free_version(&self) -> bool {
self.state == TierDeleteJournalState::Prepared
&& self.backend_identity.is_some()
&& self.version_state != rustfs_filemeta::TransitionVersionState::Unknown
&& self
.source
.as_ref()
.is_some_and(TierDeleteSourceIdentity::has_stable_identity)
}
}
impl ExpiryOp for Jentry {
@@ -617,6 +650,7 @@ pub fn transitioned_force_delete_journal_entry(
}
Some(Jentry {
persisted_version: 0,
obj_name: transitioned.name.clone(),
version_id: transitioned.version_id.clone(),
tier_name: transitioned.tier.clone(),
@@ -628,6 +662,7 @@ pub fn transitioned_force_delete_journal_entry(
version_state: transition_version_state,
state: TierDeleteJournalState::Committed,
source: None,
dispatch: None,
})
}
@@ -673,17 +708,73 @@ mod test {
use rustfs_s3_client::signer_error::invalid_utf8_header_error;
use super::{
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED,
RemoteDeleteBreaker, RemoteTierDeleteOutcome, delete_confirmed_transition_candidate_exact_with_manager_and_identity,
delete_object_from_remote_tier_idempotent, delete_object_from_remote_tier_idempotent_with_manager_and_identity,
is_remote_tier_not_found_error, is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook,
should_record_remote_delete_failure, transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
CONFIRMED_TRANSITION_EMPTY_GUARD_DISPATCHES, ERR_REMOTE_DELETE_BREAKER_OPEN, ERR_REMOTE_DELETE_LIMITER_CLOSED, Jentry,
RemoteDeleteBreaker, RemoteTierDeleteOutcome, TierDeleteJournalState, TierDeleteSourceIdentity,
delete_confirmed_transition_candidate_exact_with_manager_and_identity, delete_object_from_remote_tier_idempotent,
delete_object_from_remote_tier_idempotent_with_manager_and_identity, is_remote_tier_not_found_error,
is_signer_header_error, lifecycle, set_remote_tier_delete_test_hook, should_record_remote_delete_failure,
transitioned_delete_journal_entry, transitioned_force_delete_journal_entry,
};
use crate::storage_api_contracts::lifecycle::TransitionedObject;
use rustfs_filemeta::TransitionVersionState;
use std::io::{Error, ErrorKind};
use std::time::{Duration, Instant};
fn stable_prepared_journal() -> Jentry {
Jentry {
persisted_version: 0,
obj_name: "remote/object".to_string(),
version_id: "remote-version".to_string(),
tier_name: "WARM".to_string(),
backend_identity: Some([7; 32]),
version_id_exact: true,
version_state: TransitionVersionState::Exact,
state: TierDeleteJournalState::Prepared,
source: Some(TierDeleteSourceIdentity {
bucket: "bucket".to_string(),
object: "object".to_string(),
version_id: Some(uuid::Uuid::new_v4().to_string()),
versioned: true,
version_suspended: false,
data_dir: None,
etag: None,
mod_time: None,
}),
dispatch: None,
}
}
#[test]
fn only_stable_prepared_journal_can_replace_tier_free_version() {
let stable = stable_prepared_journal();
assert!(stable.can_replace_tier_free_version());
let mut committed = stable.clone();
committed.state = TierDeleteJournalState::Committed;
assert!(!committed.can_replace_tier_free_version());
let mut unbound = stable.clone();
unbound.backend_identity = None;
assert!(!unbound.can_replace_tier_free_version());
let mut unknown = stable.clone();
unknown.version_state = TransitionVersionState::Unknown;
assert!(!unknown.can_replace_tier_free_version());
let mut unstable = stable;
unstable.source = Some(TierDeleteSourceIdentity {
bucket: "bucket".to_string(),
object: "object".to_string(),
version_id: None,
versioned: false,
version_suspended: false,
data_dir: None,
etag: Some("etag-only".to_string()),
mod_time: None,
});
assert!(!unstable.can_replace_tier_free_version());
}
#[test]
fn signer_header_error_detection_matches_utf8_failures() {
let err = Error::new(
+211 -48
View File
@@ -21,7 +21,7 @@ use crate::bucket::bucket_target_sys::BucketTargetSys;
use crate::bucket::metadata::{load_bucket_metadata_parse, load_bucket_metadata_parse_with_presence};
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 _;
@@ -412,8 +412,14 @@ pub(crate) fn require_bucket_metadata_sys_in(
}
pub(crate) async fn object_store_in(ctx: &crate::runtime::instance::InstanceContext) -> Result<Arc<ECStore>> {
let sys = bucket_metadata_sys_of(ctx)?;
Ok(sys.read().await.api.clone())
object_store_if_initialized_in(ctx)
.await
.ok_or_else(|| Error::other("bucket metadata sys not initialized for this instance"))
}
pub(crate) async fn object_store_if_initialized_in(ctx: &crate::runtime::instance::InstanceContext) -> Option<Arc<ECStore>> {
let sys = ctx.bucket_metadata_sys().or_else(get_global_bucket_metadata_sys)?;
Some(sys.read().await.api.clone())
}
pub(crate) async fn get_in(ctx: &crate::runtime::instance::InstanceContext, bucket: &str) -> Result<Arc<BucketMetadata>> {
@@ -653,13 +659,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),
}
},
),
@@ -1130,6 +1135,16 @@ pub(crate) async fn has_authoritative_never_versioned_state(bucket: &str) -> Res
bucket_meta_sys.has_authoritative_never_versioned_state(bucket).await
}
pub(crate) async fn has_authoritative_never_versioned_state_in(
ctx: &crate::runtime::instance::InstanceContext,
bucket: &str,
) -> Result<bool> {
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
bucket_meta_sys.has_authoritative_never_versioned_state(bucket).await
}
pub async fn get_website_config(bucket: &str) -> Result<(WebsiteConfiguration, OffsetDateTime)> {
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
@@ -1258,7 +1273,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 {
@@ -1287,7 +1301,6 @@ impl BucketMetadataSys {
.time_to_live(MISSING_BUCKET_TTL)
.build(),
api,
initialized: Arc::new(RwLock::new(false)),
}
}
@@ -1348,13 +1361,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
@@ -1364,9 +1376,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();
@@ -1384,9 +1402,6 @@ impl BucketMetadataSys {
buckets = &buckets[count..]
}
let mut initialized = self.initialized.write().await;
*initialized = true;
Ok(())
}
@@ -1463,14 +1478,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?
@@ -1490,6 +1497,20 @@ impl BucketMetadataSys {
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,
@@ -1871,23 +1892,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);
@@ -1898,11 +1909,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?;
@@ -2181,10 +2190,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
@@ -2286,10 +2293,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
@@ -2512,11 +2517,169 @@ pub(crate) mod test_support {
mod tests {
use super::test_support::isolated_store_over_temp_disks;
use super::*;
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_REPLICATION_CONFIG, BUCKET_REQUEST_PAYMENT_CONFIG,
BUCKET_SSECONFIG, BUCKET_TAGGING_CONFIG, BUCKET_VERSIONING_CONFIG, BUCKET_WEBSITE_CONFIG, OBJECT_LOCK_CONFIG,
};
use crate::bucket::target::{BucketTarget, BucketTargetType, Credentials};
use crate::config::com::read_config;
use crate::storage_api_contracts::bucket::{BucketOperations as _, DeleteBucketOptions, MakeBucketOptions};
use byteorder::{ByteOrder as _, LittleEndian};
use serial_test::serial;
use tokio::time::timeout;
const NEW_WRITER_REPLICATION_XML: &[u8] = br#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Role>arn:aws:iam::111122223333:role/replication-role</Role><Rule><ID>rollback</ID><Priority>1</Priority><Filter><Prefix>documents/</Prefix></Filter><Status>Enabled</Status><Destination><Bucket>arn:aws:s3:::replica-bucket</Bucket></Destination><DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication></Rule></ReplicationConfiguration>"#;
const NEW_WRITER_CONFIGS: [(&str, &[u8]); 14] = [
(BUCKET_POLICY_CONFIG, br#"{"Version":"2012-10-17","Statement":[]}"#),
(BUCKET_NOTIFICATION_CONFIG, br#"<NotificationConfiguration/>"#),
(
BUCKET_LIFECYCLE_CONFIG,
br#"<LifecycleConfiguration><Rule><ID>expire</ID><Status>Enabled</Status><Filter><Prefix>logs/</Prefix></Filter><Expiration><Days>30</Days></Expiration></Rule></LifecycleConfiguration>"#,
),
(
OBJECT_LOCK_CONFIG,
br#"<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>7</Days></DefaultRetention></Rule></ObjectLockConfiguration>"#,
),
(
BUCKET_VERSIONING_CONFIG,
br#"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"#,
),
(
BUCKET_SSECONFIG,
br#"<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>"#,
),
(
BUCKET_TAGGING_CONFIG,
r#"<Tagging><TagSet><Tag><Key>environment</Key><Value>测试-🦀</Value></Tag></TagSet></Tagging>"#.as_bytes(),
),
(BUCKET_REPLICATION_CONFIG, NEW_WRITER_REPLICATION_XML),
(
BUCKET_CORS_CONFIG,
br#"<CORSConfiguration><CORSRule><AllowedMethod>GET</AllowedMethod><AllowedOrigin>https://example.test</AllowedOrigin></CORSRule></CORSConfiguration>"#,
),
(BUCKET_LOGGING_CONFIG, br#"<BucketLoggingStatus/>"#),
(
BUCKET_WEBSITE_CONFIG,
br#"<WebsiteConfiguration><IndexDocument><Suffix>index.html</Suffix></IndexDocument></WebsiteConfiguration>"#,
),
(
BUCKET_ACCELERATE_CONFIG,
br#"<AccelerateConfiguration><Status>Enabled</Status></AccelerateConfiguration>"#,
),
(
BUCKET_REQUEST_PAYMENT_CONFIG,
br#"<RequestPaymentConfiguration><Payer>Requester</Payer></RequestPaymentConfiguration>"#,
),
(
BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG,
br#"<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls><IgnorePublicAcls>true</IgnorePublicAcls><BlockPublicPolicy>true</BlockPublicPolicy><RestrictPublicBuckets>false</RestrictPublicBuckets></PublicAccessBlockConfiguration>"#,
),
];
#[tokio::test]
async fn g_d3_003_new_writer_replication_loads_without_fail_closed_state() {
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "rollback-new-replication";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).expect("rollback fixture bucket should be created");
}
let writer = BucketMetadataSys::new(store.clone());
let mut metadata = BucketMetadata::new(bucket);
metadata
.update_config(BUCKET_REPLICATION_CONFIG, NEW_WRITER_REPLICATION_XML.to_vec())
.expect("new-writer replication XML should be accepted before persistence");
writer
.persist_new_and_set(metadata)
.await
.expect("new-writer replication metadata should persist");
let old_reader = BucketMetadataSys::new(store);
let (loaded, _) = old_reader
.get_replication_config(bucket)
.await
.expect("old metadata_sys must not classify new-writer replication XML as invalid");
assert_eq!(loaded.role, "arn:aws:iam::111122223333:role/replication-role");
assert_eq!(loaded.rules.len(), 1);
assert_eq!(loaded.rules[0].id.as_deref(), Some("rollback"));
}
#[tokio::test]
async fn g_d3_004_new_writer_metadata_blob_keeps_legacy_header_and_configs() {
let (dirs, store) = isolated_store_over_temp_disks().await;
let bucket = "rollback-new-metadata";
for dir in &dirs {
std::fs::create_dir_all(dir.path().join(bucket)).expect("rollback fixture bucket should be created");
}
let writer = BucketMetadataSys::new(store.clone());
let mut metadata = BucketMetadata::new(bucket);
for (config_file, bytes) in NEW_WRITER_CONFIGS {
metadata
.update_config(config_file, bytes.to_vec())
.unwrap_or_else(|err| panic!("new-writer {config_file} fixture must be valid: {err}"));
}
writer
.persist_new_and_set(metadata)
.await
.expect("new-writer metadata should persist");
let path = BucketMetadata::new(bucket).save_file_path();
let blob = read_config(store.clone(), &path)
.await
.expect("persisted .metadata.bin should be readable");
assert_eq!(
LittleEndian::read_u16(&blob[0..2]),
1,
"bucket metadata format must stay rollback-readable"
);
assert_eq!(
LittleEndian::read_u16(&blob[2..4]),
1,
"bucket metadata version must stay rollback-readable"
);
let loaded = load_bucket_metadata(store, bucket)
.await
.expect("old read_bucket_metadata path must load the new-writer blob");
let loaded_configs: [(&str, &[u8]); 14] = [
(BUCKET_POLICY_CONFIG, &loaded.policy_config_json),
(BUCKET_NOTIFICATION_CONFIG, &loaded.notification_config_xml),
(BUCKET_LIFECYCLE_CONFIG, &loaded.lifecycle_config_xml),
(OBJECT_LOCK_CONFIG, &loaded.object_lock_config_xml),
(BUCKET_VERSIONING_CONFIG, &loaded.versioning_config_xml),
(BUCKET_SSECONFIG, &loaded.encryption_config_xml),
(BUCKET_TAGGING_CONFIG, &loaded.tagging_config_xml),
(BUCKET_REPLICATION_CONFIG, &loaded.replication_config_xml),
(BUCKET_CORS_CONFIG, &loaded.cors_config_xml),
(BUCKET_LOGGING_CONFIG, &loaded.logging_config_xml),
(BUCKET_WEBSITE_CONFIG, &loaded.website_config_xml),
(BUCKET_ACCELERATE_CONFIG, &loaded.accelerate_config_xml),
(BUCKET_REQUEST_PAYMENT_CONFIG, &loaded.request_payment_config_xml),
(BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG, &loaded.public_access_block_config_xml),
];
for ((expected_name, expected), (loaded_name, actual)) in NEW_WRITER_CONFIGS.into_iter().zip(loaded_configs) {
assert_eq!(loaded_name, expected_name);
assert_eq!(actual, expected, "old read_bucket_metadata changed {expected_name} bytes");
}
assert!(loaded.policy_config.is_some());
assert!(loaded.notification_config.is_some());
assert!(loaded.lifecycle_config.is_some());
assert!(loaded.object_lock_config.is_some());
assert!(loaded.versioning_config.is_some());
assert!(loaded.sse_config.is_some());
assert!(loaded.tagging_config.is_some());
assert!(loaded.replication_config.is_some());
assert!(loaded.cors_config.is_some());
assert!(loaded.logging_config.is_some());
assert!(loaded.website_config.is_some());
assert!(loaded.accelerate_config.is_some());
assert!(loaded.request_payment_config.is_some());
assert!(loaded.public_access_block_config.is_some());
}
#[tokio::test]
async fn malformed_delete_configs_are_not_treated_as_absent() {
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
+2
View File
@@ -26,8 +26,10 @@ 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 tagging;
pub mod target;
@@ -177,6 +177,28 @@ pub fn replication_write_may_pass_worm_gate(
Ok(!(retention_locked && opts.replication_retention_timestamp.is_none()))
}
/// Whether an authorized replication delete (`ObjectOptions::replication_request`)
/// addressed to an explicit version may bypass GOVERNANCE retention on the
/// local replica, exactly as an `x-amz-bypass-governance-retention` caller
/// with the bypass permission would.
///
/// The source is authoritative for a replicated version purge (issue #6850):
/// the same WORM deletion gate already ran there, and GOVERNANCE retention
/// with an authorized bypass is the only lock state it can purge through.
/// Requiring the bypass header again here makes the purge permanently
/// undeliverable — replication senders never carry it — and the sites diverge
/// forever. COMPLIANCE retention and legal hold stay blocking: the source
/// gate can never purge through them, so a replication purge that meets one
/// here is divergence or forgery and fails closed.
///
/// The trust judgment is the same one the write-path exemption uses:
/// `replication_request` is only set once the receiving handler has
/// authorized the caller for the replication action
/// (`ReplicateDeleteAction`), never straight from request headers.
pub fn replication_delete_may_bypass_governance(opts: &ObjectOptions) -> bool {
opts.replication_request && opts.version_id.is_some()
}
/// Check if an object is locked based on its metadata.
/// This is a common function used by both lifecycle evaluation and deletion checks.
///
@@ -680,6 +702,32 @@ mod tests {
assert!(err.to_string().contains("modification time"));
}
/// The replicated-purge GOVERNANCE bypass (#6850) applies only to an
/// authorized replication delete addressed to an explicit version: a
/// local delete never gets it, and a replicated delete without a version
/// id creates a delete marker rather than purging anything.
#[test]
fn replication_delete_bypasses_governance_only_for_authorized_version_purges() {
let version_purge = ObjectOptions {
replication_request: true,
version_id: Some("6b6ffbc0-b0d3-4a86-8f6c-fe19163b8dcd".to_string()),
..Default::default()
};
assert!(replication_delete_may_bypass_governance(&version_purge));
let local_version_delete = ObjectOptions {
replication_request: false,
..version_purge.clone()
};
assert!(!replication_delete_may_bypass_governance(&local_version_delete));
let replicated_marker_creation = ObjectOptions {
version_id: None,
..version_purge
};
assert!(!replication_delete_may_bypass_governance(&replicated_marker_creation));
}
/// A local PutObjectRetention / PutObjectLegalHold "clear" persists the
/// lock keys as empty strings (the MinIO on-disk shape, see
/// `parse_object_lock_retention`); that is "no lock", not corruption, and
@@ -0,0 +1,18 @@
// 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): serve and back-fill objects from an external
//! S3-compatible source bucket.
pub mod source_client;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,789 @@
// 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 and the outbound SSRF gate all
//! live here so both callers share exactly one policy. 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::{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)
}
}
/// 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>,
/// 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());
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;
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,
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"));
}
#[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"
);
}
}
+8 -8
View File
@@ -44,14 +44,14 @@ mod replication_versioning_boundary;
mod runtime_boundary;
pub use replication_config_boundary::{
ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS,
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS,
REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationTargetValidationError,
assign_site_replication_rule_priorities, invalid_replication_config_status_field, is_site_replication_role,
is_site_replication_rule, merge_incoming_replication_config, merge_user_replication_config,
replication_target_arn_deployment_id, 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,
ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS,
REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION,
REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError,
ReplicationConfigurationExt, ReplicationTargetValidationError, assign_site_replication_rule_priorities,
invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule,
merge_incoming_replication_config, merge_user_replication_config, replication_target_arn_deployment_id,
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::{
@@ -13,12 +13,12 @@
// limitations under the License.
pub use rustfs_replication::{
ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_UNSUPPORTED_FIELDS,
REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION, REPLICATION_READ_ONLY_HISTORICAL_FIELDS,
REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError, ReplicationConfigurationExt, ReplicationRuleExt,
ReplicationTargetValidationError, assign_site_replication_rule_priorities, invalid_replication_config_status_field,
is_site_replication_role, is_site_replication_rule, merge_incoming_replication_config, merge_user_replication_config,
replication_target_arn_deployment_id, 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,
ObjectOpts, OperatorRuleContract, REMOTE_TARGET_CAPABILITY_CONTRACT_VERSION, REMOTE_TARGET_READ_ONLY_HISTORICAL_FIELDS,
REMOTE_TARGET_UNSUPPORTED_FIELDS, REMOTE_TARGET_WRITABLE_FIELDS, REPLICATION_CAPABILITY_CONTRACT_VERSION,
REPLICATION_READ_ONLY_HISTORICAL_FIELDS, REPLICATION_WRITABLE_FIELDS, ReplicationConfigStructureError,
ReplicationConfigurationExt, ReplicationRuleExt, ReplicationTargetValidationError, assign_site_replication_rule_priorities,
invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule,
merge_incoming_replication_config, merge_user_replication_config, replication_target_arn_deployment_id,
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,
};
@@ -436,16 +436,21 @@ pub(crate) async fn check_replicate_delete_strict(
}
for target in decision.targets_map.values_mut() {
if let Some(client) = ReplicationTargetStore::remote_target_client(bucket, &target.arn).await {
target.synchronous = client.replicate_sync;
} else {
target.replicate = false;
target.synchronous = false;
}
let replicate_sync = ReplicationTargetStore::remote_target_client(bucket, &target.arn)
.await
.map(|client| client.replicate_sync);
apply_target_delivery_mode(target, replicate_sync);
}
Ok(decision)
}
fn apply_target_delivery_mode(target: &mut ReplicateTargetDecision, replicate_sync: Option<bool>) {
// A missing runtime client is a delivery failure, not a rule mismatch.
// Preserve admission and fall back to the asynchronous worker, which can
// persist FAILED state for the heal/retry path.
target.synchronous = replicate_sync.unwrap_or(false);
}
pub(crate) fn check_replicate_delete_with_snapshot(
dobj: &ObjectToDelete,
oi: &ObjectInfo,
@@ -629,6 +634,23 @@ mod tests {
}));
}
#[test]
fn missing_target_client_preserves_delete_admission_as_async() {
let mut target = ReplicateTargetDecision::new("arn:target".to_string(), true, true);
apply_target_delivery_mode(&mut target, None);
assert!(target.replicate, "a runtime client miss must not erase the replication rule decision");
assert!(
!target.synchronous,
"unavailable synchronous targets must fall back to the async retry path"
);
apply_target_delivery_mode(&mut target, Some(true));
assert!(target.replicate);
assert!(target.synchronous);
}
#[test]
fn must_replicate_options_preserve_request_flag() {
let user_defined = HashMap::new();
@@ -19,9 +19,10 @@ pub use rustfs_replication::{
};
pub(crate) use rustfs_replication::{
ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject, delete_marker_purge_mrf_entry,
delete_marker_purge_version_id, delete_replication_missing_source_decision, delete_replication_object_opts,
heal_uses_delete_replication_path, 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, target_delete_version_id,
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, 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,
};
@@ -1048,7 +1048,6 @@ pub fn resync_start_conflict_id(error: &EcstoreError) -> Option<&str> {
}
/// Main replication pool structure
#[derive(Debug)]
pub struct ReplicationPool<S: ReplicationStorage> {
// Atomic counters for active workers
active_workers: Arc<AtomicI32>,
@@ -1094,6 +1093,16 @@ pub struct ReplicationPool<S: ReplicationStorage> {
resyncer: Arc<ReplicationResyncer>,
}
impl<S: ReplicationStorage> std::fmt::Debug for ReplicationPool<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ReplicationPool")
.field("active_workers", &self.active_workers.load(Ordering::Relaxed))
.field("active_lrg_workers", &self.active_lrg_workers.load(Ordering::Relaxed))
.field("active_mrf_workers", &self.active_mrf_workers.load(Ordering::Relaxed))
.finish_non_exhaustive()
}
}
impl<S: ReplicationStorage> ReplicationPool<S> {
/// Creates a new replication pool with specified options
pub async fn new(opts: ReplicationPoolOpts, stats: Arc<ReplicationStats>, storage: Arc<S>) -> Arc<Self> {
@@ -2132,7 +2141,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
}
/// Load bucket replication resync statuses into memory
#[instrument(skip(_cancellation_token))]
#[instrument(skip(self, buckets, _cancellation_token), fields(bucket_count = buckets.len()))]
async fn load_resync(
self: Arc<Self>,
buckets: &[String],
@@ -3168,6 +3177,19 @@ pub(crate) async fn queue_replication_heal_internal(
}
}
ReplicationHealQueueAction::QueueDelete(dv) => {
// A purge the peer denied under object lock cannot succeed until
// the lock lapses (#6850); requeuing it every heal cycle only
// burns bandwidth and failure counters. The backoff expires on
// its own, so the purge is probed again — and converges — once
// the retention window has a chance of being over.
if super::replication_object_decision_boundary::is_version_delete_replication(&dv.delete_object)
&& super::replication_resyncer::object_lock_denied_purge_backoff_active(&dv)
{
return ReplicationHealQueueResult {
object_info: roi,
admission: ReplicationQueueAdmission::Skipped,
};
}
let admission = if let Some(pool) = runtime_sources::replication_pool() {
pool.queue_replica_delete_task(dv).await
} else {
File diff suppressed because it is too large Load Diff
@@ -36,8 +36,8 @@ use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
pub(crate) use crate::bucket::bucket_target_sys::{
AdvancedPutOptions, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions, RemoveObjectOptions, TargetClient,
resolve_read_api_version_id,
AdvancedPutOptions, HeadObjectSdkError, PutObjectOptions, PutObjectPartOptions, RemotePutObjectResponse, RemoveObjectOptions,
S3ClientError, TargetClient, resolve_read_api_version_id,
};
#[cfg(test)]
pub(crate) use crate::bucket::target::BucketTarget;
@@ -260,8 +260,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 +525,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 {
@@ -25,6 +25,8 @@ use time::OffsetDateTime;
use url::Url;
const REDACTED_CREDENTIAL: &str = "<redacted>";
const GO_YEAR_ONE_START_UNIX_SECONDS: i64 = -62_135_596_800;
const GO_YEAR_TWO_START_UNIX_SECONDS: i64 = -62_104_060_800;
#[derive(Deserialize, Serialize, Default, Clone)]
pub struct Credentials {
@@ -41,6 +43,26 @@ pub struct Credentials {
}
impl Credentials {
/// Returns the session token used for request signing.
///
/// MinIO-compatible payloads may carry an empty token. Treat whitespace-only
/// values as absent without rewriting a real token, whose bytes are opaque.
pub fn effective_session_token(&self) -> Option<&str> {
self.session_token.as_deref().filter(|token| !token.trim().is_empty())
}
/// Returns the credential expiry after normalizing Go's zero `time.Time`.
///
/// Go JSON encoders emit year 1 for an unset `time.Time`; persisted MinIO
/// target metadata can therefore contain that sentinel even for static
/// credentials.
pub fn effective_expiration(&self) -> Option<Timestamp> {
self.expiration.filter(|expiration| {
let unix_seconds = expiration.as_second();
!(GO_YEAR_ONE_START_UNIX_SECONDS..GO_YEAR_TWO_START_UNIX_SECONDS).contains(&unix_seconds)
})
}
pub fn redacted(&self) -> Self {
Self {
access_key: self.access_key.clone(),
@@ -355,6 +377,24 @@ mod tests {
use std::time::Duration;
use time::OffsetDateTime;
#[test]
fn credential_effective_values_normalize_only_compatibility_sentinels() {
let mut credentials = Credentials {
access_key: "access".to_string(),
secret_key: "secret".to_string(),
session_token: Some(" ".to_string()),
expiration: Some("0001-01-01T08:00:00+08:00".parse().expect("Go zero time should parse")),
};
assert!(credentials.effective_session_token().is_none());
assert!(credentials.effective_expiration().is_none());
credentials.session_token = Some(" opaque token ".to_string());
credentials.expiration = Some("2099-01-01T00:00:00Z".parse().expect("future timestamp should parse"));
assert_eq!(credentials.effective_session_token(), Some(" opaque token "));
assert_eq!(credentials.effective_expiration(), credentials.expiration);
}
#[test]
fn test_bucket_target_json_deserialize() {
let json = r#"
+1
View File
@@ -73,6 +73,7 @@ pub fn check_valid_bucket_name_strict(bucket_name: &str) -> Result<()> {
check_bucket_name_common(bucket_name, true)
}
// RUSTFS_COMPAT_TODO(s3gate-metadata-xml): the s3s codec reads persisted XML during migration. Remove after every supported writer uses the gateway codec and every retained metadata object and backup archive is verified or rewritten.
pub fn deserialize<T>(input: &[u8]) -> xml::DeResult<T>
where
T: for<'xml> xml::Deserialize<'xml>,
@@ -1307,6 +1307,32 @@ pub fn verify_tonic_mutation_body_digest<T>(request: &tonic::Request<T>, canonic
verify_tonic_mutation_body_digest_with_strictness(request, canonical_body, internode_rpc_body_digest_strict())
}
/// Verify a non-disk mutation without accepting a newly-generated unsigned v2 body.
///
/// The disk mutation lane has a rolling-upgrade exception for `UNSIGNED-PAYLOAD`
/// while peer replay-cache capability is being discovered. Historical v2 peers
/// used the fixed `unsigned` nonce before body-digest rollout; preserve that
/// exact marker for mixed-version compatibility, but reject unsigned v2
/// requests that omit it or present a different nonce.
pub fn verify_tonic_mutation_body_digest_reject_unsigned<T>(
request: &tonic::Request<T>,
canonical_body: &[u8],
) -> std::io::Result<()> {
let version = request
.metadata()
.get(RPC_AUTH_VERSION_HEADER)
.and_then(|value| value.to_str().ok());
let digest = request
.metadata()
.get(RPC_CONTENT_SHA256_HEADER)
.and_then(|value| value.to_str().ok());
let nonce = request.metadata().get(RPC_NONCE_HEADER).and_then(|value| value.to_str().ok());
if version == Some(RPC_AUTH_VERSION_V2) && digest == Some(UNSIGNED_PAYLOAD) && nonce != Some("unsigned") {
return Err(std::io::Error::other("RPC mutation requires a body-bound v2 signature"));
}
verify_tonic_mutation_body_digest(request, canonical_body)
}
/// [`verify_tonic_mutation_body_digest`] with the strict gate injected as a parameter, so both
/// rollout postures are unit-testable without racing on process-global environment variables.
fn verify_tonic_mutation_body_digest_with_strictness<T>(
@@ -36,6 +36,7 @@ use rustfs_rio::{ChunkReaderBox, HttpChunkReader, HttpReader, HttpWriter};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::future::Future;
use std::io;
use std::pin::Pin;
use std::sync::{Arc, LazyLock, OnceLock};
use std::task::{Context, Poll};
@@ -105,9 +106,13 @@ struct PutFileCapabilityCacheState {
cached: Option<PutFileCapabilityState>,
generation: u64,
in_flight: Option<PutFileCapabilityFlight>,
rejected_server_epoch: Option<Uuid>,
}
type PutFileCapabilityCacheEntry = Arc<tokio::sync::RwLock<PutFileCapabilityCacheState>>;
// The registry lock is released before taking an entry lock. Entry guards cover
// only cache transitions, never a probe or await; poll-based writers must be
// able to reject an epoch atomically with those transitions.
type PutFileCapabilityCacheEntry = Arc<parking_lot::RwLock<PutFileCapabilityCacheState>>;
static PUT_FILE_CAPABILITY_CACHE: LazyLock<parking_lot::RwLock<HashMap<String, PutFileCapabilityCacheEntry>>> =
LazyLock::new(|| parking_lot::RwLock::new(HashMap::new()));
@@ -119,7 +124,7 @@ fn put_file_capability_cache_entry(endpoint: &str) -> PutFileCapabilityCacheEntr
PUT_FILE_CAPABILITY_CACHE
.write()
.entry(endpoint.to_owned())
.or_insert_with(|| Arc::new(tokio::sync::RwLock::new(PutFileCapabilityCacheState::default())))
.or_insert_with(|| Arc::new(parking_lot::RwLock::new(PutFileCapabilityCacheState::default())))
.clone()
}
@@ -134,6 +139,23 @@ fn fresh_put_file_capability(state: Option<PutFileCapabilityState>, now: Instant
}
}
fn reject_put_file_server_epoch(endpoint: &str, server_epoch: Uuid) {
let entry = PUT_FILE_CAPABILITY_CACHE.read().get(endpoint).cloned();
if let Some(entry) = entry {
let mut state = entry.write();
if matches!(state.cached, Some(PutFileCapabilityState::V1 { server_epoch: cached, .. }) if cached == server_epoch) {
state.rejected_server_epoch = Some(server_epoch);
}
}
}
fn usable_put_file_capability(state: &PutFileCapabilityCacheState, now: Instant) -> Option<Option<Uuid>> {
match fresh_put_file_capability(state.cached, now)? {
Some(server_epoch) if state.rejected_server_epoch == Some(server_epoch) => None,
capability => Some(capability),
}
}
fn put_file_capability_status_is_legacy(status: u16) -> bool {
status == 404
}
@@ -322,13 +344,14 @@ impl InternodeDataTransport for TcpHttpInternodeDataTransport {
async fn open_write(&self, request: WriteStreamRequest) -> Result<FileWriter> {
let server_epoch = self.put_file_auth_capability(&request.endpoint).await?;
let nonce = server_epoch.map(|_| Uuid::new_v4());
let url = build_put_file_stream_url(&request, nonce.zip(server_epoch));
let auth_scope = server_epoch.map(|server_epoch| (Uuid::new_v4(), server_epoch));
let url = build_put_file_stream_url(&request, auth_scope);
let endpoint = request.endpoint;
let mut headers = json_headers();
build_auth_headers(&url, &Method::PUT, &mut headers)?;
let writer = HttpWriter::new(url.clone(), Method::PUT, headers).await?;
match nonce {
Some(nonce) => Ok(Box::new(PutFileAuthWriter::new(writer, url, nonce))),
match auth_scope {
Some((nonce, server_epoch)) => Ok(Box::new(PutFileAuthWriter::new(writer, url, nonce, endpoint, server_epoch))),
None => Ok(Box::new(writer)),
}
}
@@ -498,15 +521,15 @@ where
{
let entry = put_file_capability_cache_entry(endpoint);
{
let state = entry.read().await;
if let Some(cached) = fresh_put_file_capability(state.cached, Instant::now()) {
let state = entry.read();
if let Some(cached) = usable_put_file_capability(&state, Instant::now()) {
return Ok(cached);
}
}
let flight = {
let mut state = entry.write().await;
if let Some(cached) = fresh_put_file_capability(state.cached, Instant::now()) {
let mut state = entry.write();
if let Some(cached) = usable_put_file_capability(&state, Instant::now()) {
return Ok(cached);
}
if let Some(flight) = state.in_flight.clone() {
@@ -532,7 +555,7 @@ where
.await;
{
let mut state = entry.write().await;
let mut state = entry.write();
let is_current_flight = state
.in_flight
.as_ref()
@@ -540,6 +563,9 @@ where
if is_current_flight {
match outcome {
Ok(Some(server_epoch)) => {
if state.rejected_server_epoch != Some(*server_epoch) {
state.rejected_server_epoch = None;
}
state.cached = Some(PutFileCapabilityState::V1 {
server_epoch: *server_epoch,
revalidate_after: Instant::now() + PUT_FILE_V1_CAPABILITY_TTL,
@@ -630,17 +656,23 @@ struct PutFileAuthWriter<W> {
inner: W,
url: String,
nonce: Uuid,
endpoint: String,
server_epoch: Uuid,
server_epoch_rejected: bool,
hasher: Sha256,
trailer: Option<Vec<u8>>,
trailer_offset: usize,
}
impl<W> PutFileAuthWriter<W> {
fn new(inner: W, url: String, nonce: Uuid) -> Self {
fn new(inner: W, url: String, nonce: Uuid, endpoint: String, server_epoch: Uuid) -> Self {
Self {
inner,
url,
nonce,
endpoint,
server_epoch,
server_epoch_rejected: false,
hasher: Sha256::new(),
trailer: None,
trailer_offset: 0,
@@ -656,6 +688,14 @@ impl<W> PutFileAuthWriter<W> {
Ok(())
}
fn reject_server_epoch_on_conflict(&mut self, error: &io::Error) {
if self.server_epoch_rejected || !io_error_has_put_file_epoch_conflict(error) {
return;
}
reject_put_file_server_epoch(&self.endpoint, self.server_epoch);
self.server_epoch_rejected = true;
}
fn poll_write_trailer(&mut self, cx: &mut Context<'_>) -> Poll<std::io::Result<()>>
where
W: AsyncWrite + Unpin,
@@ -673,7 +713,10 @@ impl<W> PutFileAuthWriter<W> {
)));
}
Poll::Ready(Ok(written)) => written,
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Ready(Err(err)) => {
self.reject_server_epoch_on_conflict(&err);
return Poll::Ready(Err(err));
}
Poll::Pending => return Poll::Pending,
};
self.trailer_offset += written;
@@ -682,6 +725,15 @@ impl<W> PutFileAuthWriter<W> {
}
}
fn io_error_has_put_file_epoch_conflict(error: &io::Error) -> bool {
error
.get_ref()
.and_then(|source| source.downcast_ref::<rustfs_rio::InternodeHttpError>())
.is_some_and(
|error| matches!(error.kind(), rustfs_rio::InternodeHttpErrorKind::HttpStatus(status) if status.as_u16() == 409),
)
}
impl<W> AsyncWrite for PutFileAuthWriter<W>
where
W: AsyncWrite + Unpin,
@@ -698,12 +750,22 @@ where
self.hasher.update(&buf[..written]);
Poll::Ready(Ok(written))
}
other => other,
Poll::Ready(Err(err)) => {
self.reject_server_epoch_on_conflict(&err);
Poll::Ready(Err(err))
}
Poll::Pending => Poll::Pending,
}
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
match Pin::new(&mut self.inner).poll_flush(cx) {
Poll::Ready(Err(err)) => {
self.reject_server_epoch_on_conflict(&err);
Poll::Ready(Err(err))
}
other => other,
}
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
@@ -712,7 +774,13 @@ where
Poll::Ready(Err(err)) => return Poll::Ready(Err(err)),
Poll::Pending => return Poll::Pending,
}
Pin::new(&mut self.inner).poll_shutdown(cx)
match Pin::new(&mut self.inner).poll_shutdown(cx) {
Poll::Ready(Err(err)) => {
self.reject_server_epoch_on_conflict(&err);
Poll::Ready(Err(err))
}
other => other,
}
}
}
@@ -840,7 +908,6 @@ mod tests {
loop {
let strong_count = entry
.read()
.await
.in_flight
.as_ref()
.map(|flight| Arc::strong_count(&flight.outcome))
@@ -858,6 +925,50 @@ mod tests {
#[derive(Debug)]
struct LegacyTestTransport;
#[derive(Clone, Copy, Debug)]
enum PutFileFailurePhase {
Write,
Flush,
Shutdown,
}
struct PutFileFailureWriter {
phase: PutFileFailurePhase,
status: reqwest::StatusCode,
}
impl PutFileFailureWriter {
fn error(&self) -> io::Error {
rustfs_rio::new_test_internode_http_io_error(rustfs_rio::InternodeHttpErrorKind::HttpStatus(self.status))
}
}
impl tokio::io::AsyncWrite for PutFileFailureWriter {
fn poll_write(self: Pin<&mut Self>, _cx: &mut Context<'_>, buf: &[u8]) -> Poll<std::io::Result<usize>> {
Poll::Ready(if matches!(self.phase, PutFileFailurePhase::Write) {
Err(self.error())
} else {
Ok(buf.len())
})
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(if matches!(self.phase, PutFileFailurePhase::Flush) {
Err(self.error())
} else {
Ok(())
})
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(if matches!(self.phase, PutFileFailurePhase::Shutdown) {
Err(self.error())
} else {
Ok(())
})
}
}
#[async_trait::async_trait]
impl InternodeDataTransport for LegacyTestTransport {
async fn open_read(&self, _request: ReadStreamRequest) -> Result<FileReader> {
@@ -1048,7 +1159,7 @@ mod tests {
let v1_endpoint = format!("http://v1-{}.invalid", Uuid::new_v4());
let v1_entry = put_file_capability_cache_entry(&v1_endpoint);
let server_epoch = Uuid::new_v4();
v1_entry.write().await.cached = Some(PutFileCapabilityState::V1 {
v1_entry.write().cached = Some(PutFileCapabilityState::V1 {
server_epoch,
revalidate_after: Instant::now() + PUT_FILE_V1_CAPABILITY_TTL,
});
@@ -1067,7 +1178,7 @@ mod tests {
Some(server_epoch)
);
assert!(!cache_probe_called.load(Ordering::SeqCst));
v1_entry.write().await.cached = Some(PutFileCapabilityState::V1 {
v1_entry.write().cached = Some(PutFileCapabilityState::V1 {
server_epoch,
revalidate_after: Instant::now(),
});
@@ -1086,8 +1197,7 @@ mod tests {
let legacy_endpoint = format!("http://legacy-{}.invalid", Uuid::new_v4());
let legacy_entry = put_file_capability_cache_entry(&legacy_endpoint);
legacy_entry.write().await.cached =
Some(PutFileCapabilityState::LegacyUntil(Instant::now() + PUT_FILE_LEGACY_CAPABILITY_TTL));
legacy_entry.write().cached = Some(PutFileCapabilityState::LegacyUntil(Instant::now() + PUT_FILE_LEGACY_CAPABILITY_TTL));
assert!(
transport
.put_file_auth_capability(&legacy_endpoint)
@@ -1098,7 +1208,7 @@ mod tests {
let expired_endpoint = format!("http://expired-legacy-{}.invalid", Uuid::new_v4());
let expired_entry = put_file_capability_cache_entry(&expired_endpoint);
expired_entry.write().await.cached = Some(PutFileCapabilityState::LegacyUntil(Instant::now()));
expired_entry.write().cached = Some(PutFileCapabilityState::LegacyUntil(Instant::now()));
let reprobed = std::sync::atomic::AtomicBool::new(false);
assert_eq!(
resolve_put_file_auth_capability(&expired_endpoint, || async {
@@ -1349,7 +1459,7 @@ mod tests {
};
probe_started.notified().await;
{
let mut state = entry.write().await;
let mut state = entry.write();
state.generation = state.generation.checked_add(1).expect("test generation should advance");
state.cached = Some(PutFileCapabilityState::V1 {
server_epoch: newer_epoch,
@@ -1362,10 +1472,7 @@ mod tests {
task.await.expect("stale task should finish").expect("stale probe result"),
Some(stale_epoch)
);
assert_eq!(
fresh_put_file_capability(entry.read().await.cached, Instant::now()),
Some(Some(newer_epoch))
);
assert_eq!(fresh_put_file_capability(entry.read().cached, Instant::now()), Some(Some(newer_epoch)));
}
#[test]
@@ -1398,6 +1505,8 @@ mod tests {
let _ = rustfs_credentials::set_global_rpc_secret("put-file-auth-writer-test-secret".to_string());
let nonce = Uuid::parse_str("11111111-2222-4333-8444-555555555555").expect("nonce");
let server_epoch = Uuid::parse_str("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee").expect("server epoch");
let endpoint = "http://node1:9000".to_string();
let url = concat!(
"http://node1:9000/rustfs/rpc/put_file_stream?disk=disk-a&volume=bucket&path=object%2Fpart.1",
"&append=false&size=11&put_file_auth=digest-trailer-v1&put_file_nonce=11111111-2222-4333-8444-555555555555"
@@ -1406,7 +1515,7 @@ mod tests {
let mut sink = Vec::new();
{
let mut writer = PutFileAuthWriter::new(&mut sink, url.clone(), nonce);
let mut writer = PutFileAuthWriter::new(&mut sink, url.clone(), nonce, endpoint, server_epoch);
writer.write_all(b"hello world").await.expect("body write should succeed");
writer.shutdown().await.expect("shutdown should append auth trailer");
let err = writer
@@ -1424,6 +1533,143 @@ mod tests {
assert_eq!(verified, expected_digest);
}
#[tokio::test]
async fn put_file_auth_writer_reprobes_after_server_epoch_conflict() {
use tokio::io::AsyncWriteExt;
let _ = rustfs_credentials::set_global_rpc_secret("put-file-epoch-conflict-test-secret".to_string());
for status in [reqwest::StatusCode::CONFLICT, reqwest::StatusCode::BAD_REQUEST] {
for (phase, trailer_write) in [
(PutFileFailurePhase::Write, false),
(PutFileFailurePhase::Write, true),
(PutFileFailurePhase::Flush, false),
(PutFileFailurePhase::Shutdown, false),
] {
let endpoint = format!("http://epoch-conflict-{}.invalid", Uuid::new_v4());
let stale_epoch = Uuid::new_v4();
let replacement_epoch = Uuid::new_v4();
resolve_put_file_auth_capability(&endpoint, || async { Ok(Some(stale_epoch)) })
.await
.expect("initial capability should resolve");
let mut writer = PutFileAuthWriter::new(
PutFileFailureWriter { phase, status },
format!("{endpoint}{PUT_FILE_AUTH_STREAM_PATH}"),
Uuid::new_v4(),
endpoint.clone(),
stale_epoch,
);
let error = match (phase, trailer_write) {
(PutFileFailurePhase::Write, false) => writer.write_all(b"body").await,
(PutFileFailurePhase::Flush, _) => writer.flush().await,
_ => writer.shutdown().await,
}
.expect_err("injected writer error must reach the caller");
let conflict = status == reqwest::StatusCode::CONFLICT;
assert_eq!(io_error_has_put_file_epoch_conflict(&error), conflict);
let probe_called = AtomicBool::new(false);
let resolved = resolve_put_file_auth_capability(&endpoint, || async {
probe_called.store(true, Ordering::SeqCst);
Ok(Some(replacement_epoch))
})
.await
.expect("capability should remain usable or be reprobed");
assert_eq!(probe_called.load(Ordering::SeqCst), conflict, "phase={phase:?}, trailer={trailer_write}");
assert_eq!(resolved, Some(if conflict { replacement_epoch } else { stale_epoch }));
}
}
}
#[tokio::test]
async fn late_put_file_epoch_rejection_preserves_current_rejection() {
let endpoint = format!("http://late-epoch-conflict-{}.invalid", Uuid::new_v4());
let old_epoch = Uuid::new_v4();
let current_epoch = Uuid::new_v4();
let replacement_epoch = Uuid::new_v4();
assert_eq!(
resolve_put_file_auth_capability(&endpoint, || async { Ok(Some(old_epoch)) })
.await
.expect("initial epoch should be cached"),
Some(old_epoch)
);
reject_put_file_server_epoch(&endpoint, old_epoch);
assert_eq!(
resolve_put_file_auth_capability(&endpoint, || async { Ok(Some(current_epoch)) })
.await
.expect("first restart should install a new epoch"),
Some(current_epoch)
);
reject_put_file_server_epoch(&endpoint, current_epoch);
// A writer opened before the first restart can report its 409 after
// a newer writer has already rejected the second server incarnation.
reject_put_file_server_epoch(&endpoint, old_epoch);
let probe_called = AtomicBool::new(false);
let resolved = resolve_put_file_auth_capability(&endpoint, || async {
probe_called.store(true, Ordering::SeqCst);
Ok(Some(replacement_epoch))
})
.await
.expect("late old-epoch rejection must preserve the current rejection");
assert!(probe_called.load(Ordering::SeqCst), "known-rejected current epoch must be reprobed");
assert_eq!(resolved, Some(replacement_epoch));
}
#[tokio::test]
async fn put_file_epoch_rejection_is_endpoint_and_epoch_scoped() {
let endpoint = format!("http://scoped-epoch-{}.invalid", Uuid::new_v4());
let other_endpoint = format!("http://other-epoch-{}.invalid", Uuid::new_v4());
let current_epoch = Uuid::new_v4();
for endpoint in [&endpoint, &other_endpoint] {
resolve_put_file_auth_capability(endpoint, || async { Ok(Some(current_epoch)) })
.await
.expect("initial epoch should resolve");
}
reject_put_file_server_epoch(&endpoint, Uuid::new_v4());
assert_eq!(
resolve_put_file_auth_capability(&endpoint, || async { panic!("old writer must not invalidate a new epoch") })
.await
.expect("new epoch must remain cached"),
Some(current_epoch)
);
reject_put_file_server_epoch(&endpoint, current_epoch);
assert_eq!(
resolve_put_file_auth_capability(&other_endpoint, || async { panic!("another endpoint must stay cached") })
.await
.expect("other endpoint must remain cached"),
Some(current_epoch)
);
}
#[tokio::test]
async fn put_file_rejected_epoch_survives_failed_stale_and_downgrade_probes() {
let endpoint = format!("http://rejected-probe-{}.invalid", Uuid::new_v4());
let rejected_epoch = Uuid::new_v4();
let replacement_epoch = Uuid::new_v4();
resolve_put_file_auth_capability(&endpoint, || async { Ok(Some(rejected_epoch)) })
.await
.expect("initial epoch should resolve");
reject_put_file_server_epoch(&endpoint, rejected_epoch);
let failure = resolve_put_file_auth_capability(&endpoint, || async { Err(Error::other("injected probe failure")) })
.await
.expect_err("probe failure must be returned");
assert!(failure.to_string().contains("injected probe failure"));
let downgrade = resolve_put_file_auth_capability(&endpoint, || async { Ok(None) })
.await
.expect_err("rejection must not unpin authenticated v1");
assert!(downgrade.to_string().contains("downgrade rejected"));
resolve_put_file_auth_capability(&endpoint, || async { Ok(Some(rejected_epoch)) })
.await
.expect("a probe racing a restart can still return the old epoch");
assert_eq!(
resolve_put_file_auth_capability(&endpoint, || async { Ok(Some(replacement_epoch)) })
.await
.expect("same-epoch probe must not clear known rejection"),
Some(replacement_epoch)
);
}
#[test]
fn walk_dir_url_encodes_disk_ref() {
let url = build_walk_dir_url(&WalkDirStreamRequest {
+2 -2
View File
@@ -39,8 +39,8 @@ pub use http_auth::{
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, tonic_rpc_auth_failure_reason,
verify_ns_scanner_capability, verify_ns_scanner_capability_with_tier_registry_generation, verify_put_file_auth_trailer,
verify_put_file_capability, verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
verify_tonic_rpc_signature_with_bootstrap,
verify_tonic_mutation_body_digest, verify_tonic_mutation_body_digest_reject_unsigned, verify_tonic_rpc_response_proof,
verify_tonic_rpc_signature, verify_tonic_rpc_signature_with_bootstrap,
};
#[cfg(test)]
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
@@ -49,8 +49,8 @@ use rustfs_protos::proto_gen::node_service::{
ScannerActivityRequest, ScannerActivityResponse, ScannerPublicationLeaseReleaseRequest, ScannerPublicationLeaseRequest,
ScannerPublicationLeaseResponse, ServerInfoRequest, SignalServiceRequest, SignalServiceResponse, StartDecommissionRequest,
StartProfilingRequest, StopRebalanceRequest, TierMutationAbortRequest, TierMutationCommitRequest,
TierMutationControlResponse, TierMutationPeerState, TierMutationPrepareRequest, node_service_client::NodeServiceClient,
tier_mutation_control_service_client::TierMutationControlServiceClient,
TierMutationControlResponse, TierMutationFailureClass, TierMutationPeerState, TierMutationPrepareRequest,
node_service_client::NodeServiceClient, tier_mutation_control_service_client::TierMutationControlServiceClient,
};
pub use rustfs_protos::{PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS};
use rustfs_protos::{TierMutationRpcPhase, evict_failed_connection};
@@ -122,6 +122,14 @@ fn control_plane_failure(op: &str, bucket: Option<&str>, error_code: Option<i32>
if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32) {
return Error::RemoteNotInitialized;
}
if error_code == Some(rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode::ControlPlaneErrorInvalidArgument as i32)
{
return Error::InvalidArgument(
"control-plane".to_string(),
op.to_string(),
error_info.unwrap_or_else(|| format!("{op}: peer rejected invalid argument without details")),
);
}
match error_info {
Some(msg) => Error::other(msg),
None => peer_failure_without_details(op, bucket),
@@ -454,6 +462,31 @@ pub struct PeerTierMutationOutcome {
pub applied: bool,
}
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
struct TierMutationDefinitelyRejected {
message: String,
}
fn tier_mutation_definitely_rejected_error(message: String) -> Error {
Error::other(TierMutationDefinitelyRejected { message })
}
#[cfg(test)]
pub(crate) fn test_tier_mutation_definitely_rejected_error(message: &str) -> Error {
tier_mutation_definitely_rejected_error(message.to_string())
}
pub(crate) fn tier_mutation_error_is_definitely_rejected(error: &Error) -> bool {
matches!(
error,
Error::Io(io_error)
if io_error
.get_ref()
.is_some_and(|source| source.downcast_ref::<TierMutationDefinitelyRejected>().is_some())
)
}
fn validate_tier_mutation_response_proof(
version: u32,
phase: TierMutationRpcPhase,
@@ -461,6 +494,16 @@ fn validate_tier_mutation_response_proof(
canonical_payload: &[u8],
response: &TierMutationControlResponse,
) -> Result<()> {
if response.response_proof.len() > rustfs_protos::TIER_MUTATION_RPC_MAX_RESPONSE_PROOF_SIZE {
return Err(Error::other("peer tier mutation response proof exceeds size limit"));
}
if response
.error_info
.as_ref()
.is_some_and(|error| error.len() > rustfs_protos::TIER_MUTATION_RPC_MAX_ERROR_INFO_SIZE)
{
return Err(Error::other("peer tier mutation error response exceeds size limit"));
}
let canonical_response =
rustfs_protos::canonical_tier_mutation_rpc_response_body(rustfs_protos::TierMutationRpcResponseProofInput {
version,
@@ -471,6 +514,7 @@ fn validate_tier_mutation_response_proof(
state: response.state,
applied: response.applied,
error_info: response.error_info.as_deref(),
failure_class: response.failure_class,
})
.map_err(|_| Error::other("tier mutation response length cannot be represented"))?;
verify_tonic_rpc_response_proof(&canonical_response, &response.response_proof)
@@ -492,9 +536,9 @@ fn validate_tier_mutation_payload_len(phase: TierMutationRpcPhase, payload_len:
TierMutationRpcPhase::Commit => rustfs_protos::TIER_MUTATION_RPC_MAX_COMMIT_PAYLOAD_SIZE,
TierMutationRpcPhase::Abort => {
if payload_len == 0 {
return Ok(());
return Err(Error::other("tier mutation abort payload is empty"));
}
return Err(Error::other("tier mutation abort payload must be empty"));
rustfs_protos::TIER_MUTATION_RPC_MAX_ABORT_PAYLOAD_SIZE
}
_ => return Err(Error::other("tier mutation rpc phase is unsupported")),
};
@@ -513,8 +557,29 @@ fn tier_mutation_phase_label(phase: TierMutationRpcPhase) -> &'static str {
}
}
fn tier_mutation_control_status_error(phase: TierMutationRpcPhase, status: tonic::Status) -> Error {
Error::other(format!("peer tier mutation {} RPC failed: {status}", tier_mutation_phase_label(phase)))
fn tier_mutation_control_status_error(phase: TierMutationRpcPhase, requested_version: u32, status: tonic::Status) -> Error {
let message = format!("peer tier mutation {} RPC failed: {status}", tier_mutation_phase_label(phase));
let legacy_rejection = format!("unsupported tier mutation peer protocol version: {requested_version}");
// RUSTFS_COMPAT_TODO(backlog-2097-tier-mutation-v4-error-text): retain this exact v3-server rejection classifier for mixed-version peers. Remove after every supported peer returns the signed v4 failure class.
if requested_version == rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION
&& status.code() == tonic::Code::FailedPrecondition
&& status.message().as_bytes() == legacy_rejection.as_bytes()
{
return tier_mutation_definitely_rejected_error(message);
}
Error::other(message)
}
fn tier_mutation_failed_response_error(version: u32, failure_class: i32, error_info: Option<String>) -> Error {
let message = error_info.unwrap_or_else(|| "peer tier mutation failed without an error".to_string());
if version == rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION
&& TierMutationFailureClass::try_from(failure_class).ok() == Some(TierMutationFailureClass::PreDispatchRejected)
{
return tier_mutation_definitely_rejected_error(message);
}
// Missing/zero, unknown, and explicit Ambiguous are deliberately the same
// fail-closed result: the coordinator must include this peer in Abort.
Error::other(message)
}
impl PeerRestClient {
@@ -725,7 +790,7 @@ impl PeerRestClient {
/// never take it offline no matter what its message says. The substring
/// fallback only covers failures that exist purely as text, such as the
/// dial errors `get_client` wraps.
fn is_network_like_error(err: &Error) -> bool {
pub(crate) fn is_network_like_error(err: &Error) -> bool {
if let Error::Io(io_err) = err
&& let Some(status) = embedded_tonic_status(io_err)
{
@@ -1307,8 +1372,12 @@ impl PeerRestClient {
.await
}
pub async fn abort_tier_mutation(&self, mutation_id: Uuid) -> Result<PeerTierMutationOutcome> {
self.tier_mutation_control(TierMutationRpcPhase::Abort, mutation_id, Bytes::new())
pub async fn abort_tier_mutation(
&self,
mutation_id: Uuid,
canonical_prepare_payload: Bytes,
) -> Result<PeerTierMutationOutcome> {
self.tier_mutation_control(TierMutationRpcPhase::Abort, mutation_id, canonical_prepare_payload)
.await
}
@@ -1342,7 +1411,7 @@ impl PeerRestClient {
client
.prepare_tier_mutation(request)
.await
.map_err(|status| tier_mutation_control_status_error(phase, status))?
.map_err(|status| tier_mutation_control_status_error(phase, version, status))?
.into_inner()
}
TierMutationRpcPhase::Commit => {
@@ -1355,7 +1424,7 @@ impl PeerRestClient {
client
.commit_tier_mutation(request)
.await
.map_err(|status| tier_mutation_control_status_error(phase, status))?
.map_err(|status| tier_mutation_control_status_error(phase, version, status))?
.into_inner()
}
TierMutationRpcPhase::Abort => {
@@ -1368,18 +1437,19 @@ impl PeerRestClient {
client
.abort_tier_mutation(request)
.await
.map_err(|status| tier_mutation_control_status_error(phase, status))?
.map_err(|status| tier_mutation_control_status_error(phase, version, status))?
.into_inner()
}
_ => return Err(Error::other("tier mutation rpc phase is unsupported")),
};
validate_tier_mutation_response_proof(version, phase, mutation_id, &canonical_payload, &response)?;
if !response.success {
return Err(Error::other(
response
.error_info
.unwrap_or_else(|| "peer tier mutation failed without an error".to_string()),
));
return Err(tier_mutation_failed_response_error(version, response.failure_class, response.error_info));
}
if version == rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION
&& response.failure_class != TierMutationFailureClass::Unspecified as i32
{
return Err(Error::other("successful peer tier mutation response carried a failure class"));
}
let state = decode_tier_mutation_peer_state(response.state)?;
Ok(PeerTierMutationOutcome {
@@ -2335,6 +2405,29 @@ mod tests {
use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode;
assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorUnspecified as i32, 0);
assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorNotInitialized as i32, 1);
assert_eq!(ControlPlaneErrorCode::ControlPlaneErrorInvalidArgument as i32, 2);
}
#[test]
fn control_plane_failure_preserves_typed_invalid_argument_reason() {
use rustfs_protos::proto_gen::node_service::ControlPlaneErrorCode;
let reason = "durable unresolved-entry recovery requires pool metadata V2 or V3";
let err = control_plane_failure(
"start_decommission",
None,
Some(ControlPlaneErrorCode::ControlPlaneErrorInvalidArgument as i32),
Some(reason.to_string()),
);
assert!(
matches!(
err,
Error::InvalidArgument(ref scope, ref operation, ref actual_reason)
if scope == "control-plane" && operation == "start_decommission" && actual_reason == reason
),
"forwarded validation failures must remain typed and actionable"
);
}
#[test]
@@ -3301,6 +3394,7 @@ mod tests {
state: i32,
applied: bool,
error_info: Option<&'a str>,
failure_class: i32,
}
fn signed_tier_mutation_response(input: TierMutationResponseFixture<'_>) -> TierMutationControlResponse {
@@ -3314,6 +3408,7 @@ mod tests {
state: input.state,
applied: input.applied,
error_info: input.error_info,
failure_class: input.failure_class,
})
.expect("small tier mutation response should encode");
let response_proof =
@@ -3324,6 +3419,7 @@ mod tests {
applied: input.applied,
error_info: input.error_info.map(str::to_string),
response_proof: response_proof.into(),
failure_class: input.failure_class,
}
}
@@ -3341,6 +3437,7 @@ mod tests {
state: TierMutationPeerState::Prepared as i32,
applied: true,
error_info: None,
failure_class: TierMutationFailureClass::Unspecified as i32,
});
validate_tier_mutation_response_proof(
rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
@@ -3365,6 +3462,10 @@ mod tests {
applied: false,
..response.clone()
},
TierMutationControlResponse {
failure_class: TierMutationFailureClass::Ambiguous as i32,
..response.clone()
},
] {
let err = validate_tier_mutation_response_proof(
rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
@@ -3388,6 +3489,44 @@ mod tests {
assert!(err.to_string().contains("invalid tier mutation response proof"));
}
#[test]
fn tier_mutation_response_rejects_oversized_proof_and_error_before_verification() {
let mutation_id = Uuid::new_v4();
let payload = b"tier-mutation-prepare";
let oversized_proof = TierMutationControlResponse {
success: false,
state: TierMutationPeerState::Unspecified as i32,
applied: false,
error_info: None,
response_proof: vec![0; rustfs_protos::TIER_MUTATION_RPC_MAX_RESPONSE_PROOF_SIZE + 1].into(),
failure_class: TierMutationFailureClass::Ambiguous as i32,
};
let err = validate_tier_mutation_response_proof(
rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
payload,
&oversized_proof,
)
.expect_err("oversized proof must fail before cryptographic verification");
assert!(err.to_string().contains("response proof exceeds size limit"));
let oversized_error = TierMutationControlResponse {
response_proof: Bytes::new(),
error_info: Some("e".repeat(rustfs_protos::TIER_MUTATION_RPC_MAX_ERROR_INFO_SIZE + 1)),
..oversized_proof
};
let err = validate_tier_mutation_response_proof(
rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION,
TierMutationRpcPhase::Prepare,
mutation_id,
payload,
&oversized_error,
)
.expect_err("oversized error detail must fail before proof construction");
assert!(err.to_string().contains("error response exceeds size limit"));
}
#[test]
fn tier_mutation_peer_state_decode_fails_closed() {
assert_eq!(
@@ -3432,8 +3571,17 @@ mod tests {
)
.is_err()
);
validate_tier_mutation_payload_len(TierMutationRpcPhase::Abort, 0).expect("empty abort payload should fit");
assert!(validate_tier_mutation_payload_len(TierMutationRpcPhase::Abort, 1).is_err());
assert!(validate_tier_mutation_payload_len(TierMutationRpcPhase::Abort, 0).is_err());
validate_tier_mutation_payload_len(TierMutationRpcPhase::Abort, 1).expect("non-empty abort payload should fit");
validate_tier_mutation_payload_len(TierMutationRpcPhase::Abort, rustfs_protos::TIER_MUTATION_RPC_MAX_ABORT_PAYLOAD_SIZE)
.expect("max abort payload should fit");
assert!(
validate_tier_mutation_payload_len(
TierMutationRpcPhase::Abort,
rustfs_protos::TIER_MUTATION_RPC_MAX_ABORT_PAYLOAD_SIZE + 1,
)
.is_err()
);
}
#[test]
@@ -3448,7 +3596,7 @@ mod tests {
tonic::Status::deadline_exceeded("peer tier mutation control timed out"),
tonic::Status::unavailable("peer tier mutation control unavailable"),
] {
let err = tier_mutation_control_status_error(phase, status);
let err = tier_mutation_control_status_error(phase, rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION, status);
let rendered = err.to_string();
assert!(rendered.contains(&format!("peer tier mutation {label} RPC failed")), "{rendered}");
assert!(
@@ -3462,6 +3610,61 @@ mod tests {
}
}
#[test]
fn tier_mutation_v4_to_v3_rejection_classification_requires_exact_status_and_message() {
let version = rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION;
let exact = format!("unsupported tier mutation peer protocol version: {version}");
let rejected = tier_mutation_control_status_error(
TierMutationRpcPhase::Prepare,
version,
tonic::Status::failed_precondition(exact.clone()),
);
assert!(tier_mutation_error_is_definitely_rejected(&rejected));
for status in [
tonic::Status::failed_precondition(format!("{exact}.")),
tonic::Status::failed_precondition(format!("unsupported tier mutation peer protocol version: {}", version - 1)),
tonic::Status::invalid_argument(exact.clone()),
tonic::Status::unimplemented(exact),
] {
let ambiguous = tier_mutation_control_status_error(TierMutationRpcPhase::Prepare, version, status);
assert!(
!tier_mutation_error_is_definitely_rejected(&ambiguous),
"near-text, wrong-code, and Unimplemented failures must remain ambiguous"
);
}
}
#[test]
fn tier_mutation_v4_failure_class_is_typed_and_fails_closed() {
let version = rustfs_protos::TIER_MUTATION_RPC_PROTOCOL_VERSION;
let rejected = tier_mutation_failed_response_error(
version,
TierMutationFailureClass::PreDispatchRejected as i32,
Some("rejected".to_string()),
);
assert!(tier_mutation_error_is_definitely_rejected(&rejected));
for failure_class in [
TierMutationFailureClass::Unspecified as i32,
TierMutationFailureClass::Ambiguous as i32,
99,
] {
let ambiguous = tier_mutation_failed_response_error(version, failure_class, None);
assert!(
!tier_mutation_error_is_definitely_rejected(&ambiguous),
"missing, unknown, and explicit ambiguous classes must trigger Abort fanout"
);
}
let v3_ignores_v4_class = tier_mutation_failed_response_error(
rustfs_protos::TIER_MUTATION_RPC_PREVIOUS_PROTOCOL_VERSION,
TierMutationFailureClass::PreDispatchRejected as i32,
Some("legacy failure".to_string()),
);
assert!(!tier_mutation_error_is_definitely_rejected(&v3_ignores_v4_class));
}
#[tokio::test]
async fn peer_rest_client_rejects_oversized_tier_prepare_before_dialing() {
let client = test_peer_client();
@@ -190,7 +190,7 @@ fn install_heal_bucket_pre_mutation_barrier() -> Arc<DeleteBucketEmptyScanBarrie
}
#[cfg(test)]
async fn pause_after_delete_bucket_empty_scan() {
pub(crate) async fn pause_after_delete_bucket_empty_scan() {
let barrier = DELETE_BUCKET_EMPTY_SCAN_BARRIER
.lock()
.expect("empty scan barrier lock should not be poisoned")
+85 -7
View File
@@ -800,11 +800,22 @@ where
if log_error {
error!("save_config_with_opts: err: {:?}, file: {}", err, file);
}
Err(err)
Err(map_system_metadata_write_error(err, file))
}
}
}
/// A system metadata volume outage must remain retryable instead of being
/// exposed as the user-facing bucket-not-found response.
pub(crate) fn map_system_metadata_write_error(err: Error, file: &str) -> Error {
match err {
Error::BucketNotFound(_) | Error::VolumeNotFound => {
Error::InsufficientWriteQuorum(RUSTFS_META_BUCKET.to_string(), file.to_string())
}
other => other,
}
}
fn new_server_config() -> Config {
Config::new()
}
@@ -2361,6 +2372,7 @@ where
scan_mode: HealScanMode::Deep,
update_parity: false,
no_lock: false,
read_repair: false,
pool: None,
set: None,
};
@@ -2795,14 +2807,14 @@ mod tests {
use super::{
SERVER_CONFIG_LOCK, ServerConfigSnapshot, apply_dynamic_config_for_sub_sys_with, build_scalar_config_object,
config_task_join_error, configs_semantically_equal, decode_server_config_blob, encode_server_config_blob,
heal_config_descriptor, is_standard_object_server_config, lookup_configs, new_and_save_server_config, read_config,
read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty, read_config_with_metadata,
read_config_without_migrate, read_server_config_snapshot, save_server_config, save_server_config_snapshot,
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, should_warn_ignored_scalar_section,
storage_class_kvs_mut,
heal_config_descriptor, is_standard_object_server_config, lookup_configs, map_system_metadata_write_error,
new_and_save_server_config, read_config, read_config_no_lock_preserve_empty_with_metadata, read_config_preserve_empty,
read_config_with_metadata, read_config_without_migrate, read_server_config_snapshot, save_config_with_opts_inner,
save_server_config, save_server_config_snapshot, save_server_config_snapshot_with_generation,
server_config_transaction_lock_path, should_warn_ignored_scalar_section, storage_class_kvs_mut,
};
use crate::config::{audit, heal, notify, oidc, scanner};
use crate::disk::endpoint::Endpoint;
use crate::disk::{RUSTFS_META_BUCKET, endpoint::Endpoint};
use crate::error::{Error, Result};
use crate::layout::endpoints::SetupType;
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
@@ -2834,6 +2846,72 @@ mod tests {
assert!(rendered.contains("panicked"));
assert!(!rendered.contains("do-not-expose-payload"));
}
#[test]
fn system_metadata_volume_failures_map_to_retryable_write_errors() {
for error in [Error::VolumeNotFound, Error::BucketNotFound(RUSTFS_META_BUCKET.to_string())] {
assert_eq!(
map_system_metadata_write_error(error, "buckets/example/.metadata.bin"),
Error::InsufficientWriteQuorum(RUSTFS_META_BUCKET.to_string(), "buckets/example/.metadata.bin".to_string())
);
}
let other = Error::other("metadata encoding failed");
assert_eq!(map_system_metadata_write_error(other.clone(), "buckets/example/.metadata.bin"), other);
}
#[derive(Debug, Default)]
struct MetadataWriteStore {
error: Option<Error>,
}
#[async_trait::async_trait]
impl crate::storage_api_contracts::object::ObjectIO for MetadataWriteStore {
type Error = Error;
type RangeSpec = HTTPRangeSpec;
type HeaderMap = HeaderMap;
type ObjectOptions = ObjectOptions;
type ObjectInfo = ObjectInfo;
type GetObjectReader = GetObjectReader;
type PutObjectReader = PutObjReader;
async fn get_object_reader(
&self,
_bucket: &str,
_object: &str,
_range: Option<Self::RangeSpec>,
_headers: Self::HeaderMap,
_opts: &Self::ObjectOptions,
) -> core::result::Result<Self::GetObjectReader, Self::Error> {
Err(Error::FileNotFound)
}
async fn put_object(
&self,
_bucket: &str,
_object: &str,
_data: &mut Self::PutObjectReader,
_opts: &Self::ObjectOptions,
) -> core::result::Result<Self::ObjectInfo, Self::Error> {
Err(self.error.clone().expect("test store error should be configured"))
}
}
#[tokio::test]
async fn save_config_preserves_retryable_system_volume_errors() {
let store = Arc::new(MetadataWriteStore {
error: Some(Error::BucketNotFound(RUSTFS_META_BUCKET.to_string())),
});
let error =
save_config_with_opts_inner(store, "buckets/example/.metadata.bin", Vec::new(), &ObjectOptions::default(), false)
.await
.expect_err("missing metadata volume must fail");
assert_eq!(
error,
Error::InsufficientWriteQuorum(RUSTFS_META_BUCKET.to_string(), "buckets/example/.metadata.bin".to_string())
);
}
use rustfs_lock::client::LockClient;
use rustfs_lock::client::local::LocalClient;
use rustfs_lock::{LockError, LockInfo, LockResponse, LockStats};
File diff suppressed because it is too large Load Diff

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