Compare commits

...

209 Commits

Author SHA1 Message Date
Zhengchao An 043842d571 Merge branch 'main' into test/distributed-e2e-hardening 2026-09-06 03:32:13 +08:00
Zhengchao An e1608fbd9c test(odm): exercise overflow and invalid cursors reliably (#7236) 2026-09-06 03:09:39 +08:00
Zhengchao An eb1b17802c test(odm): provide the source region in access fixture (#7235) 2026-09-06 02:34:56 +08:00
Zhengchao An 112f70914d fix(build): scope migration helpers to their features (#7234)
* test(odm): keep listing header import test scoped

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

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

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

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

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

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

* docs(metadata): clarify MinIO target recovery limits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

* fix(ci): exclude sensitive scratch files from suite artifacts
2026-09-06 00:14:22 +08:00
Zhengchao An 4da48f888e Merge branch 'main' into test/distributed-e2e-hardening 2026-09-05 23:56:44 +08:00
houseme f54323b062 chore(deps): preserve scanner and heal validation compatibility (#7209)
* chore(deps): refresh scanner heal batch dependency baseline

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

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

* fix(ecstore): remove duplicate local rename implementation

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

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

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

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

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

* fix(deps): preserve supported hotpath focus expressions

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

Refs rustfs/backlog#2302.

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

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

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

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

---------

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

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

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

Co-authored-by: rustfs-ci <ci@rustfs.com>
2026-09-05 23:06:28 +08:00
overtrue d8580ec970 test(e2e): prove operations overlap data movement 2026-09-05 22:50:00 +08:00
Zhengchao An 034efa8dac Merge branch 'main' into test/distributed-e2e-hardening 2026-09-05 22:21:31 +08:00
Zhengchao An 188f380b3b feat(ecstore): add native azure blob and gcs migration sources (#7211)
* feat(ecstore): add a native azure blob odm source backend

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

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

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

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

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

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

* fix(app): simplify absent SSE configuration matching

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

* fix(ecstore): remove duplicate local rename implementation

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

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

* fix(ci): satisfy new clippy lints

* style(scanner): order merged test imports

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

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

---------

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

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

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

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

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

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

* docs(heal): register legacy MRF inspection cleanup

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

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

---------

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

* fix(ecstore): remove duplicate local rename implementation

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

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

* fix(app): simplify absent SSE configuration matching

* fix(tests): satisfy new clippy lints

---------

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

* fix(ecstore): remove duplicate local rename implementation

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

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

* fix(app): simplify absent SSE configuration matching

* fix(tests): satisfy new clippy lints

---------

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

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

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

* fix(ecstore): remove duplicate local rename implementation

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

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

* fix(app): simplify absent SSE configuration matching

* fix(tests): satisfy new clippy lints

---------

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

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

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

* fix(ci): satisfy new clippy lints

---------

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

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

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

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

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

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

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

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

---------

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

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

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

* fix(scanner): make reset cleanup safely reentrant

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

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

---------

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

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

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

* fix(admin): report partial background heal coverage

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

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

---------

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

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

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

* fix(heal): retain completed task progress

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

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

---------

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

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

---------

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

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

* fix(ecstore): drain backfill checkpoint before confirmation

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

* fix(ecstore): retain indeterminate rename recovery evidence

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

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

* fix(ecstore): preserve known preflight rename rejections

* test(ecstore): cover observed rename outer failures

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

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

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

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

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

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

* fix(site-replication): preserve retry ordering

* fix(site-replication): bound retry coordination

* fix(site-replication): serialize topology replay

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

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

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

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

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

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

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

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

* fix(site-replication): classify connect timeouts

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

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

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

* fix(site-replication): fence recovery revisions

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

* fix(site-replication): preserve s3gate boundary

---------

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

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

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

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

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

* test(scanner): add durable checkpoint diagnostics

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

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

---------

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

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

* fix(ecstore): drain backfill checkpoint before confirmation

* refactor(ecstore): isolate local object rename commit

* refactor(ecstore): remove moved quota fence import

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

* fix(ecstore): retain indeterminate rename recovery evidence

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

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

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

* fix(ecstore): preserve known preflight rename rejections

* test(ecstore): cover observed rename outer failures

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

* docs(odm): clarify folded source probe pagination

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs: rustfs/backlog#2282

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

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

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

Refs: rustfs/backlog#2282
2026-09-05 13:02:23 +08:00
Zhengchao An 1277c11f04 Merge branch 'main' into test/distributed-e2e-hardening 2026-09-05 11:57:33 +08:00
Zhengchao An 4b0d597d4d test(ecstore): fix sealed context fixture map type (#7161)
test(ecstore): match sealed context fixture map type
2026-09-05 11:54:24 +08:00
Zhengchao An 13e6424e99 docs(architecture): settle remote credential sealing threat model (#7168)
* docs(architecture): settle remote credential sealing threat model

* docs(architecture): index sealing ADR threat-model scope
2026-09-05 11:20:58 +08:00
cxymds b33693fc19 feat(tier): fence legacy state reconciliation (#7144) 2026-09-05 01:42:24 +00:00
RustFS bdb069c0fd Merge branch 'main' into test/distributed-e2e-hardening 2026-09-05 09:29:28 +08:00
Zhengchao An 9ed1d46090 docs(ecstore): define generation authority and recovery boundary (#7159) 2026-09-05 09:29:00 +08:00
RustFS b65ab747d3 Merge branch 'main' into test/distributed-e2e-hardening 2026-09-05 09:17:48 +08:00
cxymds 6eb60f8e72 feat(tier): add durable probe intent protocol (#7151)
* feat(tier): add durable probe intent protocol

* test(tier): remove redundant intent clones
2026-09-05 09:06:05 +08:00
cxymds 8dd3cabd41 test(ecstore): stabilize transition generation fixture (#7140) 2026-09-05 00:46:19 +00:00
Zhengchao An e648f683bf fix: use BTreeMap for deterministic encryption context serialization (#7154)
SealScope::encryption_context() returned a HashMap whose key order is
non-deterministic. The FakeSealer test round-trips the context through
JSON serialization, and HashMap's random iteration order caused the
prefix comparison to intermittently fail with 'encryption context mismatch'.

Switch to BTreeMap which guarantees stable key ordering.
2026-09-05 07:21:25 +08:00
cxymds 193b1b7d3f test(ecstore): stabilize sealed context encoding (#7149) 2026-09-05 07:18:37 +08:00
loverustfs 8c48f184b5 test(e2e): add distributed 4x4 validation 2026-09-05 02:51:03 +08:00
hector 3b920c7999 ci: render step results and version in workflow reports (#7141)
* ci(upgrade): render an upgrade matrix in the report; fix from default

The upgrade report only ever showed the requested deb URLs and a case
table. The nightly chain runs died installing the OLD package (default
from_version 1.0.0-rc.4-preview.1 has no .deb asset on its release, and
release 1.0.0-rc.4 ships none either), leaving an empty Total: 0 report
with no indication of what was upgraded.

- Default from_version is now 1.0.0-rc.3 (ships rustfs_1.0.0.rc.3_amd64.deb).
  Matches the auto-testing default from PR #32.
- The report generator also parses the [UPG-TOPO] lines the suite now
  emits and renders an 'Upgrade Matrix' section: per topology and KMS
  backend, the versions actually in place before/after (captured via
  'rustfs --version' on the node) and the aggregated result. When the
  suite dies before any topology completes, the matrix says so instead
  of silently showing nothing.

* fix(ci): use English headers in the upgrade matrix table

* ci(heal,pool): render step results and version in the reports

The heal and pool-expansion reports were only a raw log tail: no
structured indication of which steps passed, no overall verdict, and no
version information for the cluster under test.

The suites now emit machine-readable lines (auto-testing PR):
  [HEAL-STEP] <n> <desc> PASS|FAIL     [POOL-STEP] <n> <desc> PASS|FAIL
  [HEAL-VERSION] <ver> (node <n>)      [POOL-VERSION] <ver> (node <n>)
  [HEAL-RESULT] PASS|FAIL <detail>     [POOL-RESULT] PASS|FAIL <detail>

Both report generators parse them and emit a '## Step Results' section
before the log tail: the version captured in place via 'rustfs --version'
on a node, the overall verdict, and a per-step table. When a run dies
before any step reports (old script or early crash), the table shows a
NOT RUN placeholder row instead of silently showing nothing.
2026-09-05 02:28:14 +08:00
cxymds 5f8b097172 fix(tier): bound distributed mutation latency (#7150) 2026-09-05 02:28:08 +08:00
cxymds 445114577f docs(ilm): approve bounded recovery disposition (#7155) 2026-09-05 02:28:02 +08:00
Henry Guo 923bde6904 feat(scanner): prepare scoped bucket cache scans (#7136)
Co-authored-by: Henry Guo <marshawcoco@users.noreply.github.com>
2026-09-04 15:09:58 +00:00
cui fliter 10ccf7c31a fix(version): avoid deriving RustFS version from runtime working directory (#7118)
Signed-off-by: cuishuang <imcusg@gmail.com>
2026-09-04 23:03:11 +08:00
Zhengchao An 146003a426 refactor(odm): separate source backends from namespace mapping (#7129)
refactor(odm): isolate read-only source backend contract
2026-09-04 21:40:07 +08:00
cxymds 65ed86f76e test(ecstore): run checkpoint publication test on large stack (#7134) 2026-09-04 12:39:18 +00:00
cxymds 3da3bcd9ee fix(tier): preserve credentials during backend validation (#7127)
* fix(tier): preserve credentials during backend validation

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

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

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

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

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

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

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

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

* chore(deps): bump rustfs kafka crates

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

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

---------

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

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

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

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

backlog#1341, backlog#2205

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

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

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

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

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

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

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

* fix(scanner): keep dirty snapshot errors stable

* test(protos): satisfy dirty snapshot clippy

* fix(scanner): satisfy dirty snapshot clippy

---------

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

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

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

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

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

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

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

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

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

Closes rustfs/backlog#2207

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes rustfs#7082.

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

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

* docs(scanner): define usage authority protocol

* docs(heal): clarify scanner and ecstore boundaries

* refactor(scanner): split metrics from contracts

* feat(scanner): use shared workload snapshots

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

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

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

* fix(ecstore): clarify V3 capacity gate guidance

* fix(ecstore): keep target contention retryable

* fix(ecstore): preserve typed target lock errors

* fix(ecstore): harden decommission recovery

* fix(ecstore): close decommission recovery races

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

* fix(ecstore): model capacity mutation parameters

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

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

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

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

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

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

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

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

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

Adds scan_dir_does_not_emit_entries_past_a_limit_hit_inside_a_subdirectory.

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Found while root-causing rustfs#6825.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs rustfs/backlog#2149

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

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

Refs rustfs/backlog#2149

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

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

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

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

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

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

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

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

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

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

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

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

Refs rustfs/backlog#2149

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

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

Refs rustfs/backlog#2149

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs rustfs/backlog#2149

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

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

Refs rustfs/backlog#2149

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

* fix(scanner): validate legacy usage floor shape

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

---------

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

* fix(ilm): stabilize causal cleanup CI coverage

* test(ilm): make expire GET race deterministic

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

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

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

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

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

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

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

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

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

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

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

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

Remove the unused lifecycle url dependency reported by cargo shear.

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

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

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

---------

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

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

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

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

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

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

* fix(heal): admit unformatted replacement targets

* fix(heal): preserve replacement heal set scope

* fix(heal): attach scoped replacement targets

* fix(heal): preserve replacement heal set scope

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

---------

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

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

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

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

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

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

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

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

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

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

Fixes #7003
2026-09-01 22:41:24 +08:00
hector 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
GatewayJ 833cc51534 feat(s3-select): stream JSON document input (#6980) 2026-09-01 20:17:14 +08:00
唐小鸭 43450df589 fix(ecstore): keep degraded objects listable when drives are offline (#7010) 2026-09-01 20:16:57 +08:00
唐小鸭 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
唐小鸭 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 An 297ff4688c feat(license): add entitlement provider abstraction (#7006) 2026-09-01 19:31:35 +08:00
唐小鸭 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
唐小鸭 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
cxymds 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
唐小鸭 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
cxymds 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 <anzhengchao@gmail.com>
2026-09-01 18:32:16 +08:00
唐小鸭 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
cxymds 03aecc5c3e fix(heal): avoid pool metadata lock recursion (#6991) 2026-09-01 18:31:21 +08:00
cxymds 45fe54e389 fix(admin): align tier backend error contract (#6988) 2026-09-01 18:31:07 +08:00
cxymds 2ed5c297ac fix(deps): update mysql_async to 0.37.1 (#7007) 2026-09-01 18:30:10 +08:00
Zhengchao An 23ab078c56 fix(ecstore): reclaim stale object prefixes (#6974) 2026-09-01 10:09:00 +00:00
cxymds 80c629bfe0 test(ci): refresh e2e full selection manifests (#6983) 2026-09-01 09:26:13 +00:00
houseme 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 <heihutu@gmail.com>
2026-09-01 17:08:46 +08:00
cxymds cee84561e7 fix(heal): preserve resumable retry signals (#6995) 2026-09-01 16:55:57 +08:00
Zhengchao An b09ce8e6b5 docs: make pre-pr validation conditional (#7001) 2026-09-01 16:48:21 +08:00
cxymds a45951260a test(ecstore): stabilize activation race capacity (#6989) 2026-09-01 16:05:04 +08:00
houseme 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
cxymds 35ce8cdb80 test(tier): enforce structured two-topology gate (#6985) 2026-09-01 14:12:11 +08:00
houseme 0b1a588da5 fix: adapt object metadata to s3s DTO changes (#6981) 2026-09-01 13:04:23 +08:00
cxymds f6c6736a01 docs(deps): expand tokio-tar cleanup contract (#6984) 2026-09-01 12:07:07 +08:00
houseme 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 <heihutu@gmail.com>
Co-authored-by: Zhengchao An <anzhengchao@gmail.com>
2026-09-01 01:51:46 +00:00
Zhengchao An b0256e3453 test(e2e): cover mixed-version rolling upgrades (#6975) 2026-09-01 07:09:00 +08:00
Zhengchao An 14a77f9d79 fix(ecstore): supplement split latest listings (#6977) 2026-09-01 07:08:25 +08:00
558 changed files with 132225 additions and 26244 deletions
@@ -22,3 +22,17 @@
fixtures and encrypted migration data.
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
condition, and default toward reading old data safely.
## Outbound targets
- A change to what the replication or migration client sends by default
(checksum policy, payload framing, headers, version-id addressing) is judged
against every target class, not the one it fixes. Name each target-side rule
the current default satisfies — checksum required with Object Lock
parameters, `aws-chunked` decoding, version-id adoption, ETag equals content
MD5 — and show which cell of
`crates/e2e_test/src/replication_target_matrix_test.rs` covers each.
- A test that asserts the fix ("no trailer header") is not evidence; the
matrix cell that asserts the target accepted and stored the object is.
- Every new environment escape hatch appears in
`docs/operations/replication-outbound-transport.md` in the same diff.
+5 -4
View File
@@ -50,10 +50,11 @@ consider adding it to the script's `checked_files` list.
## `check_doc_paths.sh`
Instruction/architecture docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`,
`docs/architecture/*.md`) must not reference repo file paths that no longer
exist. If your refactor moved code, update the docs that point at it — the
error message lists `doc -> stale-path` pairs.
Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
Markdown file under `docs/` (architecture, operations, testing, index) must not
reference repo file paths that no longer exist. If your refactor moved code,
update the docs that point at it — the error message lists `doc -> stale-path`
pairs. Cite paths plus symbol names, never line numbers (see `docs/README.md`).
## `check_no_planning_docs.sh`
+2
View File
@@ -0,0 +1,2 @@
sha256-linux=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=d6aa36cfaae2c4d8590482c7e47138c5965b335b34a75f50d11ffc3366e9021e
sha256-linux=e3eb4ab7fc72224abf58c546ac0706d6605d3bd26bac7d8ce338829fd3daecc2
sha256-darwin=a881fd7d3f5cb94654221ca85b8b30cce1b95e608824a55a15339cbc294e6d34
sha256-linux=a2933d83dfe74ffa03410a0959333a1c48288b8469ca9f17273d449d7510c24b
+2 -1
View File
@@ -1 +1,2 @@
sha256=9c2b958035a038ffd5ab98cac5f59a1b8e6a16e141f109ec7fb956afc0f11105
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
+1
View File
@@ -0,0 +1 @@
sha256=87c05c46d611ea7ed3feb5f7276bda8e5a0f70d72165d305d73a457907e7ba79
+1 -1
View File
@@ -1 +1 @@
sha256=8d5517f5f2fc32d561782dfccd51b7f746f5e25b2835e37e100c883f7f18777d
sha256=95c8adc016bbc0df9fb2afa24a108bcdf6567ec4d0518725a6cae301593ab556
+1 -1
View File
@@ -1 +1 @@
sha256=dbebfbab9b9efd4eff31211e69dd32235dc00e207f2ab0dd919a1b2ac9e724c2
sha256=a2542dc86bbff56b2177efc621785c56fa7e8d813b209b7d935e1e41a9f0ad15
+72
View File
@@ -0,0 +1,72 @@
{
"lane": "ci/test-and-lint",
"tests": [
{
"invariant": "write-quorum",
"suite": "rustfs-ecstore",
"name": "set_disk::ops::object::inline_put_commit_path_tests::inline_put_direct_commit_accepts_exact_quorum_and_rejects_quorum_minus_one"
},
{
"invariant": "metadata-rollback",
"suite": "rustfs-ecstore",
"name": "set_disk::core::io_primitives::tests::write_unique_file_info_reverts_metadata_when_write_quorum_fails"
},
{
"invariant": "stale-writer",
"suite": "rustfs-ecstore",
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::put_object_no_lock_aborts_after_outer_namespace_lock_loss"
},
{
"invariant": "range-body",
"suite": "rustfs-ecstore",
"name": "set_disk::ops::object::transition_upload_integrity_tests::transitioned_compressed_object_range_get_returns_plaintext_slice"
},
{
"invariant": "multipart-cancellation",
"suite": "rustfs-ecstore",
"name": "set_disk::ops::multipart::tests::cancelled_complete_keeps_upload_lock_through_tail_cleanup"
},
{
"invariant": "list-uncommitted-version",
"suite": "rustfs-filemeta",
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
},
{
"invariant": "minio-object-fixture",
"suite": "rustfs-filemeta",
"name": "filemeta::test::parses_real_minio_object_xlmeta"
},
{
"invariant": "corrupt-part-arrays",
"suite": "rustfs-filemeta",
"name": "filemeta::test::crc_valid_but_part_arrays_corrupt_into_fileinfo_errors_not_panics"
}
],
"fixtures": [
{
"path": "crates/filemeta/tests/fixtures/minio/object_large_bin.xlmeta.hex",
"sha256": "e8093767806d701e639b48d023190e858fbc4cde69bcfd83c22af8cba8452ce5",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
},
{
"path": "crates/filemeta/tests/fixtures/minio/object_small_txt.xlmeta.hex",
"sha256": "2a415ad3a3be5a9440035d4026ff880e0e8c1ec1701be9f4e077734e8dce03da",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
},
{
"path": "crates/filemeta/tests/fixtures/minio/object_versioned_txt.xlmeta.hex",
"sha256": "7f21f50c326dd8b0228deb6dbdb7052b3d0a3f8ee6c85d43486f0e6bb7a97261",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
},
{
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata.blob.hex",
"sha256": "f2b6e260aff106adf6039feb1c645686e84e75404ff725491fb18668be5db203",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
},
{
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata_full.xlmeta.hex",
"sha256": "3b6de589519c08a1614c8bd409bb8199c17d42043861b07bce513075e6fbfc12",
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
}
]
}
+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
+3 -2
View File
@@ -3,9 +3,10 @@
.NOTPARALLEL: pre-commit pre-pr dev-check
.PHONY: setup-hooks
setup-hooks: ## Set up git hooks
setup-hooks: ## Install the configured pre-commit hooks
@echo "🔧 Setting up git hooks..."
chmod +x .git/hooks/pre-commit
pre-commit validate-config
pre-commit install
@echo "✅ Git hooks setup complete!"
.PHONY: doc-paths-check
+8 -5
View File
@@ -35,13 +35,16 @@ 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/test_security_workflow.py
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
$(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
+160 -15
View File
@@ -65,11 +65,11 @@ 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))$/))'
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
setup = 'ecstore-base-stack'
[[profile.default.scripts]]
@@ -89,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.
@@ -160,6 +183,13 @@ test-group = 'e2e-reliability'
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
test-group = 'e2e-inline-boundaries'
# 4-node 4-drive distributed Actions suite: each case starts four rustfs
# processes and up to sixteen data directories. Serialize across nextest's
# process boundary so several 4x4 clusters never overlap.
[[profile.default.overrides]]
filter = 'package(e2e_test) & test(/^distributed::/)'
test-group = 'e2e-cluster-nightly'
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
# does not cross nextest process boundaries, so keep every Vault-backed test in
# one group.
@@ -183,11 +213,11 @@ 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))$/))'
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
setup = 'ecstore-base-stack'
[[profile.ci.scripts]]
@@ -250,6 +280,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]]
@@ -318,7 +362,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 —
@@ -356,13 +401,32 @@ test-group = 'ecstore-serial-flaky'
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
# Single-node servers on random ports with isolated temp dirs — meets the
# admission criteria unchanged.
#
# On-demand migration GA (backlog#2163 ODM-16): three named cases join the
# lane, one per user-visible contract of the feature — a GET miss that pulls
# the object and persists it locally, a HEAD miss that answers from the source
# and stores nothing, and the admin config/status pair that must redact the
# source secret. Each spawns one single-node rustfs server plus the in-process
# fake S3 source (`fake_s3_target`, already in the first clause), so they meet
# the admission criteria unchanged; measured at 15.8 s / 15.8 s / 15.9 s, which
# is entirely the shared server startup and overlaps the lane's other tests.
# The rest of `on_demand_migration::{get_basic,interaction,backfill,
# harness_self}_test` stays in e2e-full and the fault / concurrency /
# real-source modules stay in e2e-nightly; this is an allowlist, not a module
# clause, so a new ODM test never lands here silently.
#
# Scanner authoritative usage publication (backlog#2213): data_usage_test is
# the PR-lane e2e coverage for scanner usage snapshots consumed by quota and
# admin surfaces. It uses the same single-node, random-port, isolated-temp-dir
# fixture as the existing smoke modules.
[profile.e2e-smoke]
default-filter = """
package(e2e_test) & (
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
| test(/^reliant::lifecycle::/)
| test(/^reliant::tiering::/)
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
)
"""
fail-fast = false
@@ -402,6 +466,10 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
# until it is explicitly promoted to the fast PR subset — no replication test
# is ever silently left out of CI.
#
# replication_target_matrix_test (the outbound target matrix: every object
# shape against every remote-target failure mode the fake target models) runs
# here in full; its expectation table pins known-red cells to open issues.
#
# #[serial] does NOT serialize under nextest (process-per-test; see the file
# header). These tests need no cross-test serialization: each spawns its own
# server(s) on random ports with isolated temp dirs, so they are parallel-safe
@@ -419,7 +487,7 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
[profile.e2e-repl-nightly]
default-filter = """
package(e2e_test)
& test(/^replication_extension_test::/)
& (test(/^replication_extension_test::/) | test(/^replication_target_matrix_test::/))
& !test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
"""
fail-fast = false
@@ -432,15 +500,29 @@ path = "junit.xml"
# ---------------------------------------------------------------------------
# e2e-nightly profile — destructive multi-process cluster fault domains
# ---------------------------------------------------------------------------
# These seven modules are deliberately outside e2e-full's merge budget. Each
# These eight modules are deliberately outside e2e-full's merge budget. Each
# starts a real multi-process or multi-disk topology and exercises node/disk
# loss, quorum, cleanup, notification fan-in, or admin-timeout behavior. The
# consolidated nightly workflow runs them serially to avoid resource
# starvation; failures are never retried.
#
# heal_erasure_disk_rebuild_test also runs in e2e-full so core heal rebuild
# regressions are caught no later than the merge/main lane. It remains here for
# nightly serial coverage with the other cluster fault domains.
#
# On-demand migration (backlog#2158 ODM-11) joins by the second clause: the
# fault matrix waits out the 30 s circuit-breaker window, the concurrency
# matrix drives 100-deep bursts, and the real-source cases start a second
# (loop guard: a third) RustFS process. They are too slow or too heavy for
# the merge budget; `on_demand_migration::{get_basic,interaction}_test` stay
# in e2e-full, which excludes exactly these three modules.
[profile.e2e-nightly]
default-filter = """
package(e2e_test)
& test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& (
test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
| test(/^on_demand_migration::(concurrency_test|fault_test|real_source_test)::/)
)
"""
fail-fast = false
@@ -451,6 +533,55 @@ path = "junit.xml"
filter = 'package(e2e_test)'
test-group = 'e2e-cluster-nightly'
# ---------------------------------------------------------------------------
# e2e-distributed profile — 4-node 4-disk Actions suite
# ---------------------------------------------------------------------------
# Storage-sensitive PR / nightly / dispatch lane owned by
# .github/workflows/e2e-distributed.yml.
# Each case starts four rustfs processes (and for site replication, two
# clusters). Upgrade cases also require RUSTFS_UPGRADE_SOURCE_BINARY.
# Serialized via e2e-cluster-nightly with no retries.
[profile.e2e-distributed]
default-filter = 'package(e2e_test) & test(/^distributed::/)'
fail-fast = false
# Decommission / rebalance cases poll for up to 180s with little stdout.
slow-timeout = { period = "120s", terminate-after = 6 }
[profile.e2e-distributed.junit]
path = "junit.xml"
[[profile.e2e-distributed.overrides]]
filter = 'package(e2e_test)'
test-group = 'e2e-cluster-nightly'
# ---------------------------------------------------------------------------
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
# ---------------------------------------------------------------------------
# backlog#2167. Report-only, scheduled, never a required check; wired by
# .github/workflows/on-demand-migration-interop.yml.
#
# The four cases in `on_demand_migration::interop_test` take their source from
# the environment (`RUSTFS_ODM_INTEROP_*`, documented on the constants in
# `crates/e2e_test/src/on_demand_migration/common.rs`), so the same bodies run
# against the in-process fake source locally and against a MinIO container or a
# real cloud provider in the lane. The cloud jobs narrow this profile with their
# own `-E` filter to the three-case minimum (GET miss, HEAD miss, merged list
# pagination) and pass `--no-tests=fail` so a rename cannot silently select
# nothing; the MinIO job runs the whole profile, backfill included.
#
# These cases are deliberately absent from every other lane: without an
# interop source they only re-prove what `get_basic_test` and
# `list_through_test` already cover in e2e-smoke and e2e-full. The committed
# selection digest is the guard against a rename dropping one of them.
[profile.e2e-odm-interop]
default-filter = 'package(e2e_test) & test(/^on_demand_migration::interop_test::/)'
fail-fast = false
[profile.e2e-odm-interop.junit]
# Emitted to target/nextest/e2e-odm-interop/junit.xml; the lane uploads it and
# reconciles it against the per-case JSON report entries.
path = "junit.xml"
# ---------------------------------------------------------------------------
# e2e-protocols profile — serial protocol lane
# ---------------------------------------------------------------------------
@@ -471,16 +602,27 @@ path = "junit.xml"
# quota, checksum, encryption,
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
# skips. Budget <= 45 min; authority for the suite count is `cargo nextest list
# --profile e2e-full` (see docs/testing/e2e-suite-inventory.md).
# --profile e2e-full -p e2e_test` (platform-dependent; see docs/testing/README.md).
#
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
# * protocols:: — FTPS/SFTP/WebDAV, run from the dedicated protocol profile
# with one worker because the suite owns fixed ports.
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
# * cluster suites that spin up a RustFSTestClusterEnvironment
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
# object_lambda) — too heavy for the merge budget; they run in the
# e2e-nightly serial cluster-fault lane.
# namespace_lock_quorum, admin_timeout_regression, object_lambda) — too
# heavy for the merge budget; they run in the e2e-nightly serial
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
# excluded here because backlog#2213 promotes core heal rebuild coverage to
# this merge/main lane while retaining nightly coverage.
# * distributed:: — 4-node 4-disk Actions suite (S3, lock, versioning,
# replication, quota, observability, expand/decommission/rebalance, site
# replication, chaos, upgrade history/IAM). Owns [profile.e2e-distributed] and
# .github/workflows/e2e-distributed.yml.
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
# cases, which are meaningless without a source: they run in the dedicated
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
# MinIO container or a real cloud provider. Excluding them here also keeps
# this profile's committed selection digest stable.
# * replication_extension_test — repl-1 already splits it into the PR
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
# it for those, so e2e-full does not double-run it.
@@ -496,8 +638,11 @@ path = "junit.xml"
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|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
& !test(/^distributed::/)
& !test(/^replication_extension_test::/)
& !test(/^replication_target_matrix_test::/)
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
"""
fail-fast = false
@@ -0,0 +1,100 @@
name: On-demand migration interop report
description: >-
Merge the per-case JSON entries an on-demand-migration interop run wrote with
the nextest JUnit result into one provider report, and summarise it.
inputs:
provider:
description: Provider the run addressed (minio, aws, r2, gcs).
required: true
cases-dir:
description: Directory the cases wrote their JSON entries into.
required: true
junit:
description: nextest JUnit XML of the run.
required: true
output:
description: Path of the merged JSON report to write.
required: true
runs:
using: composite
steps:
# The JUnit file is authoritative for which cases ran and how they ended:
# a case that fails or panics never reaches its own report entry, so
# trusting the entries alone would silently shorten the report exactly when
# something went wrong. The entries only add what JUnit cannot know — the
# source request accounting and the bucket's migration counters.
- name: Merge interop case reports
shell: bash
env:
ODM_REPORT_PROVIDER: ${{ inputs.provider }}
ODM_REPORT_CASES_DIR: ${{ inputs.cases-dir }}
ODM_REPORT_JUNIT: ${{ inputs.junit }}
ODM_REPORT_OUTPUT: ${{ inputs.output }}
run: |
python3 - <<'PY'
import json
import os
import pathlib
import xml.etree.ElementTree as ElementTree
provider = os.environ["ODM_REPORT_PROVIDER"]
cases_dir = pathlib.Path(os.environ["ODM_REPORT_CASES_DIR"])
junit = pathlib.Path(os.environ["ODM_REPORT_JUNIT"])
output = pathlib.Path(os.environ["ODM_REPORT_OUTPUT"])
entries = {}
if cases_dir.is_dir():
for path in sorted(cases_dir.glob("*.json")):
entry = json.loads(path.read_text())
entries[entry["case"]] = entry
cases = []
for case in ElementTree.parse(junit).getroot().iter("testcase"):
name = case.get("name", "")
failed = [child for child in case if child.tag in ("failure", "error")]
skipped = [child for child in case if child.tag == "skipped"]
outcome = "failed" if failed else "skipped" if skipped else "passed"
entry = entries.get(name.rsplit("::", 1)[-1], {})
cases.append(
{
"name": name,
"outcome": outcome,
"junit_duration_ms": round(float(case.get("time", "0")) * 1000),
"case_duration_ms": entry.get("duration_ms"),
"source_requests": entry.get("source_requests"),
"odm_counters": entry.get("odm_counters"),
}
)
report = {
"provider": provider,
"repository": os.environ.get("GITHUB_REPOSITORY", ""),
"sha": os.environ.get("GITHUB_SHA", ""),
"run_id": os.environ.get("GITHUB_RUN_ID", ""),
"cases": cases,
"totals": {
"cases": len(cases),
"passed": sum(1 for case in cases if case["outcome"] == "passed"),
"failed": sum(1 for case in cases if case["outcome"] == "failed"),
},
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
summary = [f"### On-demand migration interop: `{provider}`", "", "| Case | Outcome | Duration | Source requests |", "|---|---|---|---|"]
for case in cases:
requests = case["source_requests"]
counted = f"{requests['total']} ({requests['counted_by']})" if requests else "not reported"
summary.append(f"| `{case['name']}` | {case['outcome']} | {case['junit_duration_ms']} ms | {counted} |")
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle:
handle.write("\n".join(summary) + "\n\n")
# A passed case with no entry of its own means the harness stopped
# writing one: the report would keep looking complete while silently
# losing its request accounting.
unreported = [case["name"] for case in cases if case["outcome"] == "passed" and case["source_requests"] is None]
if unreported:
raise SystemExit(f"passed cases wrote no interop report entry: {', '.join(unreported)}")
PY
+120
View File
@@ -0,0 +1,120 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: Quick Checks
description: Run the shared compile-free RustFS quality checks.
runs:
using: composite
steps:
- name: Install quality tools
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
with:
tool: |
ripgrep@15.2.0
shellcheck@0.11.0
- name: Install actionlint
shell: bash
run: |
actionlint_dir="$(mktemp -d "${RUNNER_TEMP}/actionlint.XXXXXX")"
curl --fail --location --silent --show-error \
--output "$actionlint_dir/actionlint.tar.gz" \
https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $actionlint_dir/actionlint.tar.gz" | sha256sum --check --status
tar -xzf "$actionlint_dir/actionlint.tar.gz" -C "$actionlint_dir" actionlint
rm "$actionlint_dir/actionlint.tar.gz"
echo "$actionlint_dir" >> "$GITHUB_PATH"
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: rustfmt
- name: Check workflow syntax and shell scripts
shell: bash
run: shellcheck --version && actionlint
- name: Check code formatting
shell: bash
run: cargo fmt --all --check
- name: Check unsafe code allowances
shell: bash
run: ./scripts/check_unsafe_code_allowances.sh
- name: Check layered dependencies
shell: bash
run: ./scripts/check_layer_dependencies.sh
- name: Check architecture migration rules
shell: bash
run: ./scripts/check_architecture_migration_rules.sh
- name: Check logging guardrails
shell: bash
run: ./scripts/check_logging_guardrails.sh
- name: Check error other(format!) ratchet
shell: bash
run: ./scripts/check_error_other_format_ratchet.sh
- name: Check tokio io-uring feature guard
shell: bash
run: ./scripts/check_no_tokio_io_uring.sh
- name: Check extension schema boundaries
shell: bash
run: ./scripts/check_extension_schema_boundaries.sh
- name: Check body-cache whitelist guard
shell: bash
run: ./scripts/check_body_cache_whitelist.sh
- name: Check s3s footprint ratchet
shell: bash
run: ./scripts/check_s3s_footprint.sh
- name: Check cryptographic capability wording
shell: bash
run: ./scripts/check_fips_wording.sh
- name: Check no embedded secret material
shell: bash
run: ./scripts/check_embedded_secrets.sh
- name: Run script contract tests
shell: bash
run: make script-tests
- name: Check test wiring
shell: bash
run: |
python3 ./scripts/check_test_wiring.py --self-test
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
python3 ./scripts/test_security_workflow.py
python3 ./scripts/test_nightly_candidate.py
python3 ./scripts/check_test_wiring.py
- name: Check no planning docs committed
shell: bash
run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
shell: bash
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition
shell: bash
run: ./scripts/check_uring_lane_lib_only.sh
+5 -5
View File
@@ -10,16 +10,16 @@ Use N/A when there is no related issue.
## Summary of Changes
<!--
Briefly explain what changed and why reviewers should accept it.
Focus on behavior, compatibility, and review-relevant context.
Describe the concrete problem and resulting behavior. For a behavior change, name the input or state that triggers it and the expected outcome. Explain any new dependency or abstraction that the change needs.
-->
## Verification
<!--
List the commands or checks you ran, for example:
- `make pre-commit`
Give 13 concrete pieces of evidence for the changed behavior: the test or command, its observed result, and the regression it catches. For a bug fix, record a failing-before/passing-after check or explain why it was unavailable.
Use N/A only when verification is not applicable.
Identify the tested commit and any local changes. When testing a prebuilt binary or external service, include its source/version and artifact identity; a successful run against a different build is not evidence for this change.
List relevant checks not run and the remaining risk. Use the validation tier in AGENTS.md; do not run broader checks solely to fill this section. For documentation-only changes, list the applicable documentation checks. Use N/A only when verification is not applicable.
-->
## Impact
+5
View File
@@ -4,6 +4,11 @@
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
{
"workflow": ".github/workflows/e2e-distributed.yml",
"max_age_hours": 36,
"never_ran_grace_until": "2026-09-18T00:00:00Z"
},
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
+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
@@ -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
@@ -1065,7 +1065,7 @@ jobs:
while IFS= read -r preview_tag; do
[[ -n "$preview_tag" ]] || continue
echo "🧹 Deleting preview release $preview_tag (tag kept)"
gh release delete "$preview_tag" --yes
gh release delete "$preview_tag" --repo "${GITHUB_REPOSITORY}" --yes
DELETED=$((DELETED + 1))
done < <(
jq -r --arg tag "$TAG" '
+6 -88
View File
@@ -12,24 +12,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# Companion to ci.yml for required status checks.
#
# ci.yml skips docs-only pull requests via paths-ignore, but the branch ruleset
# requires a check named "Test and Lint" — without this workflow a docs-only PR
# would wait on it forever. This workflow triggers on exactly the paths ci.yml
# ignores and reports success under the same job name. Mixed PRs trigger both
# workflows and the real check still gates: a required check with any failing
# run blocks the merge.
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
#
# "Quick Checks" is mirrored here ahead of the ruleset change that will make it
# required too (rustfs/backlog#1599). Until that change lands this job is
# inert; mirroring it first is what lets the ruleset change happen without
# stranding docs-only PRs on a check nobody reports.
#
# Keep the paths list below in sync with the pull_request paths-ignore list
# in ci.yml, and keep the quick-checks steps below byte-identical to the
# quick-checks job in ci.yml.
# Reports the existing required checks for paths excluded by ci.yml.
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
# action to keep validation coverage aligned. Keep this paths list in sync with
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
name: Continuous Integration (docs only)
@@ -59,19 +45,6 @@ permissions:
contents: read
jobs:
# Deliberately NOT a bare `echo`. Once "Quick Checks" becomes a required
# check, ci.yml gates every expensive job behind it, so a mixed PR reports
# two check runs with this name: the real one (45-51s) and this companion.
# GitHub has no written contract for how it picks between same-named
# required check runs ("latest wins" vs "any failure blocks"), so instead of
# relying on ordering we make both runs execute the same commands against
# the same merge ref — their conclusions are then necessarily identical and
# the choice does not matter. Keep these steps byte-identical to the
# quick-checks job in ci.yml (a guard script that asserts this, and the paths
# sync below, is tracked in rustfs/backlog#1603).
#
# For a genuinely docs-only PR this adds no strictness (no code changed, so
# fmt and the guards always pass) and costs ~50s of ubuntu-latest.
quick-checks:
name: Quick Checks
runs-on: ubuntu-latest
@@ -82,63 +55,8 @@ jobs:
with:
persist-credentials: false
- name: Install ripgrep
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
with:
tool: ripgrep@15.2.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: rustfmt
- name: Check code formatting
run: cargo fmt --all --check
- name: Check unsafe code allowances
run: ./scripts/check_unsafe_code_allowances.sh
- name: Check layered dependencies
run: ./scripts/check_layer_dependencies.sh
- name: Check architecture migration rules
run: ./scripts/check_architecture_migration_rules.sh
- name: Check logging guardrails
run: ./scripts/check_logging_guardrails.sh
- name: Check tokio io-uring feature guard
run: ./scripts/check_no_tokio_io_uring.sh
- name: Check extension schema boundaries
run: ./scripts/check_extension_schema_boundaries.sh
- name: Check body-cache whitelist guard
run: ./scripts/check_body_cache_whitelist.sh
- name: Check s3s footprint ratchet
run: ./scripts/check_s3s_footprint.sh
- name: Check cryptographic capability wording
run: ./scripts/check_fips_wording.sh
- name: Check no embedded secret material
run: ./scripts/check_embedded_secrets.sh
- name: Check test wiring
run: |
python3 ./scripts/check_test_wiring.py --self-test
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
python3 ./scripts/check_test_wiring.py
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition
run: ./scripts/check_uring_lane_lib_only.sh
- name: Run shared quick checks
uses: ./.github/actions/quick-checks
test-and-lint:
name: Test and Lint
+10 -66
View File
@@ -100,12 +100,7 @@ jobs:
- name: Typos check with custom config file
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
# Fast, compile-free checks that fail early so contributors get feedback in
# ~1 minute instead of waiting for the full test job.
#
# These steps are mirrored byte-for-byte in ci-docs-only.yml so that a mixed
# PR, which reports two check runs named "Quick Checks", cannot get one red
# and one green. Edit both jobs together.
# Fail early with compile-free checks shared with docs-only CI.
quick-checks:
name: Quick Checks
if: github.event_name != 'pull_request' || github.event.action != 'closed'
@@ -117,66 +112,8 @@ jobs:
with:
persist-credentials: false
- name: Install ripgrep
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
with:
tool: ripgrep@15.2.0
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
components: rustfmt
- name: Check code formatting
run: cargo fmt --all --check
- name: Check unsafe code allowances
run: ./scripts/check_unsafe_code_allowances.sh
- name: Check layered dependencies
run: ./scripts/check_layer_dependencies.sh
- name: Check architecture migration rules
run: ./scripts/check_architecture_migration_rules.sh
- name: Check logging guardrails
run: ./scripts/check_logging_guardrails.sh
- name: Check error other(format!) ratchet
run: ./scripts/check_error_other_format_ratchet.sh
- name: Check tokio io-uring feature guard
run: ./scripts/check_no_tokio_io_uring.sh
- name: Check extension schema boundaries
run: ./scripts/check_extension_schema_boundaries.sh
- name: Check body-cache whitelist guard
run: ./scripts/check_body_cache_whitelist.sh
- name: Check s3s footprint ratchet
run: ./scripts/check_s3s_footprint.sh
- name: Check cryptographic capability wording
run: ./scripts/check_fips_wording.sh
- name: Check no embedded secret material
run: ./scripts/check_embedded_secrets.sh
- name: Check test wiring
run: |
python3 ./scripts/check_test_wiring.py --self-test
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
python3 ./scripts/check_test_wiring.py
- name: Check no planning docs committed
run: ./scripts/check_no_planning_docs.sh
- name: Check CI paths stay in sync
run: ./scripts/check_ci_paths_sync.sh
- name: Check io_uring lane --lib precondition
run: ./scripts/check_uring_lane_lib_only.sh
- name: Run shared quick checks
uses: ./.github/actions/quick-checks
test-and-lint:
name: Test and Lint
@@ -269,6 +206,7 @@ jobs:
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
run: |
mkdir -p artifacts/test-and-lint
rm -f target/nextest/ci/junit.xml
./scripts/ci/resource_sampler.sh start nextest
trap './scripts/ci/resource_sampler.sh stop' EXIT
set +e
@@ -277,6 +215,12 @@ jobs:
--status-level all --final-status-level all \
2>&1 | tee artifacts/test-and-lint/nextest.log
status=${PIPESTATUS[0]}
if [[ "${status}" -eq 0 ]]; then
cargo nextest list --profile ci --all --exclude e2e_test --message-format json \
> artifacts/test-and-lint/core-test-listing.json \
&& python3 scripts/check_test_wiring.py --check-core artifacts/test-and-lint/core-test-listing.json \
&& test -s target/nextest/ci/junit.xml || status=$?
fi
{
echo "command=cargo nextest run --profile ci --all --exclude e2e_test"
echo "exit_status=${status}"
+199
View File
@@ -0,0 +1,199 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# 4-node 4-disk distributed e2e lane.
#
# Each selected test starts a real localhost cluster via
# `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
# nightly runs, and manual dispatches all execute the same fail-closed suite.
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
name: e2e-distributed
on:
pull_request:
paths:
- "Cargo.lock"
- "Cargo.toml"
- ".config/nextest.toml"
- ".github/workflows/e2e-distributed.yml"
- "crates/audit/**"
- "crates/common/**"
- "crates/config/**"
- "crates/e2e_test/**"
- "crates/ecstore/**"
- "crates/filemeta/**"
- "crates/heal/**"
- "crates/iam/**"
- "crates/lock/**"
- "crates/madmin/**"
- "crates/notify/**"
- "crates/replication/**"
- "crates/s3-client/**"
- "crates/s3-ops/**"
- "crates/s3-types/**"
- "crates/scanner/**"
- "crates/storage-api/**"
- "crates/utils/**"
- "rustfs/**"
workflow_dispatch:
inputs:
filter:
description: "Optional nextest -E filter (default: the whole e2e-distributed profile)"
required: false
default: ""
schedule:
# 05:53 UTC nightly — clear of e2e-nightly (04:29) and ODM interop (05:23).
- cron: "53 5 * * *"
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' }}
jobs:
distributed:
name: Distributed 4-node 4-disk e2e
runs-on: sm-standard-4
timeout-minutes: 180
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NO_PROXY: 127.0.0.1,localhost
HTTP_PROXY: ""
HTTPS_PROXY: ""
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
rust-version: stable
cache-shared-key: ci-e2e-distributed
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: 'false'
- name: Prepare isolated filesystems for pool movement
run: |
set -euo pipefail
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
mkdir -p "${mount_base}"
roots=()
for pool in 0 1 2 3; do
image="${mount_base}/pool-${pool}.img"
mountpoint="${mount_base}/pool-${pool}"
truncate -s 1G "${image}"
mkfs.ext4 -q -F "${image}"
mkdir -p "${mountpoint}"
sudo mount -o loop,nosuid,nodev "${image}" "${mountpoint}"
sudo chmod 1777 "${mountpoint}"
roots+=("${mountpoint}")
done
printf -v joined_roots '%s:' "${roots[@]}"
echo "RUSTFS_E2E_POOL_ROOTS=${joined_roots%:}" >> "${GITHUB_ENV}"
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[0]}"
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[1]}"
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[2]}"
findmnt --noheadings --output TARGET,SOURCE,FSTYPE --target "${roots[3]}"
- name: Download pinned previous release
env:
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
run: |
set -euo pipefail
mkdir -p "$SOURCE_DIR"
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
curl --fail --location --retry 3 --output "$archive" \
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
unzip -q "$archive" -d "$SOURCE_DIR"
chmod +x "$SOURCE_DIR/rustfs"
test -x "$SOURCE_DIR/rustfs"
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
- name: Build rustfs binary
run: |
cargo build -p rustfs --bins
: > target/debug/rustfs.features
- name: Verify distributed e2e membership
env:
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-distributed-list.json
run: |
cargo nextest list --profile e2e-distributed -p e2e_test --message-format json > "${NEXTEST_LISTING}"
python3 ./scripts/check_test_wiring.py --check-profile e2e-distributed "${NEXTEST_LISTING}"
- name: Run distributed 4-node e2e suite
env:
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
FILTER: ${{ inputs.filter }}
run: |
set -euo pipefail
if [ -n "${FILTER}" ]; then
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
else
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
fi
- name: Upload distributed e2e diagnostics
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: e2e-distributed-${{ github.run_number }}
path: |
target/nextest/e2e-distributed/junit.xml
${{ runner.temp }}/rustfs-e2e-distributed-list.json
${{ runner.temp }}/rustfs-e2e-distributed-logs/
retention-days: 7
if-no-files-found: warn
- name: Unmount isolated pool filesystems
if: always()
run: |
set -euo pipefail
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
for pool in 0 1 2 3; do
mountpoint="${mount_base}/pool-${pool}"
if mountpoint --quiet "${mountpoint}"; then
sudo umount "${mountpoint}"
fi
done
alert-on-failure:
name: Alert on scheduled failure
needs: [distributed]
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+40 -9
View File
@@ -19,7 +19,9 @@ on:
paths:
- ".github/workflows/e2e-upgrade.yml"
- "crates/e2e_test/src/common.rs"
- "crates/e2e_test/src/fake_s3_target/**"
- "crates/e2e_test/src/lib.rs"
- "crates/e2e_test/src/replication_extension_test.rs"
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
- "crates/ecstore/**"
- "crates/filemeta/**"
@@ -44,13 +46,42 @@ concurrency:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
jobs:
direct-upgrade:
name: Direct upgrade from rc.2
upgrade:
name: ${{ matrix.name }}
strategy:
fail-fast: false
matrix:
include:
# The two `_from_rc2_` tests keep their names: they assert
# release-independent object contracts and pass unchanged against the
# newer pinned source, so renaming them would only churn history and
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
# single source of truth for which release they actually run against.
- name: Direct upgrade from the previous release
cache_key: e2e-direct-upgrade
test: direct_upgrade_from_rc2_preserves_object_contracts
artifact: direct-upgrade
- name: Mixed-version rolling upgrade from the previous release
cache_key: e2e-mixed-version-upgrade
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
artifact: mixed-version-upgrade
- name: Bucket configuration survives the upgrade
cache_key: e2e-bucket-config-upgrade
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
artifact: bucket-config-upgrade
- name: Rollback reads current bucket metadata
cache_key: e2e-bucket-config-rollback
test: rollback_to_previous_release_reads_current_bucket_metadata
artifact: bucket-config-rollback
- name: ODM configuration recovery after rc.5 rollback
cache_key: e2e-odm-config-rollback
test: rc5_rollback_requires_restoring_odm_configuration
artifact: odm-config-rollback
runs-on: ubuntu-latest
timeout-minutes: 60
env:
@@ -64,7 +95,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 +120,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
+51 -8
View File
@@ -166,8 +166,9 @@ jobs:
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
# Skipped when the R2 secrets are not configured (artifact-only mode).
- name: Upload DEB to Cloudflare R2
if: env.R2_ACCESS_KEY_ID != ''
id: publish
env:
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
@@ -182,28 +183,70 @@ jobs:
exit 0
fi
if ! command -v aws >/dev/null 2>&1; then
sudo apt-get update && sudo apt-get install -y -qq awscli
fi
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="auto"
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
SOURCE_SHA="$(git rev-parse HEAD)"
if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then
echo "Checkout SHA does not match the nightly build run" >&2
exit 1
fi
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
# Old AWS CLI models lack conditional PutObject support. Never fall
# back to an overwriting upload for a candidate.
AWS_CLI=aws
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
sudo apt-get update
sudo apt-get install -y -qq python3-venv
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/nightly-awscli.XXXXXX")"
trap 'rm -rf "${AWS_CLI_DIR}"' EXIT
python3 -m venv "${AWS_CLI_DIR}"
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
fi
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
"${AWS_CLI}" --version
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${CANDIDATE_KEY}" \
--body "${DEB_FILE}" --if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
PUBLISHED_SHA256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${CANDIDATE_URL}" | sha256sum | cut -d ' ' -f 1)"
if [[ "${PUBLISHED_SHA256}" != "${DEB_SHA256}" ]]; then
echo "Published candidate checksum does not match the built package" >&2
exit 1
fi
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
# Stable "latest" alias so tests can fetch the newest nightly
# without knowing today's date.
echo "📤 Uploading latest alias"
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
--endpoint-url "$R2_ENDPOINT" --only-show-errors
echo "✅ R2 upload complete"
CANDIDATE_FILE="${RUNNER_TEMP}/nightly-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
jq -n --arg source_sha "${SOURCE_SHA}" \
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
--arg package_url "${CANDIDATE_URL}" --arg package_sha256 "${DEB_SHA256}" \
'{schema: 1, source_sha: $source_sha, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' \
> "${CANDIDATE_FILE}"
echo "candidate_file=${CANDIDATE_FILE}" >> "${GITHUB_OUTPUT}"
- name: Upload nightly candidate manifest
if: ${{ steps.publish.outputs.candidate_file != '' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: nightly-candidate-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.candidate_file }}
if-no-files-found: error
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
#
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
@@ -0,0 +1,315 @@
# Copyright 2024 RustFS Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# On-demand migration provider interop (rustfs/backlog#2167, ODM-20).
#
# The in-process fake source that the merge-gate ODM suite runs against covers
# the protocol semantics, but real implementations differ in path-style vs
# virtual-host addressing, region handling, ETag shape, list pagination and
# rate limiting. This lane runs the same case bodies
# (crates/e2e_test/src/on_demand_migration/interop_test.rs) against real
# sources; the source is injected through RUSTFS_ODM_INTEROP_* environment
# variables, so nothing about the cases is duplicated per provider.
#
# Report-only and scheduled. It is never a required check and must not be
# promoted to one: it depends on third-party endpoints and on repository
# secrets that a fork does not have.
#
# Jobs:
# * minio-source runs the whole e2e-odm-interop profile — read-through,
# HEAD passthrough, merged list pagination and a backfill — against a
# pinned MinIO container. The backfill is sized at 5,000 objects here: the
# fake source retains at most 4,096 object versions and 4,096 journal
# entries, so the merge-gate backfill coverage cannot go past that, and a
# real source is where a production-shaped batch belongs.
# * cloud-source runs the three-case minimum (GET miss, HEAD miss, merged
# list pagination) against AWS S3, Cloudflare R2 and the GCS XML
# interoperability API. Each provider is skipped with a summary note when
# its ODM_INTEROP_* repository secrets are absent, which is the normal
# state on a fork and in any clone of this repository.
#
# Every job uploads one JSON report per provider naming the cases, their
# timings and the source request accounting.
name: on-demand-migration-interop
on:
workflow_dispatch:
schedule:
# Nightly at 05:23 UTC, offset from the other nightly lanes.
- cron: "23 5 * * *"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
# The three cases a cloud provider is asked for. Named individually rather
# than by module so adding a fourth case does not silently start billing a
# cloud account for it.
CLOUD_CASE_FILTER: >-
package(e2e_test) & test(/^on_demand_migration::interop_test::(interop_get_miss_pulls_from_the_source_and_serves_locally|interop_head_miss_answers_from_the_source_without_persisting|interop_list_through_pages_the_source_namespace)$/)
CLOUD_CASE_COUNT: "3"
jobs:
minio-source:
name: MinIO source (read-through, list-through, backfill)
# Skip on forks: needs this repository's runners and is not a contributor
# gate.
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
timeout-minutes: 90
env:
NO_PROXY: 127.0.0.1,localhost
# Fixed credentials of the container this job starts and throws away;
# not a secret and deliberately not read from one, so the lane runs
# unattended in any clone that enables it.
MINIO_ROOT_USER: rustfsodminterop
MINIO_ROOT_PASSWORD: rustfsodminteropsecret
RUSTFS_ODM_INTEROP_PROVIDER: minio
RUSTFS_ODM_INTEROP_ENDPOINT: http://127.0.0.1:9100
RUSTFS_ODM_INTEROP_REGION: auto
RUSTFS_ODM_INTEROP_BUCKET: odm-interop-source
RUSTFS_ODM_INTEROP_PATH_STYLE: path
RUSTFS_ODM_INTEROP_ACCESS_KEY: rustfsodminterop
RUSTFS_ODM_INTEROP_SECRET_KEY: rustfsodminteropsecret
RUSTFS_ODM_INTEROP_BACKFILL_OBJECTS: "5000"
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/minio/cases
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/minio/selection.json
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Rust environment
uses: ./.github/actions/setup
with:
cache-shared-key: ci-odm-interop
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
install-build-packaging-tools: 'false'
- name: Start MinIO source
run: |
set -euo pipefail
mkdir -p artifacts/odm-interop/minio
docker run -d --name rustfs-odm-interop-minio \
-e "MINIO_ROOT_USER=${MINIO_ROOT_USER}" \
-e "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" \
-p 9100:9000 \
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
for _ in $(seq 1 120); do
curl -fsS http://127.0.0.1:9100/minio/health/live >/dev/null 2>&1 && break
sleep 1
done
curl -fsS http://127.0.0.1:9100/minio/health/live
# The harness never creates a bucket, so that pointing it at a cloud
# account cannot create one there either. The source bucket for the
# container is created here instead.
- name: Create the MinIO source bucket
env:
AWS_ACCESS_KEY_ID: ${{ env.MINIO_ROOT_USER }}
AWS_SECRET_ACCESS_KEY: ${{ env.MINIO_ROOT_PASSWORD }}
AWS_DEFAULT_REGION: us-east-1
run: |
aws --endpoint-url "${RUSTFS_ODM_INTEROP_ENDPOINT}" \
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
- name: Build the RustFS binary under test
run: cargo build --locked -p rustfs --bins
# The lane selects tests by module, so a rename would quietly shrink it.
# The committed digest in .config/e2e-odm-interop-selection.txt fails
# closed on that.
- name: Verify interop lane membership
run: |
cargo nextest list --profile e2e-odm-interop -p e2e_test --message-format json > "${NEXTEST_LISTING}"
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
- name: Run the interop cases against MinIO
run: cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
- name: Build the MinIO interop report
if: always()
uses: ./.github/actions/odm-interop-report
with:
provider: minio
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
junit: target/nextest/e2e-odm-interop/junit.xml
output: artifacts/odm-interop/minio/report.json
- name: Collect MinIO logs
if: always()
run: |
docker logs --tail 500 rustfs-odm-interop-minio \
> artifacts/odm-interop/minio/minio.log 2>&1 || true
- name: Stop MinIO source
if: always()
run: docker rm -f rustfs-odm-interop-minio >/dev/null 2>&1 || true
- name: Upload the MinIO interop report
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: odm-interop-minio-${{ github.run_number }}-${{ github.run_attempt }}
path: |
artifacts/odm-interop/minio
target/nextest/e2e-odm-interop/junit.xml
retention-days: 14
# Unlike a production migration source, which needs read access only, the
# credentials here also seed the objects each case reads back, so they need
# write and delete on the interop bucket. Every run seeds under
# `odm-interop/<case>/<uuid>/` and deletes what it seeded when the case
# passes; give the bucket an expiration lifecycle rule so the prefixes a
# failing case leaves behind cannot accumulate.
cloud-source:
name: ${{ matrix.provider }} source (three-case minimum)
if: github.repository == 'rustfs/rustfs'
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- provider: aws
secret_prefix: AWS
path_style: virtual
- provider: r2
secret_prefix: R2
path_style: virtual
- provider: gcs
secret_prefix: GCS_HMAC
path_style: virtual
env:
RUSTFS_ODM_INTEROP_PROVIDER: ${{ matrix.provider }}
RUSTFS_ODM_INTEROP_PATH_STYLE: ${{ matrix.path_style }}
RUSTFS_ODM_INTEROP_ENDPOINT: ${{ secrets[format('ODM_INTEROP_{0}_ENDPOINT', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_REGION: ${{ secrets[format('ODM_INTEROP_{0}_REGION', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_BUCKET: ${{ secrets[format('ODM_INTEROP_{0}_BUCKET', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_ACCESS_KEY: ${{ secrets[format('ODM_INTEROP_{0}_ACCESS_KEY_ID', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_SECRET_KEY: ${{ secrets[format('ODM_INTEROP_{0}_SECRET_ACCESS_KEY', matrix.secret_prefix)] }}
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/cases
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/selection.json
steps:
# Absent secrets are the normal state, not a failure: the lane reports
# which providers it could reach and skips the rest. An empty value is
# what an unset repository secret expands to, so it is checked, not the
# secret's existence.
- name: Check for provider credentials
id: credentials
run: |
set -euo pipefail
if [ -z "${RUSTFS_ODM_INTEROP_ENDPOINT}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_REGION}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_BUCKET}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_ACCESS_KEY}" ] \
|| [ -z "${RUSTFS_ODM_INTEROP_SECRET_KEY}" ]; then
echo "present=false" >> "$GITHUB_OUTPUT"
{
echo "### On-demand migration interop: \`${{ matrix.provider }}\`"
echo
echo "Skipped: the \`ODM_INTEROP_${{ matrix.secret_prefix }}_*\` repository secrets"
echo "(\`_ENDPOINT\`, \`_REGION\`, \`_BUCKET\`, \`_ACCESS_KEY_ID\`, \`_SECRET_ACCESS_KEY\`)"
echo "are not configured, so no real \`${{ matrix.provider }}\` source was reached."
echo
} >> "$GITHUB_STEP_SUMMARY"
else
echo "present=true" >> "$GITHUB_OUTPUT"
fi
- name: Checkout repository
if: steps.credentials.outputs.present == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Rust environment
if: steps.credentials.outputs.present == 'true'
uses: ./.github/actions/setup
with:
cache-shared-key: ci-odm-interop
cache-save-if: 'false'
install-build-packaging-tools: 'false'
- name: Build the RustFS binary under test
if: steps.credentials.outputs.present == 'true'
run: cargo build --locked -p rustfs --bins
# A filterset that matches nothing is valid, so the count is asserted
# rather than inferred from a green run.
- name: Verify the three-case minimum still selects three cases
if: steps.credentials.outputs.present == 'true'
run: |
set -euo pipefail
mkdir -p "$(dirname "${NEXTEST_LISTING}")"
cargo nextest list --profile e2e-odm-interop -p e2e_test \
-E "${CLOUD_CASE_FILTER}" --message-format json > "${NEXTEST_LISTING}"
selected="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(sum(1 for suite in d.get("rust-suites", {}).values() for test in suite.get("testcases", {}).values() if test.get("filter-match", {}).get("status") == "matches"))' "${NEXTEST_LISTING}")"
echo "cloud interop cases selected: ${selected}"
if [ "${selected}" != "${CLOUD_CASE_COUNT}" ]; then
echo "::error::CLOUD_CASE_FILTER selected ${selected} cases, expected ${CLOUD_CASE_COUNT}; the interop cases were renamed or moved. Context: rustfs/backlog#2167."
exit 1
fi
- name: Run the three-case minimum
if: steps.credentials.outputs.present == 'true'
run: |
cargo nextest run --profile e2e-odm-interop -p e2e_test \
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
- name: Build the ${{ matrix.provider }} interop report
if: always() && steps.credentials.outputs.present == 'true'
uses: ./.github/actions/odm-interop-report
with:
provider: ${{ matrix.provider }}
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
junit: target/nextest/e2e-odm-interop/junit.xml
output: artifacts/odm-interop/${{ matrix.provider }}/report.json
- name: Upload the ${{ matrix.provider }} interop report
if: always() && steps.credentials.outputs.present == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: odm-interop-${{ matrix.provider }}-${{ github.run_number }}-${{ github.run_attempt }}
path: |
artifacts/odm-interop/${{ matrix.provider }}
target/nextest/e2e-odm-interop/junit.xml
retention-days: 14
alert-on-failure:
name: Alert on scheduled failure
needs: [minio-source, cloud-source]
if: >-
always() && github.event_name == 'schedule' &&
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Open or update failure-tracking issue
uses: ./.github/actions/schedule-failure-issue
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
+160 -90
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:
@@ -245,18 +304,16 @@ jobs:
- 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,9 +390,12 @@ 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"
[[ $(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"
@@ -343,16 +403,19 @@ jobs:
- 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 <support@rustfs.com>" \
--description "High-performance distributed object storage" \
@@ -410,13 +475,15 @@ jobs:
LICENSE=/usr/share/doc/rustfs/LICENSE \
README.md=/usr/share/doc/rustfs/README.md
RPM_FILE=$(find . -maxdepth 1 -type f -name 'rustfs-*.rpm' -print | head -1)
RPM_FILE="${RPM_FILE#./}"
if [[ -z "$RPM_FILE" ]]; then
if [[ ! -f "$RPM_FILE" ]]; then
echo "❌ RPM build failed"
exit 1
fi
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"
@@ -438,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
@@ -455,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
@@ -465,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"
@@ -493,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
@@ -552,14 +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"
echo ""
echo "| Item | Value |"
echo "|------|-------|"
echo "| Version | \`${{ needs.resolve.outputs.version }}\` |"
echo "| Build Type | ${{ needs.resolve.outputs.build_type }} |"
echo "| Build Run | #${{ needs.resolve.outputs.build_run_id }} |"
echo "| Package Status | ${{ needs.package.result }} |"
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,61 @@
# 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 -> performance). Each suite attempts the next handoff even
# when its tests 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'
+251 -185
View File
@@ -23,6 +23,11 @@ on:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
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
@@ -50,18 +55,44 @@ jobs:
heal-test:
runs-on: smoke-testing
timeout-minutes: 480
# Manual-only standalone run. Nightly chain already runs heal in
# rustfs-pool-expand-test.yml to avoid duplicate heal executions.
if: ${{ github.event_name == 'workflow_dispatch' }}
# 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 }}
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-heal-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'RUSTFS_WARP_LOG_FILE=%s/warp.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not
# 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: |
@@ -98,7 +129,7 @@ jobs:
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
- name: Preflight checks
run: |
@@ -108,7 +139,7 @@ jobs:
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
- name: Run heal test (write -> outage -> heal -> verify)
id: test
@@ -116,15 +147,12 @@ jobs:
./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 }}" \
--log-file /tmp/rustfs-heal-test.log
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
--log-file "${LOG_FILE}"
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-heal-test.log
REPORT_FILE: /tmp/rustfs-heal-report.md
if: ${{ always() && steps.evidence.outcome == 'success' }}
run: |
set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}'
@@ -133,27 +161,99 @@ jobs:
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
STEPS_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/steps.md"
CASE_RESULT=success
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY' || CASE_RESULT=failure
import re
import sys
log_file, out_file = sys.argv[1], sys.argv[2]
ansi = re.compile(r'\x1b\[[0-9;]*m')
step_re = re.compile(r'^\[HEAL-STEP\]\s+(\d+)\s+(.+?)\s+(PASS|FAIL|SKIP)\s*$')
ver_re = re.compile(r'^\[HEAL-VERSION\]\s+(\S+)(?:\s+\(node\s+(\S+)\))?\s*$')
result_re = re.compile(r'^\[HEAL-RESULT\]\s+(PASS|FAIL)\s+(.*)$')
steps = {}
order = []
status_rank = {'SKIP': 0, 'PASS': 1, 'FAIL': 2}
version = None
version_node = None
verdict = None
verdict_detail = ''
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = step_re.match(line)
if m:
n, desc, status = m.group(1), m.group(2), m.group(3)
if n not in steps:
order.append(n)
if n not in steps or status_rank[status] > status_rank[steps[n][1]]:
steps[n] = (desc, status)
continue
m = ver_re.match(line)
if m:
version, version_node = m.group(1), m.group(2)
continue
m = result_re.match(line)
if m and verdict != 'FAIL':
verdict, verdict_detail = m.group(1), m.group(2)
except FileNotFoundError:
pass
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Step Results\n\n')
if version:
node_note = f' (captured via `rustfs --version` on {version_node})' if version_node else ''
out.write(f'- Version under test: **{version}**{node_note}\n')
if verdict:
out.write(f'- Overall result: **{verdict}** — {verdict_detail}\n')
out.write('\n')
out.write('| Step | Description | Result |\n')
out.write('| --- | --- | --- |\n')
for n in sorted(order, key=int):
desc, status = steps[n]
out.write(f'| {n} | {desc} | {status} |\n')
if not order:
out.write('| - | - | NOT RUN (no step result lines found) |\n')
complete = set(steps) == {str(n) for n in range(1, 8)}
sys.exit(0 if complete and verdict != 'FAIL' and all(status == 'PASS' for _, status in steps.values()) else 1)
PY
RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
RESULT=success
fi
{
echo "# RustFS heal test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}" || true
echo '```'
if [ "${RESULT}" = "success" ]; then
cat "${STEPS_TABLE}"
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}"
echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial step results and suite.log."
fi
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard
if: always()
if: ${{ always() && steps.evidence.outcome == 'success' }}
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
@@ -163,177 +263,94 @@ jobs:
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}")"
# Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:($content|rtrimstr("\n")), 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}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
rm -f "${B64_FILE}"
cat > /tmp/rustfs-functional-index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustFS Functional Test Reports</title>
<style>
:root { --bg:#f4f6fb; --card:#fff; --text:#1f2937; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
h1 { margin: 0 0 8px; font-size: 26px; }
p { margin: 0 0 14px; color: var(--muted); }
.tabs { display: flex; gap: 10px; margin: 14px 0 18px; flex-wrap: wrap; }
button { border: 1px solid var(--line); background: #fff; color: var(--text); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.report-btn { border: 0; background: transparent; padding: 0; color: var(--accent); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.meta { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; }
.kv { margin: 6px 0; color: var(--text); }
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>RustFS Functional Test Reports</h1>
<p>Select a suite and date to view the build version used in that run.</p>
<div class="tabs" id="tabs"></div>
<ul id="list"></ul>
<div class="meta">
<div class="kv"><strong>Date:</strong> <span id="report-date" class="muted">N/A</span></div>
<div class="kv"><strong>RustFS Version:</strong> <span id="report-version" class="muted">N/A</span></div>
<div class="kv"><a id="report-link" href="#" target="_blank" rel="noreferrer">Open report</a></div>
</div>
</div>
</div>
<script>
const suites = [
{ key: 's3', label: 'S3 Compatibility' },
{ key: 'kms', label: 'KMS' },
{ key: 'tier', label: 'Tier' },
{ key: 'heal', label: 'Heal' },
{ key: 'pool', label: 'Pool Expansion' },
{ key: 'security', label: 'Security' },
{ key: 'upgrade', label: 'Upgrade' },
];
const tabs = document.getElementById('tabs');
const list = document.getElementById('list');
const reportDate = document.getElementById('report-date');
const reportVersion = document.getElementById('report-version');
const reportLink = document.getElementById('report-link');
function parseVersion(markdown) {
const m = markdown.match(/^- RustFS Version:\s*(.+)$/m);
return m ? m[1].trim() : 'N/A';
}
async function showReport(report) {
reportDate.textContent = report.name.replace('.md', '');
reportVersion.textContent = 'Loading...';
reportLink.href = report.html_url;
try {
const res = await fetch(report.download_url, { cache: 'no-store' });
if (!res.ok) {
reportVersion.textContent = 'N/A';
return;
}
const text = await res.text();
reportVersion.textContent = parseVersion(text);
} catch (_e) {
reportVersion.textContent = 'N/A';
}
}
async function loadSuite(suite) {
list.innerHTML = '<li>Loading...</li>';
const api = `https://api.github.com/repos/rustfs/dashboard/contents/functional-reports/${suite}`;
try {
const res = await fetch(api);
if (!res.ok) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
const data = await res.json();
const files = data.filter(f => f.type === 'file' && f.name.endsWith('.md')).sort((a,b) => b.name.localeCompare(a.name));
if (!files.length) {
list.innerHTML = '<li>No reports yet.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
return;
}
list.innerHTML = '';
files.forEach((f) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'report-btn';
btn.textContent = f.name.replace('.md', '');
btn.addEventListener('click', () => showReport(f));
li.appendChild(btn);
list.appendChild(li);
});
showReport(files[0]);
} catch (_e) {
list.innerHTML = '<li>Failed to load reports.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
}
}
function setActive(key) {
for (const btn of tabs.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.key === key);
}
loadSuite(key);
}
for (const suite of suites) {
const btn = document.createElement('button');
btn.textContent = suite.label;
btn.dataset.key = suite.key;
btn.addEventListener('click', () => setActive(suite.key));
tabs.appendChild(btn);
}
setActive('s3');
</script>
</body>
</html>
EOF
INDEX_PATH="functional/index.html"
INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${INDEX_SHA}" ]; then
jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
else
jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
- 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 }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'heal'
SUITE_LABEL: 'Heal'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
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 "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}"
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -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()
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-heal-test-${{ github.run_id }}
name: rustfs-heal-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
/tmp/rustfs-heal-test*.log
/tmp/rustfs-warp.*.log
if-no-files-found: warn
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/warp.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/steps.md
if-no-files-found: error
- name: Cleanup environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }}
@@ -354,6 +371,55 @@ jobs:
'
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()
run: |
+186 -185
View File
@@ -23,10 +23,9 @@ on:
description: 'Set RUSTFS_KMS_CONFIG_SECRET (runs KMS-107 config sealing)'
required: false
type: string
workflow_run:
# Strict shared-environment order: run after S3 compatibility test completes.
workflows: ["RustFS S3 Compatibility Test"]
types: [completed]
repository_dispatch:
# Chain handoff: dispatched when the S3 compatibility suite finishes.
types: [rustfs-chain-kms]
permissions:
contents: read
@@ -50,18 +49,47 @@ env:
jobs:
kms-test:
runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 420
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run' }}
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout auto-testing scripts
- name: Checkout repository (for report parser)
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: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-kms-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not
# 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: |
@@ -99,9 +127,6 @@ jobs:
- 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
@@ -131,10 +156,7 @@ jobs:
./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
if: ${{ always() && steps.evidence.outcome == 'success' }}
run: |
set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}'
@@ -146,79 +168,43 @@ jobs:
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
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
CASE_RESULT=success
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
RESULT=success
fi
{
echo "# RustFS KMS test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo ""
cat "${CASE_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}" || true
echo '```'
if [ "${RESULT}" = "success" ]; then
cat "${CASE_TABLE}"
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}"
echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard
if: always()
if: ${{ always() && steps.evidence.outcome == 'success' }}
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
@@ -228,127 +214,93 @@ jobs:
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}")"
# Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:($content|rtrimstr("\n")), 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}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
rm -f "${B64_FILE}"
cat > /tmp/rustfs-functional-index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustFS Functional Test Reports</title>
<style>
:root { --bg:#f4f6fb; --card:#fff; --text:#1f2937; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
h1 { margin: 0 0 8px; font-size: 26px; }
p { margin: 0 0 14px; color: var(--muted); }
.tabs { display: flex; gap: 10px; margin: 14px 0 18px; flex-wrap: wrap; }
button { border: 1px solid var(--line); background: #fff; color: var(--text); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>RustFS Functional Test Reports</h1>
<p>S3, KMS, Tier report tabs. Each tab lists reports by date.</p>
<div class="tabs" id="tabs"></div>
<ul id="list"></ul>
</div>
</div>
<script>
const suites = [
{ key: 's3', label: 'S3 Compatibility' },
{ key: 'kms', label: 'KMS' },
{ key: 'tier', label: 'Tier' },
{ key: 'heal', label: 'Heal' },
{ key: 'pool', label: 'Pool Expansion' },
{ key: 'security', label: 'Security' },
{ key: 'upgrade', label: 'Upgrade' },
];
const tabs = document.getElementById('tabs');
const list = document.getElementById('list');
async function loadSuite(suite) {
list.innerHTML = '<li>Loading...</li>';
const api = `https://api.github.com/repos/rustfs/dashboard/contents/functional-reports/${suite}`;
try {
const res = await fetch(api);
if (!res.ok) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
const data = await res.json();
const files = data.filter(f => f.type === 'file' && f.name.endsWith('.md')).sort((a,b) => b.name.localeCompare(a.name));
if (!files.length) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
list.innerHTML = files.map(f => `<li><a href="${f.html_url}" target="_blank" rel="noreferrer">${f.name.replace('.md','')}</a></li>`).join('');
} catch (_e) {
list.innerHTML = '<li>Failed to load reports.</li>';
}
}
function setActive(key) {
for (const btn of tabs.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.key === key);
}
loadSuite(key);
}
for (const suite of suites) {
const btn = document.createElement('button');
btn.textContent = suite.label;
btn.dataset.key = suite.key;
btn.addEventListener('click', () => setActive(suite.key));
tabs.appendChild(btn);
}
setActive('s3');
</script>
</body>
</html>
EOF
INDEX_PATH="functional/index.html"
INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${INDEX_SHA}" ]; then
jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
else
jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
- 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 }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'kms'
SUITE_LABEL: 'KMS'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
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 "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}"
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -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()
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-kms-test-${{ github.run_id }}
name: rustfs-kms-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
/tmp/rustfs-kms.log
/tmp/rustfs-kms-report.md
if-no-files-found: warn
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
if-no-files-found: error
- name: Cleanup environment (after)
if: always()
@@ -369,6 +321,55 @@ jobs:
'
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: |
+125 -36
View File
@@ -48,18 +48,17 @@ 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 handoff: dispatched when the replication suite finishes.
types: [rustfs-chain-performance]
permissions:
contents: read
# Dedicated pf-testing runner/environment: own concurrency group so perf runs
# never block (or are blocked by) the pool-expansion / heal tests.
# The default performance nodes overlap the other suites' remote VMs, even
# though the runner differs. Hold the shared lock through cleanup as well.
concurrency:
group: rustfs-performance-test
group: rustfs-shared-functional-tests
cancel-in-progress: false
defaults:
@@ -76,8 +75,6 @@ env:
# 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' }}
# Fixed benchmark result directory so later steps can read summary.md
RUSTFS_RESULT_DIR: /tmp/rustfs-perf-results
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
@@ -87,16 +84,43 @@ jobs:
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 }}
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-performance-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'RUSTFS_RESULT_DIR=%s/results\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'VERSION_FILE=%s/version.txt\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not
# 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: |
@@ -109,7 +133,7 @@ jobs:
if: ${{ inputs.cleanup_before != 'false' }}
run: |
chmod +x auto-testing/rustfs_performance_test.sh
./auto-testing/rustfs_performance_test.sh --step 1 -y
./auto-testing/rustfs_performance_test.sh --step 1 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Install RustFS package & start cluster (4x4)
run: |
@@ -119,7 +143,7 @@ jobs:
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
- name: Preflight checks
run: |
@@ -129,7 +153,7 @@ jobs:
else
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
fi
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
- name: Run benchmark (GET/PUT/MIXED)
id: benchmark
@@ -142,17 +166,15 @@ jobs:
--step 5 -y \
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
--log-file /tmp/rustfs-perf-test.log
--log-file "${LOG_FILE}"
- name: Analyze results
if: ${{ steps.benchmark.conclusion == 'success' }}
run: |
./auto-testing/rustfs_performance_test.sh --step 6 -y
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Collect RustFS version info
if: ${{ steps.benchmark.conclusion == 'success' }}
env:
VERSION_FILE: /tmp/rustfs-version.txt
run: |
set -euo pipefail
read -r -a NODES <<< "${RUSTFS_NODES}"
@@ -172,7 +194,6 @@ jobs:
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
@@ -180,7 +201,7 @@ jobs:
exit 0
fi
SUMMARY="${RESULT_DIR}/summary.md"
[ -f "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
[ -s "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
DATE="$(date -u +%Y-%m-%d)"
REPORT_PATH="reports/${DATE}.md"
{
@@ -188,6 +209,8 @@ jobs:
echo ""
echo "- **Date**: ${DATE}"
echo "- **Run**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- **Attempt**: ${GITHUB_RUN_ATTEMPT}"
echo "- **Workflow Commit**: ${GITHUB_SHA}"
echo "- **Trigger**: ${{ github.event_name }}"
echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
echo ""
@@ -197,8 +220,8 @@ jobs:
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())')"
} > "${REPORT_FILE}"
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: ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
@@ -212,21 +235,87 @@ jobs:
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 }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'performance'
SUITE_LABEL: 'Performance'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
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 "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}"
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -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()
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-perf-test-${{ github.run_id }}
name: rustfs-perf-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
/tmp/rustfs-perf-test*.log
/tmp/rustfs-perf-results/**
/tmp/rustfs-version.txt
if-no-files-found: warn
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/version.txt
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/master.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.tsv
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/get_*.txt
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/put_*.txt
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/mixed_*.txt
if-no-files-found: error
- name: Reset test environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }}
run: |
./auto-testing/rustfs_performance_test.sh --step 7 -y
./auto-testing/rustfs_performance_test.sh --step 7 -y --log-file "${LOG_FILE:-/dev/null}"
- name: Notify on failure
if: failure()
+200 -496
View File
@@ -1,4 +1,4 @@
name: RustFS Pool Expansion / Heal Test
name: RustFS Pool Expansion Test
on:
workflow_dispatch:
@@ -33,14 +33,6 @@ on:
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
@@ -49,17 +41,16 @@ on:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
workflow_run:
# Strict shared-environment order: run after tier test completes.
workflows: ["RustFS Tier Test"]
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-shared-functional-tests
cancel-in-progress: false
@@ -80,330 +71,13 @@ env:
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
jobs:
heal-test:
name: Heal test
runs-on: smoke-testing
timeout-minutes: 480
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run' }}
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: Show environment
run: |
uname -a
jq --version
openssl version
df -h /data | tail -1
- name: Cleanup environment (before)
if: ${{ inputs.cleanup_before != 'false' }}
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: 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)
id: test
run: |
ARGS=(--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)
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: 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
cat > /tmp/rustfs-functional-index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustFS Functional Test Reports</title>
<style>
:root { --bg:#f4f6fb; --card:#fff; --text:#1f2937; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
h1 { margin: 0 0 8px; font-size: 26px; }
p { margin: 0 0 14px; color: var(--muted); }
.tabs { display: flex; gap: 10px; margin: 14px 0 18px; flex-wrap: wrap; }
button { border: 1px solid var(--line); background: #fff; color: var(--text); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.report-btn { border: 0; background: transparent; padding: 0; color: var(--accent); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.meta { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; }
.kv { margin: 6px 0; color: var(--text); }
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>RustFS Functional Test Reports</h1>
<p>Select a suite and date to view the build version used in that run.</p>
<div class="tabs" id="tabs"></div>
<ul id="list"></ul>
<div class="meta">
<div class="kv"><strong>Date:</strong> <span id="report-date" class="muted">N/A</span></div>
<div class="kv"><strong>RustFS Version:</strong> <span id="report-version" class="muted">N/A</span></div>
<div class="kv"><a id="report-link" href="#" target="_blank" rel="noreferrer">Open report</a></div>
</div>
</div>
</div>
<script>
const suites = [
{ key: 's3', label: 'S3 Compatibility' },
{ key: 'kms', label: 'KMS' },
{ key: 'tier', label: 'Tier' },
{ key: 'heal', label: 'Heal' },
{ key: 'pool', label: 'Pool Expansion' },
{ key: 'security', label: 'Security' },
{ key: 'upgrade', label: 'Upgrade' },
];
const tabs = document.getElementById('tabs');
const list = document.getElementById('list');
const reportDate = document.getElementById('report-date');
const reportVersion = document.getElementById('report-version');
const reportLink = document.getElementById('report-link');
function parseVersion(markdown) {
const m = markdown.match(/^- RustFS Version:\s*(.+)$/m);
return m ? m[1].trim() : 'N/A';
}
async function showReport(report) {
reportDate.textContent = report.name.replace('.md', '');
reportVersion.textContent = 'Loading...';
reportLink.href = report.html_url;
try {
const res = await fetch(report.download_url, { cache: 'no-store' });
if (!res.ok) {
reportVersion.textContent = 'N/A';
return;
}
const text = await res.text();
reportVersion.textContent = parseVersion(text);
} catch (_e) {
reportVersion.textContent = 'N/A';
}
}
async function loadSuite(suite) {
list.innerHTML = '<li>Loading...</li>';
const api = `https://api.github.com/repos/rustfs/dashboard/contents/functional-reports/${suite}`;
try {
const res = await fetch(api);
if (!res.ok) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
const data = await res.json();
const files = data.filter(f => f.type === 'file' && f.name.endsWith('.md')).sort((a,b) => b.name.localeCompare(a.name));
if (!files.length) {
list.innerHTML = '<li>No reports yet.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
return;
}
list.innerHTML = '';
files.forEach((f) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'report-btn';
btn.textContent = f.name.replace('.md', '');
btn.addEventListener('click', () => showReport(f));
li.appendChild(btn);
list.appendChild(li);
});
showReport(files[0]);
} catch (_e) {
list.innerHTML = '<li>Failed to load reports.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
}
}
function setActive(key) {
for (const btn of tabs.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.key === key);
}
loadSuite(key);
}
for (const suite of suites) {
const btn = document.createElement('button');
btn.textContent = suite.label;
btn.dataset.key = suite.key;
btn.addEventListener('click', () => setActive(suite.key));
tabs.appendChild(btn);
}
setActive('s3');
</script>
</body>
</html>
EOF
INDEX_PATH="functional/index.html"
INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${INDEX_SHA}" ]; then
jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
else
jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
fi
- 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: Cleanup environment (after)
if: ${{ always() && inputs.cleanup_after != 'false' }}
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: Notify on failure
if: failure()
run: |
echo "RustFS heal test failed"
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
echo "See the uploaded log artifact for details."
# Pool expansion runs after heal regardless of heal outcome.
# 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
runs-on: smoke-testing
timeout-minutes: 360
needs: heal-test
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run') }}
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
@@ -411,14 +85,25 @@ jobs:
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: |
@@ -692,6 +377,60 @@ jobs:
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
STEPS_TABLE="${POOL_ARTIFACT_DIR}/pool-steps.md"
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY'
import re
import sys
log_file, out_file = sys.argv[1], sys.argv[2]
ansi = re.compile(r'\x1b\[[0-9;]*m')
step_re = re.compile(r'^\[POOL-STEP\]\s+(\d+)\s+(.+?)\s+(PASS|FAIL|SKIP)\s*$')
ver_re = re.compile(r'^\[POOL-VERSION\]\s+(\S+)(?:\s+\(node\s+(\S+)\))?\s*$')
result_re = re.compile(r'^\[POOL-RESULT\]\s+(PASS|FAIL)\s+(.*)$')
steps = {}
order = []
version = None
version_node = None
verdict = None
verdict_detail = ''
try:
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
for raw in fh:
line = ansi.sub('', raw).strip()
m = step_re.match(line)
if m:
n, desc, status = m.group(1), m.group(2), m.group(3)
if n not in steps:
order.append(n)
steps[n] = (desc, status) # later lines win (fail after pass)
continue
m = ver_re.match(line)
if m:
version, version_node = m.group(1), m.group(2)
continue
m = result_re.match(line)
if m:
verdict, verdict_detail = m.group(1), m.group(2)
except FileNotFoundError:
pass
with open(out_file, 'w', encoding='utf-8') as out:
out.write('## Step Results\n\n')
if version:
node_note = f' (captured via `rustfs --version` on {version_node})' if version_node else ''
out.write(f'- Version under test: **{version}**{node_note}\n')
if verdict:
out.write(f'- Overall result: **{verdict}** — {verdict_detail}\n')
out.write('\n')
out.write('| Step | Description | Result |\n')
out.write('| --- | --- | --- |\n')
for n in sorted(order, key=int):
desc, status = steps[n]
out.write(f'| {n} | {desc} | {status} |\n')
if not order:
out.write('| - | - | NOT RUN (no step result lines found) |\n')
PY
{
echo "# RustFS pool expansion test report"
echo ""
@@ -701,6 +440,8 @@ jobs:
echo "- Warp concurrent: ${{ inputs.warp_concurrent || '32' }}"
echo "- Test Step Outcome: ${{ steps.pool_test.outcome }}"
echo ""
cat "${STEPS_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}" || true
@@ -798,167 +539,81 @@ jobs:
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}")"
# Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:($content|rtrimstr("\n")), 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}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
rm -f "${B64_FILE}"
cat > /tmp/rustfs-functional-index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustFS Functional Test Reports</title>
<style>
:root { --bg:#f4f6fb; --card:#fff; --text:#1f2937; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
h1 { margin: 0 0 8px; font-size: 26px; }
p { margin: 0 0 14px; color: var(--muted); }
.tabs { display: flex; gap: 10px; margin: 14px 0 18px; flex-wrap: wrap; }
button { border: 1px solid var(--line); background: #fff; color: var(--text); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.report-btn { border: 0; background: transparent; padding: 0; color: var(--accent); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.meta { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; }
.kv { margin: 6px 0; color: var(--text); }
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>RustFS Functional Test Reports</h1>
<p>Select a suite and date to view the build version used in that run.</p>
<div class="tabs" id="tabs"></div>
<ul id="list"></ul>
<div class="meta">
<div class="kv"><strong>Date:</strong> <span id="report-date" class="muted">N/A</span></div>
<div class="kv"><strong>RustFS Version:</strong> <span id="report-version" class="muted">N/A</span></div>
<div class="kv"><a id="report-link" href="#" target="_blank" rel="noreferrer">Open report</a></div>
</div>
</div>
</div>
<script>
const suites = [
{ key: 's3', label: 'S3 Compatibility' },
{ key: 'kms', label: 'KMS' },
{ key: 'tier', label: 'Tier' },
{ key: 'heal', label: 'Heal' },
{ key: 'pool', label: 'Pool Expansion' },
{ key: 'security', label: 'Security' },
{ key: 'upgrade', label: 'Upgrade' },
];
const tabs = document.getElementById('tabs');
const list = document.getElementById('list');
const reportDate = document.getElementById('report-date');
const reportVersion = document.getElementById('report-version');
const reportLink = document.getElementById('report-link');
function parseVersion(markdown) {
const m = markdown.match(/^- RustFS Version:\s*(.+)$/m);
return m ? m[1].trim() : 'N/A';
}
async function showReport(report) {
reportDate.textContent = report.name.replace('.md', '');
reportVersion.textContent = 'Loading...';
reportLink.href = report.html_url;
try {
const res = await fetch(report.download_url, { cache: 'no-store' });
if (!res.ok) {
reportVersion.textContent = 'N/A';
return;
}
const text = await res.text();
reportVersion.textContent = parseVersion(text);
} catch (_e) {
reportVersion.textContent = 'N/A';
}
}
async function loadSuite(suite) {
list.innerHTML = '<li>Loading...</li>';
const api = `https://api.github.com/repos/rustfs/dashboard/contents/functional-reports/${suite}`;
try {
const res = await fetch(api);
if (!res.ok) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
const data = await res.json();
const files = data.filter(f => f.type === 'file' && f.name.endsWith('.md')).sort((a,b) => b.name.localeCompare(a.name));
if (!files.length) {
list.innerHTML = '<li>No reports yet.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
return;
}
list.innerHTML = '';
files.forEach((f) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'report-btn';
btn.textContent = f.name.replace('.md', '');
btn.addEventListener('click', () => showReport(f));
li.appendChild(btn);
list.appendChild(li);
});
showReport(files[0]);
} catch (_e) {
list.innerHTML = '<li>Failed to load reports.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
}
}
function setActive(key) {
for (const btn of tabs.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.key === key);
}
loadSuite(key);
}
for (const suite of suites) {
const btn = document.createElement('button');
btn.textContent = suite.label;
btn.dataset.key = suite.key;
btn.addEventListener('click', () => setActive(suite.key));
tabs.appendChild(btn);
}
setActive('s3');
</script>
</body>
</html>
EOF
INDEX_PATH="functional/index.html"
INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${INDEX_SHA}" ]; then
jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
else
jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
- 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()
@@ -997,6 +652,55 @@ jobs:
'
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()
run: |
@@ -0,0 +1,382 @@
# 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.
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
timeout-minutes: 360
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
steps:
- name: Checkout repository (for report parser)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-replication-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not
# 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
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() && steps.evidence.outcome == 'success' }}
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="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
CASE_RESULT=success
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
RESULT=success
fi
{
echo "# RustFS replication test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo ""
if [ "${RESULT}" = "success" ]; then
cat "${CASE_TABLE}"
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}"
echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard
if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true
env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
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"
# Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
else
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
rm -f "${B64_FILE}"
- 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 }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'replication'
SUITE_LABEL: 'Replication (bucket + site)'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
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 "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}"
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -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() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-replication-${{ github.run_id }}-${{ github.run_attempt }}
path: |
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
if-no-files-found: error
- 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: "Continue functional chain (next: Performance)"
if: ${{ always() && github.event_name == 'repository_dispatch' }}
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-performance' \
-F 'client_payload[from_suite]=replication'; then
echo "dispatched next suite Performance (attempt ${attempt})"
DISPATCHED=1
break
fi
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
sleep "${attempt}0"
done
if [ "${DISPATCHED:-0}" -ne 1 ]; then
echo "ERROR: functional chain stalled: could not dispatch Performance after 3 attempts" >&2
TITLE="[functional][chain] stalled after replication (run ${GITHUB_RUN_ID})"
BODY_FILE="$(mktemp)"
trap 'rm -f "${BODY_FILE}"' EXIT
{
echo "The functional chain could not hand off from **replication** to **Performance** after 3 attempts."
echo ""
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Expected next event: 'rustfs-chain-performance'"
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
echo "- Recovery: re-dispatch manually with"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-performance'"
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
} > "${BODY_FILE}"
gh issue create -R rustfs/backlog --title "${TITLE}" \
--body-file "${BODY_FILE}" --label functional-test \
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|| echo "could not file the stall alert issue either; check the token" >&2
exit 1
fi
- name: Notify on failure
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."
+186 -238
View File
@@ -11,10 +11,9 @@ on:
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
required: false
type: string
workflow_run:
# Run after upgrade compatibility completes; the nightly deb is what the test installs.
workflows: ["RustFS Upgrade Test"]
types: [completed]
repository_dispatch:
# Chain handoff: dispatched when the upgrade suite finishes.
types: [rustfs-chain-s3]
permissions:
contents: read
@@ -38,18 +37,47 @@ env:
jobs:
s3-compat-test:
runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 360
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
- name: Checkout repository (for report parser)
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: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-s3-compat-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not
# 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: |
@@ -78,9 +106,6 @@ jobs:
- 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
@@ -97,10 +122,7 @@ jobs:
./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
if: ${{ always() && steps.evidence.outcome == 'success' }}
run: |
set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}'
@@ -122,83 +144,44 @@ jobs:
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
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
CASE_RESULT=success
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
RESULT=success
fi
{
echo "# RustFS S3 compatibility test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo ""
cat "${CASE_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}" || true
echo '```'
if [ "${RESULT}" = "success" ]; then
cat "${CASE_TABLE}"
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}"
echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard
if: always()
if: ${{ always() && steps.evidence.outcome == 'success' }}
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
@@ -208,177 +191,93 @@ jobs:
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}")"
# Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:($content|rtrimstr("\n")), 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}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
rm -f "${B64_FILE}"
cat > /tmp/rustfs-functional-index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustFS Functional Test Reports</title>
<style>
:root { --bg:#f4f6fb; --card:#fff; --text:#1f2937; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
h1 { margin: 0 0 8px; font-size: 26px; }
p { margin: 0 0 14px; color: var(--muted); }
.tabs { display: flex; gap: 10px; margin: 14px 0 18px; flex-wrap: wrap; }
button { border: 1px solid var(--line); background: #fff; color: var(--text); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.report-btn { border: 0; background: transparent; padding: 0; color: var(--accent); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.meta { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; }
.kv { margin: 6px 0; color: var(--text); }
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>RustFS Functional Test Reports</h1>
<p>Select a suite and date to view the build version used in that run.</p>
<div class="tabs" id="tabs"></div>
<ul id="list"></ul>
<div class="meta">
<div class="kv"><strong>Date:</strong> <span id="report-date" class="muted">N/A</span></div>
<div class="kv"><strong>RustFS Version:</strong> <span id="report-version" class="muted">N/A</span></div>
<div class="kv"><a id="report-link" href="#" target="_blank" rel="noreferrer">Open report</a></div>
</div>
</div>
</div>
<script>
const suites = [
{ key: 's3', label: 'S3 Compatibility' },
{ key: 'kms', label: 'KMS' },
{ key: 'tier', label: 'Tier' },
{ key: 'heal', label: 'Heal' },
{ key: 'pool', label: 'Pool Expansion' },
{ key: 'security', label: 'Security' },
{ key: 'upgrade', label: 'Upgrade' },
];
const tabs = document.getElementById('tabs');
const list = document.getElementById('list');
const reportDate = document.getElementById('report-date');
const reportVersion = document.getElementById('report-version');
const reportLink = document.getElementById('report-link');
function parseVersion(markdown) {
const m = markdown.match(/^- RustFS Version:\s*(.+)$/m);
return m ? m[1].trim() : 'N/A';
}
async function showReport(report) {
reportDate.textContent = report.name.replace('.md', '');
reportVersion.textContent = 'Loading...';
reportLink.href = report.html_url;
try {
const res = await fetch(report.download_url, { cache: 'no-store' });
if (!res.ok) {
reportVersion.textContent = 'N/A';
return;
}
const text = await res.text();
reportVersion.textContent = parseVersion(text);
} catch (_e) {
reportVersion.textContent = 'N/A';
}
}
async function loadSuite(suite) {
list.innerHTML = '<li>Loading...</li>';
const api = `https://api.github.com/repos/rustfs/dashboard/contents/functional-reports/${suite}`;
try {
const res = await fetch(api);
if (!res.ok) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
const data = await res.json();
const files = data.filter(f => f.type === 'file' && f.name.endsWith('.md')).sort((a,b) => b.name.localeCompare(a.name));
if (!files.length) {
list.innerHTML = '<li>No reports yet.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
return;
}
list.innerHTML = '';
files.forEach((f) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'report-btn';
btn.textContent = f.name.replace('.md', '');
btn.addEventListener('click', () => showReport(f));
li.appendChild(btn);
list.appendChild(li);
});
showReport(files[0]);
} catch (_e) {
list.innerHTML = '<li>Failed to load reports.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
}
}
function setActive(key) {
for (const btn of tabs.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.key === key);
}
loadSuite(key);
}
for (const suite of suites) {
const btn = document.createElement('button');
btn.textContent = suite.label;
btn.dataset.key = suite.key;
btn.addEventListener('click', () => setActive(suite.key));
tabs.appendChild(btn);
}
setActive('s3');
</script>
</body>
</html>
EOF
INDEX_PATH="functional/index.html"
INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${INDEX_SHA}" ]; then
jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
else
jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
- 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 }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 's3'
SUITE_LABEL: 'S3 compatibility'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
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 "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}"
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -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()
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-s3-compat-${{ github.run_id }}
name: rustfs-s3-compat-${{ github.run_id }}-${{ github.run_attempt }}
path: |
/tmp/rustfs-s3-compat.log
/tmp/rustfs-s3-compat-report.md
if-no-files-found: warn
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
if-no-files-found: error
- name: Cleanup environment (after)
if: always()
@@ -399,6 +298,55 @@ jobs:
'
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: |
+164 -188
View File
@@ -46,10 +46,9 @@ on:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
workflow_run:
# Runs last in the functional chain, after pool/heal, on the shared VMs.
workflows: ["RustFS Pool Expansion / Heal Test"]
types: [completed]
repository_dispatch:
# Chain handoff: dispatched when the pool expansion suite finishes.
types: [rustfs-chain-security]
permissions:
contents: read
@@ -75,23 +74,46 @@ env:
jobs:
security-test:
runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 360
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 }}
# Checkout the repository into its own subdirectory. Checking out at
# the workspace root would wipe the auto-testing clone above (that is
# exactly how run 33934141181 lost rustfs-security-test.sh).
- name: Checkout repository (for the OIDC live gate script)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
path: rustfs-repo
- name: Initialize security evidence
id: evidence
run: |
set -euo pipefail
umask 077
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${SECURITY_ARTIFACTS_DIR}" "${SECURITY_ARTIFACTS_DIR}-scratch"
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not
# 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: |
@@ -125,8 +147,9 @@ jobs:
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
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}-scratch
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/rustfs-repo/scripts/test/oidc_keycloak_live.sh
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-security-test.sh
@@ -149,29 +172,48 @@ jobs:
else
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
./auto-testing/rustfs-security-test.sh "${ARGS[@]}"
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}" 2>&1 | tee "${SECURITY_ARTIFACTS_DIR}/suite.log"
- name: Generate report
if: always()
id: report
if: ${{ always() && steps.evidence.outcome == 'success' }}
env:
TEST_OUTCOME: ${{ steps.test.outcome }}
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
RESULT=failure
if [ "${TEST_OUTCOME}" = "success" ] && [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
RESULT=success
fi
cat /tmp/rustfs-security-report.md >> "${GITHUB_STEP_SUMMARY}"
{
echo "# RustFS security test report"
echo ""
echo "- Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${TEST_OUTCOME}"
echo ""
# The dashboard prioritizes case rows over the step outcome.
# Keep partial case results in the artifact when the suite fails.
if [ "${RESULT}" = "success" ]; then
cat "${SECURITY_ARTIFACTS_DIR}/suite-report.md"
elif [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
echo "The suite did not complete successfully. See suite-report.md in this run's artifact for diagnostics."
else
echo "The suite did not produce a non-empty report."
fi
} > "${SECURITY_ARTIFACTS_DIR}/report.md"
cat "${SECURITY_ARTIFACTS_DIR}/report.md" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard
if: always()
if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true
env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-security-report.md
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
SUITE: security
run: |
set -euo pipefail
@@ -181,177 +223,93 @@ jobs:
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}")"
# Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:($content|rtrimstr("\n")), 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}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
rm -f "${B64_FILE}"
cat > /tmp/rustfs-functional-index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustFS Functional Test Reports</title>
<style>
:root { --bg:#f4f6fb; --card:#fff; --text:#1f2937; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
h1 { margin: 0 0 8px; font-size: 26px; }
p { margin: 0 0 14px; color: var(--muted); }
.tabs { display: flex; gap: 10px; margin: 14px 0 18px; flex-wrap: wrap; }
button { border: 1px solid var(--line); background: #fff; color: var(--text); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.report-btn { border: 0; background: transparent; padding: 0; color: var(--accent); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.meta { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; }
.kv { margin: 6px 0; color: var(--text); }
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>RustFS Functional Test Reports</h1>
<p>Select a suite and date to view the build version used in that run.</p>
<div class="tabs" id="tabs"></div>
<ul id="list"></ul>
<div class="meta">
<div class="kv"><strong>Date:</strong> <span id="report-date" class="muted">N/A</span></div>
<div class="kv"><strong>RustFS Version:</strong> <span id="report-version" class="muted">N/A</span></div>
<div class="kv"><a id="report-link" href="#" target="_blank" rel="noreferrer">Open report</a></div>
</div>
</div>
</div>
<script>
const suites = [
{ key: 's3', label: 'S3 Compatibility' },
{ key: 'kms', label: 'KMS' },
{ key: 'tier', label: 'Tier' },
{ key: 'heal', label: 'Heal' },
{ key: 'pool', label: 'Pool Expansion' },
{ key: 'security', label: 'Security' },
{ key: 'upgrade', label: 'Upgrade' },
];
const tabs = document.getElementById('tabs');
const list = document.getElementById('list');
const reportDate = document.getElementById('report-date');
const reportVersion = document.getElementById('report-version');
const reportLink = document.getElementById('report-link');
function parseVersion(markdown) {
const m = markdown.match(/^- RustFS Version:\s*(.+)$/m);
return m ? m[1].trim() : 'N/A';
}
async function showReport(report) {
reportDate.textContent = report.name.replace('.md', '');
reportVersion.textContent = 'Loading...';
reportLink.href = report.html_url;
try {
const res = await fetch(report.download_url, { cache: 'no-store' });
if (!res.ok) {
reportVersion.textContent = 'N/A';
return;
}
const text = await res.text();
reportVersion.textContent = parseVersion(text);
} catch (_e) {
reportVersion.textContent = 'N/A';
}
}
async function loadSuite(suite) {
list.innerHTML = '<li>Loading...</li>';
const api = `https://api.github.com/repos/rustfs/dashboard/contents/functional-reports/${suite}`;
try {
const res = await fetch(api);
if (!res.ok) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
const data = await res.json();
const files = data.filter(f => f.type === 'file' && f.name.endsWith('.md')).sort((a,b) => b.name.localeCompare(a.name));
if (!files.length) {
list.innerHTML = '<li>No reports yet.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
return;
}
list.innerHTML = '';
files.forEach((f) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'report-btn';
btn.textContent = f.name.replace('.md', '');
btn.addEventListener('click', () => showReport(f));
li.appendChild(btn);
list.appendChild(li);
});
showReport(files[0]);
} catch (_e) {
list.innerHTML = '<li>Failed to load reports.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
}
}
function setActive(key) {
for (const btn of tabs.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.key === key);
}
loadSuite(key);
}
for (const suite of suites) {
const btn = document.createElement('button');
btn.textContent = suite.label;
btn.dataset.key = suite.key;
btn.addEventListener('click', () => setActive(suite.key));
tabs.appendChild(btn);
}
setActive('s3');
</script>
</body>
</html>
EOF
INDEX_PATH="functional/index.html"
INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${INDEX_SHA}" ]; then
jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
else
jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
- 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'
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/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 [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -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()
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-security-test-${{ github.run_id }}
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
/tmp/rustfs-security-report.md
/tmp/rustfs-security.*/*
if-no-files-found: ignore
${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
${{ env.SECURITY_ARTIFACTS_DIR }}/suite.log
${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
if-no-files-found: error
retention-days: 3
- name: Cleanup environment (after)
@@ -373,6 +331,24 @@ jobs:
'
done
- name: "Continue functional chain (next: Replication)"
# Only chain-triggered runs forward to the next suite; standalone
# workflow_dispatch runs stop after their own cleanup.
if: ${{ always() && github.event_name == 'repository_dispatch' }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
exit 1
fi
echo "Dispatching next functional suite: Replication"
gh api --method POST repos/rustfs/rustfs/dispatches \
-f event_type='rustfs-chain-replication' \
-F 'client_payload[from_suite]=security'
- name: Notify on failure
if: failure()
run: |
+186 -239
View File
@@ -20,10 +20,9 @@ on:
- single-multi
- multi-multi
default: all
workflow_run:
# Strict shared-environment order: run after tier test completes.
workflows: ["RustFS Tier Test"]
types: [completed]
repository_dispatch:
# Chain handoff: dispatched when the tier suite finishes.
types: [rustfs-chain-storage]
permissions:
contents: read
@@ -47,18 +46,47 @@ env:
jobs:
storage-test:
runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 360
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
- name: Checkout repository (for report parser)
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: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-storage-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not
# 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: |
@@ -87,9 +115,6 @@ jobs:
- 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
@@ -112,10 +137,7 @@ jobs:
./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
if: ${{ always() && steps.evidence.outcome == 'success' }}
run: |
set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}'
@@ -137,83 +159,44 @@ jobs:
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
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
CASE_RESULT=success
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
RESULT=success
fi
{
echo "# RustFS storage engine test report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo ""
cat "${CASE_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}" || true
echo '```'
if [ "${RESULT}" = "success" ]; then
cat "${CASE_TABLE}"
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}"
echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard
if: always()
if: ${{ always() && steps.evidence.outcome == 'success' }}
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
@@ -223,178 +206,93 @@ jobs:
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}")"
# Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:($content|rtrimstr("\n")), 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}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
rm -f "${B64_FILE}"
cat > /tmp/rustfs-functional-index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustFS Functional Test Reports</title>
<style>
:root { --bg:#f4f6fb; --card:#fff; --text:#1f2937; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
h1 { margin: 0 0 8px; font-size: 26px; }
p { margin: 0 0 14px; color: var(--muted); }
.tabs { display: flex; gap: 10px; margin: 14px 0 18px; flex-wrap: wrap; }
button { border: 1px solid var(--line); background: #fff; color: var(--text); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.report-btn { border: 0; background: transparent; padding: 0; color: var(--accent); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.meta { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; }
.kv { margin: 6px 0; color: var(--text); }
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>RustFS Functional Test Reports</h1>
<p>Select a suite and date to view the build version used in that run.</p>
<div class="tabs" id="tabs"></div>
<ul id="list"></ul>
<div class="meta">
<div class="kv"><strong>Date:</strong> <span id="report-date" class="muted">N/A</span></div>
<div class="kv"><strong>RustFS Version:</strong> <span id="report-version" class="muted">N/A</span></div>
<div class="kv"><a id="report-link" href="#" target="_blank" rel="noreferrer">Open report</a></div>
</div>
</div>
</div>
<script>
const suites = [
{ key: 's3', label: 'S3 Compatibility' },
{ key: 'kms', label: 'KMS' },
{ key: 'tier', label: 'Tier' },
{ key: 'storage', label: 'Storage Engine' },
{ key: 'heal', label: 'Heal' },
{ key: 'pool', label: 'Pool Expansion' },
{ key: 'security', label: 'Security' },
{ key: 'upgrade', label: 'Upgrade' },
];
const tabs = document.getElementById('tabs');
const list = document.getElementById('list');
const reportDate = document.getElementById('report-date');
const reportVersion = document.getElementById('report-version');
const reportLink = document.getElementById('report-link');
function parseVersion(markdown) {
const m = markdown.match(/^- RustFS Version:\s*(.+)$/m);
return m ? m[1].trim() : 'N/A';
}
async function showReport(report) {
reportDate.textContent = report.name.replace('.md', '');
reportVersion.textContent = 'Loading...';
reportLink.href = report.html_url;
try {
const res = await fetch(report.download_url, { cache: 'no-store' });
if (!res.ok) {
reportVersion.textContent = 'N/A';
return;
}
const text = await res.text();
reportVersion.textContent = parseVersion(text);
} catch (_e) {
reportVersion.textContent = 'N/A';
}
}
async function loadSuite(suite) {
list.innerHTML = '<li>Loading...</li>';
const api = `https://api.github.com/repos/rustfs/dashboard/contents/functional-reports/${suite}`;
try {
const res = await fetch(api);
if (!res.ok) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
const data = await res.json();
const files = data.filter(f => f.type === 'file' && f.name.endsWith('.md')).sort((a,b) => b.name.localeCompare(a.name));
if (!files.length) {
list.innerHTML = '<li>No reports yet.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
return;
}
list.innerHTML = '';
files.forEach((f) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'report-btn';
btn.textContent = f.name.replace('.md', '');
btn.addEventListener('click', () => showReport(f));
li.appendChild(btn);
list.appendChild(li);
});
showReport(files[0]);
} catch (_e) {
list.innerHTML = '<li>Failed to load reports.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
}
}
function setActive(key) {
for (const btn of tabs.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.key === key);
}
loadSuite(key);
}
for (const suite of suites) {
const btn = document.createElement('button');
btn.textContent = suite.label;
btn.dataset.key = suite.key;
btn.addEventListener('click', () => setActive(suite.key));
tabs.appendChild(btn);
}
setActive('s3');
</script>
</body>
</html>
EOF
INDEX_PATH="functional/index.html"
INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${INDEX_SHA}" ]; then
jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
else
jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
- 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 }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'storage'
SUITE_LABEL: 'Storage engine'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
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 "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}"
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -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()
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-storage-${{ github.run_id }}
name: rustfs-storage-${{ github.run_id }}-${{ github.run_attempt }}
path: |
/tmp/rustfs-storage.log
/tmp/rustfs-storage-report.md
if-no-files-found: warn
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
if-no-files-found: error
- name: Cleanup environment (after)
if: always()
@@ -415,6 +313,55 @@ jobs:
'
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: |
+437 -190
View File
@@ -11,10 +11,29 @@ on:
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
required: false
type: string
workflow_run:
# Strict shared-environment order: run after KMS test completes.
workflows: ["RustFS KMS Test"]
types: [completed]
rc_archive_url:
description: 'Exact RustFS CLI Linux archive URL.'
required: false
default: 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz'
type: string
rc_archive_sha256:
description: 'Expected SHA-256 of the RustFS CLI archive.'
required: false
default: 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df'
type: string
rc_sha256:
description: 'Expected SHA-256 of the extracted RustFS CLI binary.'
required: false
default: '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df'
type: string
force_case_failure:
description: 'Diagnostic only: rewrite single-single/TIER-101 to FAIL after execution to verify artifact and final-gate behavior.'
required: false
default: false
type: boolean
repository_dispatch:
# Chain handoff: dispatched when the KMS suite finishes.
types: [rustfs-chain-tier]
permissions:
contents: read
@@ -33,23 +52,154 @@ env:
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
RUSTFS_RC_ARCHIVE_URL: ${{ inputs.rc_archive_url || 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz' }}
RUSTFS_RC_ARCHIVE_SHA256: ${{ inputs.rc_archive_sha256 || 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df' }}
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df' }}
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
TIER_ARTIFACTS_DIR: /tmp/rustfs-tier-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
jobs:
tier-test:
runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 420
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run' }}
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 }}
- 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: Prepare pinned RustFS CLI
id: rc
run: |
set -euo pipefail
umask 077
case "${RUSTFS_RC_ARCHIVE_URL}" in
https://*) ;;
*)
echo "RustFS CLI archive URL must use HTTPS" >&2
exit 1
;;
esac
EXPECTED_ARCHIVE_SHA256="$(printf '%s' "${RUSTFS_RC_ARCHIVE_SHA256}" | tr '[:upper:]' '[:lower:]')"
EXPECTED_RC_SHA256="$(printf '%s' "${RUSTFS_EXPECTED_RC_SHA256}" | tr '[:upper:]' '[:lower:]')"
if ! [[ "${EXPECTED_ARCHIVE_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
echo "invalid RustFS CLI archive SHA-256" >&2
exit 1
fi
if ! [[ "${EXPECTED_RC_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
echo "invalid RustFS CLI binary SHA-256" >&2
exit 1
fi
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
RC_ARCHIVE="${RC_ROOT}/rustfs-cli.tar.gz"
RC_BIN="${RC_ROOT}/rc"
if ! mkdir -- "${RC_ROOT}"; then
echo "refusing to reuse RustFS CLI directory: ${RC_ROOT}" >&2
exit 1
fi
curl --fail --location --retry 3 --retry-all-errors \
--connect-timeout 15 --max-time 180 \
--proto '=https' --proto-redir '=https' \
--output "${RC_ARCHIVE}" "${RUSTFS_RC_ARCHIVE_URL}"
RC_ARCHIVE_SIZE="$(wc -c < "${RC_ARCHIVE}" | tr -d '[:space:]')"
if [ "${RC_ARCHIVE_SIZE}" -eq 0 ] || [ "${RC_ARCHIVE_SIZE}" -gt 33554432 ]; then
echo "RustFS CLI archive size is outside the accepted range: ${RC_ARCHIVE_SIZE}" >&2
exit 1
fi
if ! ACTUAL_ARCHIVE_SHA256="$(openssl dgst -sha256 -r "${RC_ARCHIVE}" | awk '{print $1}')"; then
echo "failed to calculate RustFS CLI archive SHA-256" >&2
exit 1
fi
if [ "${ACTUAL_ARCHIVE_SHA256}" != "${EXPECTED_ARCHIVE_SHA256}" ]; then
echo "RustFS CLI archive SHA-256 mismatch: expected ${EXPECTED_ARCHIVE_SHA256}, got ${ACTUAL_ARCHIVE_SHA256}" >&2
exit 1
fi
ARCHIVE_MEMBERS="$(tar -tzf "${RC_ARCHIVE}")"
if ! grep -Fxq 'rc' <<< "${ARCHIVE_MEMBERS}"; then
echo "RustFS CLI archive does not contain the rc entry" >&2
exit 1
fi
if ! tar -xOzf "${RC_ARCHIVE}" rc > "${RC_BIN}"; then
echo "failed to extract the RustFS CLI binary" >&2
exit 1
fi
chmod 0700 "${RC_BIN}"
if ! ACTUAL_RC_SHA256="$(openssl dgst -sha256 -r "${RC_BIN}" | awk '{print $1}')"; then
echo "failed to calculate RustFS CLI binary SHA-256" >&2
exit 1
fi
if [ "${ACTUAL_RC_SHA256}" != "${EXPECTED_RC_SHA256}" ]; then
echo "RustFS CLI binary SHA-256 mismatch: expected ${EXPECTED_RC_SHA256}, got ${ACTUAL_RC_SHA256}" >&2
exit 1
fi
if ! RC_VERSION_OUTPUT="$(timeout 30 "${RC_BIN}" --version 2>&1)"; then
echo "failed to execute the pinned RustFS CLI" >&2
exit 1
fi
RC_VERSION="${RC_VERSION_OUTPUT%%$'\n'*}"
if [ -z "${RC_VERSION}" ]; then
echo "pinned RustFS CLI returned an empty version" >&2
exit 1
fi
jq -n \
--arg schema_version '1' \
--arg generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg archive_url "${RUSTFS_RC_ARCHIVE_URL}" \
--arg archive_sha256 "${ACTUAL_ARCHIVE_SHA256}" \
--arg archive_size "${RC_ARCHIVE_SIZE}" \
--arg path "${RC_BIN}" \
--arg version "${RC_VERSION}" \
--arg sha256 "${ACTUAL_RC_SHA256}" \
'{
schema_version: ($schema_version | tonumber),
generated_at: $generated_at,
archive: {
url: $archive_url,
sha256: $archive_sha256,
size: ($archive_size | tonumber)
},
binary: {
path: $path,
version: $version,
sha256: $sha256
}
}' > "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json"
printf 'path=%s\n' "${RC_BIN}" >> "${GITHUB_OUTPUT}"
echo "RustFS CLI ready: ${RC_VERSION} (${ACTUAL_RC_SHA256})"
- name: Show environment
run: |
@@ -110,13 +260,25 @@ jobs:
id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-tier.log
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
RC_BIN: ${{ steps.rc.outputs.path }}
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
run: |
set -euo pipefail
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
chmod +x auto-testing/rustfs-tier-test.sh
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
ARGS=(--all-topologies -y --log-file "${LOG_FILE}")
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
@@ -126,15 +288,34 @@ jobs:
fi
./auto-testing/rustfs-tier-test.sh "${ARGS[@]}"
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-tier.log
REPORT_FILE: /tmp/rustfs-tier-report.md
- name: Inject diagnostic case failure
if: ${{ always() && steps.evidence.outcome == 'success' && inputs.force_case_failure }}
run: |
set -euo pipefail
PACKAGE_URL='${{ inputs.package_url }}'
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
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
@@ -142,65 +323,37 @@ jobs:
else
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
fi
CASE_TABLE="/tmp/rustfs-tier-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
if RC_BOOTSTRAP_SUMMARY="$(jq -r '.binary | "\(.version) / \(.sha256)"' "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json" 2>/dev/null)"; then
:
else
RC_BOOTSTRAP_SUMMARY="missing or invalid"
fi
set +e
python3 auto-testing/rustfs_tier_report.py \
--results-dir "${TIER_ARTIFACTS_DIR}/cases" \
--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: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Trigger: ${{ github.event_name }}"
echo "- Run: ${RUN_URL}"
echo "- Trigger: ${TRIGGER_NAME}"
echo "- Package: ${PACKAGE_SOURCE}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
echo "- Client bootstrap: ${RC_BOOTSTRAP_SUMMARY}"
echo "- Test Step Outcome: ${TEST_OUTCOME}"
echo "- Structured Gate Exit: ${CASE_GATE_RC}"
echo ""
cat "${CASE_TABLE}" || true
cat "${CASE_TABLE}"
echo ""
echo "## Log tail"
echo '```text'
@@ -210,11 +363,11 @@ jobs:
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
- name: Upload functional report to dashboard
if: always()
if: ${{ always() && steps.evidence.outcome == 'success' }}
continue-on-error: true
env:
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
REPORT_FILE: /tmp/rustfs-tier-report.md
REPORT_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-report.md
SUITE: tier
run: |
set -euo pipefail
@@ -224,127 +377,60 @@ jobs:
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}")"
# Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:($content|rtrimstr("\n")), 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}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
rm -f "${B64_FILE}"
cat > /tmp/rustfs-functional-index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustFS Functional Test Reports</title>
<style>
:root { --bg:#f4f6fb; --card:#fff; --text:#1f2937; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
h1 { margin: 0 0 8px; font-size: 26px; }
p { margin: 0 0 14px; color: var(--muted); }
.tabs { display: flex; gap: 10px; margin: 14px 0 18px; flex-wrap: wrap; }
button { border: 1px solid var(--line); background: #fff; color: var(--text); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>RustFS Functional Test Reports</h1>
<p>S3, KMS, Tier report tabs. Each tab lists reports by date.</p>
<div class="tabs" id="tabs"></div>
<ul id="list"></ul>
</div>
</div>
<script>
const suites = [
{ key: 's3', label: 'S3 Compatibility' },
{ key: 'kms', label: 'KMS' },
{ key: 'tier', label: 'Tier' },
{ key: 'heal', label: 'Heal' },
{ key: 'pool', label: 'Pool Expansion' },
{ key: 'security', label: 'Security' },
{ key: 'upgrade', label: 'Upgrade' },
];
const tabs = document.getElementById('tabs');
const list = document.getElementById('list');
async function loadSuite(suite) {
list.innerHTML = '<li>Loading...</li>';
const api = `https://api.github.com/repos/rustfs/dashboard/contents/functional-reports/${suite}`;
try {
const res = await fetch(api);
if (!res.ok) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
const data = await res.json();
const files = data.filter(f => f.type === 'file' && f.name.endsWith('.md')).sort((a,b) => b.name.localeCompare(a.name));
if (!files.length) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
list.innerHTML = files.map(f => `<li><a href="${f.html_url}" target="_blank" rel="noreferrer">${f.name.replace('.md','')}</a></li>`).join('');
} catch (_e) {
list.innerHTML = '<li>Failed to load reports.</li>';
}
}
function setActive(key) {
for (const btn of tabs.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.key === key);
}
loadSuite(key);
}
for (const suite of suites) {
const btn = document.createElement('button');
btn.textContent = suite.label;
btn.dataset.key = suite.key;
btn.addEventListener('click', () => setActive(suite.key));
tabs.appendChild(btn);
}
setActive('s3');
</script>
</body>
</html>
EOF
INDEX_PATH="functional/index.html"
INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${INDEX_SHA}" ]; then
jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
else
jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
- 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 \
rc-bootstrap.json \
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()
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-tier-test-${{ github.run_id }}
path: |
/tmp/rustfs-tier.log
/tmp/rustfs-tier-report.md
if-no-files-found: warn
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()
@@ -367,6 +453,167 @@ jobs:
'
done
- name: Cleanup pinned RustFS CLI
if: always()
run: |
set -euo pipefail
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
rm -f -- "${RC_ROOT}/rustfs-cli.tar.gz" "${RC_ROOT}/rc"
if [ -d "${RC_ROOT}" ]; then
rmdir -- "${RC_ROOT}"
fi
- name: Enforce tier suite result
id: gate
if: always()
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: |
+217 -238
View File
@@ -18,15 +18,15 @@ on:
workflow_dispatch:
inputs:
from_version:
description: 'OLD RustFS release tag (e.g. 1.0.0-rc.4-preview.1)'
description: 'OLD RustFS release tag, e.g. 1.0.0-rc.3 (its release must ship a .deb asset). Leave empty for the default.'
required: false
default: '1.0.0-rc.4-preview.1'
default: '1.0.0-rc.3'
from_url:
description: 'OLD .deb URL. Overrides from_version.'
required: false
type: string
to_version:
description: 'NEW RustFS release tag (leave empty for latest nightly)'
description: 'NEW RustFS release tag, e.g. 1.0.0-rc.5 (any version with a .deb asset). Leave empty for latest nightly.'
required: false
to_url:
description: 'NEW .deb URL. Overrides to_version / nightly default.'
@@ -53,11 +53,9 @@ on:
description: 'Reset the nodes after the test (DESTROYS test data/config)'
type: boolean
default: true
workflow_run:
# Runs first in the functional chain: upgrade compatibility gates the
# nightly suites that follow (S3 -> KMS -> Tier -> Pool/Heal -> Security).
workflows: ["Nightly GNU Build"]
types: [completed]
repository_dispatch:
# Functional-chain entry: dispatched by rustfs-functional-chain.yml.
types: [rustfs-chain-upgrade]
permissions:
contents: read
@@ -81,18 +79,47 @@ env:
jobs:
upgrade-test:
runs-on: smoke-testing
continue-on-error: true
timeout-minutes: 420
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
- name: Checkout repository (for report parser)
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: Initialize functional evidence
id: evidence
run: |
set -euo pipefail
umask 077
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-upgrade-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
{
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
} >> "${GITHUB_ENV}"
# auto-testing is private: clone it with the dedicated PF token (not
# 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: |
@@ -133,9 +160,8 @@ jobs:
- name: Run upgrade compatibility suite
id: test
continue-on-error: true
env:
LOG_FILE: /tmp/rustfs-upgrade.log
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
run: |
set -euo pipefail
chmod +x auto-testing/rustfs-upgrade-test.sh
@@ -166,13 +192,33 @@ jobs:
else
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
fi
# Fail fast with a clear message when a requested release tag has
# no .deb asset (e.g. 1.0.0-rc.4 ships only zips), instead of
# letting the suite die mid-run on a 404.
check_release_asset() {
local version="$1" tag asset url
[ -n "${version}" ] && [ "${version}" != "null" ] || return 0
tag="${version#v}"
asset="rustfs_${tag//-/.}_amd64.deb"
url="https://github.com/rustfs/rustfs/releases/download/${tag}/${asset}"
if ! gh api "repos/rustfs/rustfs/releases/tags/${tag}" --jq '.assets[].name' 2>/dev/null | grep -qxF "${asset}"; then
echo "ERROR: release ${tag} has no downloadable asset ${asset}:" >&2
echo " ${url}" >&2
echo "Pick a tag whose release ships a .deb (check its release assets)." >&2
exit 1
fi
echo "resolved ${tag} -> ${url}"
}
if [ -z "${FROM_URL}" ]; then
check_release_asset "${FROM_VERSION}"
fi
if [ -z "${TO_URL}" ]; then
check_release_asset "${TO_VERSION}"
fi
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
- name: Generate report
if: always()
env:
LOG_FILE: /tmp/rustfs-upgrade.log
REPORT_FILE: /tmp/rustfs-upgrade-report.md
if: ${{ always() && steps.evidence.outcome == 'success' }}
run: |
set -euo pipefail
FROM_URL='${{ inputs.from_url }}'
@@ -193,80 +239,47 @@ jobs:
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
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
MATRIX_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/matrix.md"
CASE_RESULT=success
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" || CASE_RESULT=failure
RESULT=failure
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
RESULT=success
fi
{
echo "# RustFS upgrade compatibility report"
echo ""
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${{ github.event_name }}"
echo "- From: ${FROM_SOURCE}"
echo "- To: ${TO_SOURCE}"
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
echo "- Test Step Outcome: ${RESULT}"
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
echo ""
cat "${CASE_TABLE}" || true
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}" || true
echo '```'
if [ "${RESULT}" = "success" ]; then
cat "${MATRIX_TABLE}"
echo ""
cat "${CASE_TABLE}"
echo ""
echo "## Log tail"
echo '```text'
tail -n 200 "${LOG_FILE}"
echo '```'
else
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
fi
} | tee "${REPORT_FILE}"
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
[ "${RESULT}" = "success" ]
- name: Upload functional report to dashboard
if: always()
if: ${{ always() && steps.evidence.outcome == 'success' }}
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
@@ -276,177 +289,94 @@ jobs:
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}")"
# Base64-encode the report into a temp file and feed it to jq via
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
B64_FILE="$(mktemp)"
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${SHA}" ]; then
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
'{message:$msg, content:($content|rtrimstr("\n")), 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}' \
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
'{message:$msg, content:($content|rtrimstr("\n"))}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
fi
rm -f "${B64_FILE}"
cat > /tmp/rustfs-functional-index.html <<'EOF'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RustFS Functional Test Reports</title>
<style>
:root { --bg:#f4f6fb; --card:#fff; --text:#1f2937; --muted:#6b7280; --line:#e5e7eb; --accent:#0f766e; }
* { box-sizing: border-box; }
body { margin: 0; font-family: ui-sans-serif, -apple-system, Segoe UI, Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); }
.wrap { max-width: 980px; margin: 32px auto; padding: 0 16px; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
h1 { margin: 0 0 8px; font-size: 26px; }
p { margin: 0 0 14px; color: var(--muted); }
.tabs { display: flex; gap: 10px; margin: 14px 0 18px; flex-wrap: wrap; }
button { border: 1px solid var(--line); background: #fff; color: var(--text); border-radius: 10px; padding: 8px 14px; cursor: pointer; }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.report-btn { border: 0; background: transparent; padding: 0; color: var(--accent); }
ul { list-style: none; margin: 0; padding: 0; }
li { padding: 10px 0; border-bottom: 1px solid var(--line); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.meta { margin-top: 16px; border-top: 1px solid var(--line); padding-top: 14px; }
.kv { margin: 6px 0; color: var(--text); }
.muted { color: var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>RustFS Functional Test Reports</h1>
<p>Select a suite and date to view the build version used in that run.</p>
<div class="tabs" id="tabs"></div>
<ul id="list"></ul>
<div class="meta">
<div class="kv"><strong>Date:</strong> <span id="report-date" class="muted">N/A</span></div>
<div class="kv"><strong>RustFS Version:</strong> <span id="report-version" class="muted">N/A</span></div>
<div class="kv"><a id="report-link" href="#" target="_blank" rel="noreferrer">Open report</a></div>
</div>
</div>
</div>
<script>
const suites = [
{ key: 'upgrade', label: 'Upgrade' },
{ key: 's3', label: 'S3 Compatibility' },
{ key: 'kms', label: 'KMS' },
{ key: 'tier', label: 'Tier' },
{ key: 'heal', label: 'Heal' },
{ key: 'pool', label: 'Pool Expansion' },
{ key: 'security', label: 'Security' },
];
const tabs = document.getElementById('tabs');
const list = document.getElementById('list');
const reportDate = document.getElementById('report-date');
const reportVersion = document.getElementById('report-version');
const reportLink = document.getElementById('report-link');
function parseVersion(markdown) {
const m = markdown.match(/^- RustFS Version:\s*(.+)$/m);
return m ? m[1].trim() : 'N/A';
}
async function showReport(report) {
reportDate.textContent = report.name.replace('.md', '');
reportVersion.textContent = 'Loading...';
reportLink.href = report.html_url;
try {
const res = await fetch(report.download_url, { cache: 'no-store' });
if (!res.ok) {
reportVersion.textContent = 'N/A';
return;
}
const text = await res.text();
reportVersion.textContent = parseVersion(text);
} catch (_e) {
reportVersion.textContent = 'N/A';
}
}
async function loadSuite(suite) {
list.innerHTML = '<li>Loading...</li>';
const api = `https://api.github.com/repos/rustfs/dashboard/contents/functional-reports/${suite}`;
try {
const res = await fetch(api);
if (!res.ok) {
list.innerHTML = '<li>No reports yet.</li>';
return;
}
const data = await res.json();
const files = data.filter(f => f.type === 'file' && f.name.endsWith('.md')).sort((a,b) => b.name.localeCompare(a.name));
if (!files.length) {
list.innerHTML = '<li>No reports yet.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
return;
}
list.innerHTML = '';
files.forEach((f) => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.className = 'report-btn';
btn.textContent = f.name.replace('.md', '');
btn.addEventListener('click', () => showReport(f));
li.appendChild(btn);
list.appendChild(li);
});
showReport(files[0]);
} catch (_e) {
list.innerHTML = '<li>Failed to load reports.</li>';
reportDate.textContent = 'N/A';
reportVersion.textContent = 'N/A';
reportLink.href = '#';
}
}
function setActive(key) {
for (const btn of tabs.querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.key === key);
}
loadSuite(key);
}
for (const suite of suites) {
const btn = document.createElement('button');
btn.textContent = suite.label;
btn.dataset.key = suite.key;
btn.addEventListener('click', () => setActive(suite.key));
tabs.appendChild(btn);
}
setActive('upgrade');
</script>
</body>
</html>
EOF
INDEX_PATH="functional/index.html"
INDEX_CONTENT="$(python3 -c 'import base64;print(base64.b64encode(open("/tmp/rustfs-functional-index.html","rb").read()).decode())')"
INDEX_SHA="$(gh api "repos/rustfs/dashboard/contents/${INDEX_PATH}" -q '.sha' 2>/dev/null || true)"
if [ -n "${INDEX_SHA}" ]; then
jq -n --arg msg "functional ui update" --arg content "${INDEX_CONTENT}" --arg sha "${INDEX_SHA}" \
'{message:$msg, content:$content, sha:$sha}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
else
jq -n --arg msg "functional ui init" --arg content "${INDEX_CONTENT}" \
'{message:$msg, content:$content}' \
| gh api --method PUT "repos/rustfs/dashboard/contents/${INDEX_PATH}" --input - >/dev/null
- 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 }}
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
SUITE: 'upgrade'
SUITE_LABEL: 'Upgrade compatibility'
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
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 "- Attempt: ${GITHUB_RUN_ATTEMPT}"
echo "- Workflow Commit: ${GITHUB_SHA}"
echo "- Trigger: ${GITHUB_EVENT_NAME}"
echo "- Date: $(date -u +%Y-%m-%d)"
echo ""
echo "## Report (errors and symptoms)"
echo ""
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
redact < "${REPORT_FILE}"
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -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()
if: ${{ always() && steps.evidence.outcome == 'success' }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-upgrade-test-${{ github.run_id }}
name: rustfs-upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
path: |
/tmp/rustfs-upgrade-report.md
/tmp/rustfs-upgrade.*/*
if-no-files-found: ignore
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/matrix.md
if-no-files-found: error
retention-days: 3
- name: Cleanup environment (after)
@@ -468,6 +398,55 @@ jobs:
'
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: |
@@ -42,6 +42,7 @@ jobs:
- name: Check latest scheduled runs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RUSTFS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set +e
python3 scripts/check_scheduled_validation_freshness.py \
@@ -22,6 +22,7 @@ on:
- "Continuous Integration"
- "coverage"
- "e2e-nightly"
- "e2e-distributed"
- "e2e-s3tests"
- "Fuzz"
- "mint"
+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
+5 -3
View File
@@ -33,6 +33,7 @@ profile.json
*.zst
.secrets
*.go
!crates/zip/tests/fixtures/snowball/**/generate/*.go
*.pb
*.svg
deploy/logs/*.log.*
@@ -55,11 +56,10 @@ docs/*
!docs/architecture/**
!docs/operations/
!docs/operations/**
!docs/postmortems/
!docs/postmortems/**
!docs/testing/
!docs/testing/**
docs/heal-scanner-logging-governance.md
docs/benchmark/rustfs-target-bench/
docs/benchmark/*.md
.codegraph/*
.docker/test/compat/data/*
.docker/test/compat/kms/*
@@ -83,6 +83,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
+3 -3
View File
@@ -3,9 +3,9 @@
repos:
- repo: local
hooks:
- id: rustfs-dev-check
name: rustfs dev-check
entry: make dev-check
- id: rustfs-fmt-check
name: Rust formatting
entry: cargo fmt --all --check
language: system
types: [rust]
pass_filenames: false
+13 -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.
@@ -160,6 +162,12 @@ Risk and review shape:
S3-visible semantics. Cover all applicable lenses using exactly two
independent reviewers when delegation is explicitly authorized. Split the
lenses between them. Otherwise perform two fresh sequential passes.
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
the remote SDK configuration sends to every replication or migration target
is high risk for every target class even when the change fixes one. Follow
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
run the outbound target matrix, document each new env knob in the same PR,
and list verified and unverified target classes in the PR Impact section.
Available domain lenses are security, concurrency/durability, compatibility,
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
+3 -3
View File
@@ -62,7 +62,7 @@ rustfs/ # Workspace root (virtual manifest)
│ ├── utils/ # Pure utility functions
│ ├── ... # (see "Crate Reference" below)
│ └── e2e_test/ # End-to-end integration tests
└── docs/ # Design documents and analysis
└── docs/ # Agent knowledge base: contracts, runbooks, testing rules (index: docs/architecture/README.md)
```
### Main Crate Layers (`rustfs/src/`)
@@ -92,7 +92,7 @@ refactors.
| Domain | Current workspace crates | Responsibility |
|--------|--------------------------|----------------|
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. |
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-metrics`, `utils` | Shared configuration, data-usage models, heal domain contracts, scanner telemetry types, utilities, and checksums. |
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
@@ -135,7 +135,7 @@ default build (lifecycle:
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
collision, not copies; renaming is tracked in rustfs/backlog#1847.
- `LastMinuteLatency` has two deliberately different implementations: the
per-second bucketed accumulator in `crates/scanner-contracts/src/last_minute.rs` and
per-second bucketed accumulator in `crates/scanner-metrics/src/last_minute.rs` and
the in-memory endpoint-health sample tracker in
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
why it stays local).
+10
View File
@@ -12,6 +12,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Per-pool erasure parity**: Erasure parity (STANDARD and reduced-redundancy) is now resolved independently for every pool instead of reusing the first pool's value. A heterogeneous topology — for example a 4-drive pool plus a 2-drive pool created during expansion — previously inherited the first pool's parity and could resolve to zero data shards in the smaller pool, panicking Reed-Solomon construction on write. Automatic parity now resolves per pool (for example `2+2` in the 4-drive pool and `1+1` in the 2-drive pool). Fixes #4801.
### Added
- **On-Demand Migration**: Lazy, pull-style migration of an existing S3-compatible bucket into RustFS. A local bucket is attached to an external source bucket; a GET for a key that does not exist locally fetches it from the source, streams it to the client, and stores it locally in the same pass, so every later read is served locally. The module is on by default; set `RUSTFS_ON_DEMAND_MIGRATION_ENABLED=false` on every node to turn it off. A bucket with no source configured behaves exactly as before — the runtime never intervenes on its reads and makes no outbound call. Operator guide at `docs/operations/on-demand-migration.md`.
- Per-bucket configuration persisted as `on-demand-migration.json` in the bucket metadata: source provider (`s3`, `aws`, `minio`, `rustfs`, `r2`, `gcs`), endpoint, region, addressing style, credentials and TLS material, an optional key-prefix filter and source-prefix rewrite, and a policy block covering the inline size threshold, multipart part size, concurrency, queue capacity, timeouts, bandwidth limit and negative-cache TTL
- Admin routes under `/rustfs/admin/v3/on-demand-migration/{bucket}`: `PUT` (with `?dry-run=true` to validate and probe the source without saving), `GET`, `DELETE`, `GET .../status`, plus `POST .../backfill?op=start|cancel` and `GET .../backfill` for the background full-backfill job with its resumable checkpoint. Authorized by the new `admin:GetBucketOnDemandMigration` and `admin:SetBucketOnDemandMigration` actions; every response redacts `secret_key` and `session_token`
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
- Listings: `ListObjects` v1 remains local with ordinary key markers. `ListObjectsV2` can merge source objects when `policy.list_through = true`; this is off by default
- Upgrade and rollback: finish upgrading every node before enabling ODM. An rc.5 node that writes bucket configuration drops the ODM fields from metadata; neither a later restart nor moving the service out of ECStore recovers them. Before rollback, disable ODM and securely retain the original full configuration and credentials. After every node returns to a compatible version, restore and validate that configuration. Redacted exports cannot replace the credential backup; source-only objects are unavailable through RustFS while ODM is disabled. See the upgrade and rollback section of `docs/operations/on-demand-migration.md`
- Optional Google dependencies: default and `full` server builds retain native GCS support. `cargo build -p rustfs --no-default-features --features ftps,webdav` excludes Google SDKs while preserving configuration decoding and redaction; native GCS ODM and tier operations require the `gcs` feature. Do not use that build with existing GCS-tiered data
- Limitations: PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
+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)
+28 -41
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,34 +96,30 @@ 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)
Git hooks are **not** versioned in this repository, so a fresh clone has no
active pre-commit hook. If you add your own `.git/hooks/pre-commit` (a good
choice is a one-liner that runs `make pre-commit`), you can mark it executable
with:
The optional hook uses the checked-in `.pre-commit-config.yaml`. Install [pre-commit](https://pre-commit.com/#installation), then run this from the checkout or a linked worktree:
```bash
make setup-hooks
```
Or manually:
The hook runs `cargo fmt --all --check` when staged files include Rust source. It does not compile the workspace or run tests. Fix formatting with `cargo fmt --all`, inspect and stage the result, then commit again.
```bash
chmod +x .git/hooks/pre-commit
```
`pre-commit install` resolves Git's hook directory for linked worktrees and preserves an existing hook in migration mode. If you use `core.hooksPath`, keep that hook manager and integrate `pre-commit run` there; the installer refuses to silently replace that configuration.
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.
A local hook provides early formatting feedback. With or without it, follow the verification tiers in `AGENTS.md`, run relevant behavioral tests, and satisfy the CI merge gates. `make pre-commit` and `make dev-check` remain explicit broader commands.
### 📝 Formatting Configuration
@@ -127,34 +131,16 @@ fn_call_width = 90
single_line_let_else_max_width = 100
```
### 🚫 Commit Prevention
If you set up a pre-commit hook and your code doesn't meet the formatting requirements, the hook will:
1. **Block the commit** and show clear error messages
2. **Provide exact commands** to fix the issues
3. **Guide you through** the resolution process
Example output when formatting fails:
```
❌ Code formatting check failed!
💡 Please run 'cargo fmt --all' to format your code before committing.
🔧 Quick fix:
cargo fmt --all
git add .
git commit
```
### 🔄 Development Workflow
1. **Make your changes**
2. **Format your code**: `make fmt` or `cargo fmt --all`
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
3. **Select relevant checks** using the validation tier in `AGENTS.md`; use `make pre-commit` when its broader fast gate adds useful coverage
4. **Commit your changes**: `git commit -m "your message"`
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
@@ -193,11 +179,12 @@ Configure your IDE to:
#### Pre-commit hook not running?
```bash
# Check if hook is executable
ls -la .git/hooks/pre-commit
# Make it executable if needed
chmod +x .git/hooks/pre-commit
pre-commit validate-config
pre-commit run --all-files
# Inspect any configured hook manager; do not overwrite it.
git config --get core.hooksPath
# Install if no separate hook manager is configured.
make setup-hooks
```
#### Formatting issues?
Generated
+552 -268
View File
File diff suppressed because it is too large Load Diff
+36 -29
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
@@ -51,7 +52,7 @@ members = [
"crates/s3select-api", # S3 Select API interface
"crates/s3select-query", # S3 Select query engine
"crates/scanner", # Scanner for data integrity checks and health monitoring
"crates/scanner-contracts", # Scanner metrics and cycle contracts
"crates/scanner-metrics", # Scanner metrics and cycle telemetry
"crates/security-governance", # Security governance contracts
"crates/extension-schema", # Extension schema contracts
"crates/signer", # client signer
@@ -71,7 +72,7 @@ resolver = "3"
edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/rustfs/rustfs"
rust-version = "1.97.1"
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. "
@@ -92,7 +93,7 @@ redundant_clone = "warn"
rustfs = { path = "./rustfs", version = "1.0.0-rc.5" }
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.5" }
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.5" }
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.5" }
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.0-rc.5" }
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.5" }
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.5" }
rustfs-common = { path = "crates/common", version = "1.0.0-rc.5" }
@@ -105,6 +106,7 @@ 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" }
@@ -142,8 +144,8 @@ 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" }
async-compression = { version = "0.4.43" }
mysql_async = { default-features = false, version = "0.37.1" }
async-compression = { version = "0.4.44" }
async-recursion = "1.1.1"
async-trait = "0.1.92"
async-nats = { version = "0.50.0", default-features = false }
@@ -163,10 +165,10 @@ http-body = "1.1.0"
http-body-util = "0.1.5"
minlz = "1.2.3"
reqwest = "0.13.4"
rustfs-kafka-async = { version = "1.2.0" }
rustfs-kafka-async = { version = "1.3.1" }
socket2 = { version = "0.6.5" }
tokio = { version = "1.53.1" }
tokio-rustls = { default-features = false, version = "0.26.4" }
tokio-rustls = { default-features = false, version = "0.26.5" }
tokio-stream = { version = "0.1.19" }
tokio-test = "0.4.5"
tokio-util = { version = "0.7.19" }
@@ -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"] }
@@ -197,10 +199,10 @@ serde_urlencoded = "0.7.1"
# matching stable releases are not available yet, while previous stable lines
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
# releases.
aes-gcm = { version = "=0.11.1" }
argon2 = { version = "=0.6.0" }
blake2 = "=0.11.0"
chacha20poly1305 = { version = "=0.11.0" }
aes-gcm = { version = "0.11.1" }
argon2 = { version = "0.6.0" }
blake2 = "0.11.0"
chacha20poly1305 = { version = "0.11.0" }
crc-fast = "1.10.0"
hmac = { version = "0.13.0" }
jsonwebtoken = { version = "11.0.0" }
@@ -232,20 +234,24 @@ tokio-postgres-rustls = "0.14.0"
# Utilities and Tools
anyhow = "1.0.104"
arc-swap = "1.9.2"
# RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin until bounded extension parsing is released upstream. Remove after astral-sh/tokio-tar#118 is merged and a published tokio-tar release exposes the extension limits used here.
# RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin while Snowball and Swift still depend on it. Remove after Snowball uses a released tar-codec/tar-framing API that exposes precedence-resolved MinIO vendor records, RustFS preserves cancellation-safe ownership of large streamed members, footerless minio-go input is accepted only at an authenticated complete request boundary, the existing resource-limit, cancellation, and error-fuse regressions pass, and Swift no longer needs this fork.
astral-tokio-tar = { git = "https://github.com/cxymds/tokio-tar.git", rev = "603756478b7668436e464519c77ccac22a99ba96" }
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
tar-codec = "0.0.14"
tar-framing = "0.0.14"
atoi = "3.1.0"
atomic_enum = "0.3.0"
aws-config = { version = "1.11.0" }
aws-config = { version = "1.12.0" }
aws-credential-types = { version = "1.3.0" }
aws-sdk-kms = { default-features = false, version = "1.117.0" }
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
aws-sdk-sts = { default-features = false, version = "1.113.0" }
aws-sdk-kms = { default-features = false, version = "1.118.0" }
aws-sdk-s3 = { default-features = false, version = "1.145.0" }
aws-sdk-sts = { default-features = false, version = "1.114.0" }
aws-smithy-async = { version = "1.3.0" }
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
aws-smithy-runtime-api = { version = "1.15.0" }
aws-smithy-types = { version = "1.6.2" }
aws-smithy-runtime-api = { version = "1.16.0" }
aws-smithy-types = { version = "1.6.3" }
base64-simd = "0.8.0"
brotli = "8.0.4"
brotli = "9.0.0"
clap = { version = "4.6.6" }
const-str = { version = "1.1.0" }
convert_case = "0.12.0"
@@ -283,7 +289,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"
@@ -296,7 +302,7 @@ pretty_assertions = "1.4.1"
rand = { version = "0.10.2" }
ratelimit = "2.0.0"
rayon = "1.12.0"
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
rustfs-erasure-codec = { version = "8.0.2" }
reed-solomon-simd = "3.1.0"
regex = { version = "1.13.1" }
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
@@ -305,11 +311,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 = "9c4690d8e73fc8d184031a19b2c4539ebc77d180", 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" }
@@ -337,7 +343,7 @@ windows = { version = "0.62.2" }
windows-sys = "0.61.2"
xxhash-rust = { version = "0.8.18" }
zip = "8.6.0"
zstd = "0.13.3"
zstd = "0.14.0"
# Observability and Metrics
metrics = "0.24.6"
@@ -355,17 +361,18 @@ pyroscope = { version = "2.1.1" }
# FTP and SFTP
libunftp = { version = "0.23.0" }
unftp-core = "0.1.0"
suppaftp = { version = "10.0.2" }
suppaftp = { version = "11.0.0" }
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
russh = { version = "0.63.1" }
russh = { version = "0.63.2" }
russh-sftp = "2.4.0"
# WebDAV
dav-server = "0.11.0"
# Performance Analysis and Memory Profiling
rustfs-mimalloc = { version = "0.5.1" }
hotpath = { version = "0.24.0", default-features = false }
rustfs-mimalloc = { version = "0.5.3" }
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
hotpath = { version = "=0.25.0", default-features = false }
# Snapshot testing for output format regression detection
insta = { version = "1.48" }
+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
+27 -10
View File
@@ -48,16 +48,33 @@ Unlike other storage systems, RustFS is released under the permissible Apache 2.
- **Open Source**: Licensed under Apache 2.0, encouraging unrestricted community contributions and commercial usage.
- **User-Friendly**: Designed with simplicity in mind for easy deployment and management.
| Feature | Status | Feature | Status |
| :---------------------- | :----------- | :----------------------- | :--------------- |
| **S3 Core Features** | ✅ Available | **Bitrot Protection** | ✅ Available |
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
| **Versioning** | ✅ Available | **Bucket Replication** | ✅ Available |
| **Logging** | ✅ Available | **Lifecycle Management** | 🚧 Under Testing |
| **Event Notifications** | ✅ Available | **Distributed Mode** | 🚧 Under Testing |
| **K8s Helm Charts** | ✅ Available | **RustFS KMS** | 🚧 Under Testing |
| **Keystone Auth** | ✅ Available | **Multi-Tenancy** | ✅ Available |
| **Swift API** | ✅ Available | **Swift Metadata Ops** | 🚧 Partial |
Status legend: ✅ Available — shipped and covered by CI gates; 🧪 Preview — shipped behind an opt-in flag or with a bounded compatibility claim.
| Feature | Status | Feature | Status |
| :------------------------------- | :----------- | :--------------------------------- | :----------- |
| **S3 Core Features** | ✅ Available | **Distributed Mode** | ✅ Available |
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
| **Versioning** | ✅ Available | **Bitrot Protection** | ✅ Available |
| **Object Lock (WORM)** | ✅ Available | **Healing & Scanner** | ✅ Available |
| **Server-Side Encryption** | ✅ Available | **Pool Expansion / Decommission** | ✅ Available |
| **RustFS KMS** | ✅ Available | **Bucket Replication** | ✅ Available |
| **Lifecycle Management (ILM)** | ✅ Available | **Site Replication** | ✅ Available |
| **ILM Tiering (Remote S3)** | ✅ Available | **Bucket Quota** | ✅ Available |
| **S3 Select** | ✅ Available | **Event Notifications** | ✅ Available |
| **S3 Tables (Iceberg REST)** | 🧪 Preview | **Audit Logging** | ✅ Available |
| **IAM / Policies** | ✅ Available | **Logging & Observability** | ✅ Available |
| **OIDC / SSO** | ✅ Available | **Web Console** | ✅ Available |
| **Keystone Auth** | ✅ Available | **K8s Helm Charts** | ✅ Available |
| **Swift API** | ✅ Available | **FTPS / WebDAV** | ✅ Available |
| **Multi-Tenancy** | ✅ Available | **SFTP** | ✅ Available |
| **MinIO On-Disk Compatibility** | 🧪 Preview | | |
Notes:
- **RustFS KMS**: Vault (KV2 / Transit) and AWS KMS backends are supported for production. The `Local` and `Static` backends are for development and testing only. See [KMS backend security properties](docs/operations/kms-backend-security.md).
- **Swift API / SFTP**: opt-in cargo features (`--features swift`, `--features sftp`, or `full`). FTPS and WebDAV are enabled in the default build.
- **S3 Tables**: ships as an Iceberg REST Catalog with automated PyIceberg and DuckDB coverage; other engines and vendor profiles carry bounded claims listed in the [S3 Tables support matrix](docs/architecture/s3-tables-support-matrix.md).
- **MinIO On-Disk Compatibility**: gated behind the `rio-v2` feature and not part of the default build. Objects MinIO encrypted are not readable by RustFS. See [MinIO file-format interoperability](docs/architecture/minio-file-format-compat.md).
## RustFS vs MinIO Performance
+15
View File
@@ -130,6 +130,21 @@ Scanner cycle budget controls:
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
## Remote tier timeout environment variables
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
- remote tier TCP connect timeout.
- default is `10`.
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
- `RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS`
- remote tier request timeout through response headers.
- default is `86400` so large transition uploads keep a production-safe budget.
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default. Very large values are accepted and act as a correspondingly long budget.
- `RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS`
- maximum idle time between remote tier response-body chunks.
- default is `60`; the timer resets only when non-empty body data keeps progressing.
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
## Drive timeout environment variables
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
+59
View File
@@ -137,6 +137,28 @@ pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
/// Environment variable for remote tier TCP connect timeout in seconds.
pub const ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS";
/// Default remote tier TCP connect timeout in seconds.
pub const DEFAULT_TIER_REMOTE_CONNECT_TIMEOUT_SECS: u64 = 10;
/// Environment variable for the remote tier request timeout in seconds.
///
/// This bounds upload/download request progress through response headers. The
/// default is intentionally large so multi-TiB transition uploads keep their
/// previous production budget while black-hole remotes no longer wait forever.
pub const ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS";
/// Default remote tier request timeout in seconds.
pub const DEFAULT_TIER_REMOTE_REQUEST_TIMEOUT_SECS: u64 = 24 * 60 * 60;
/// Environment variable for remote tier response-body idle timeout in seconds.
///
/// The timer is re-armed on every non-empty response-body chunk, so slow but
/// progressing remotes can continue while silent response bodies are cancelled.
pub const ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS";
/// Default remote tier response-body idle timeout in seconds.
pub const DEFAULT_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: u64 = 60;
/// Request the object-transaction fencing contract used by storage-owned
/// cleanup receipts and lock-window optimizations.
///
@@ -197,6 +219,27 @@ pub const DEFAULT_POOL_META_V3_FLEET_CONFIRMED: bool = false;
const _: () = assert!(!DEFAULT_POOL_META_V3_WRITE);
const _: () = assert!(!DEFAULT_POOL_META_V3_FLEET_CONFIRMED);
/// Maximum unpacked size accepted for one Snowball archive member.
///
/// The value is expressed in bytes. Invalid values use the default, while
/// valid values are clamped to [`MAX_SNOWBALL_ENTRY_BYTES`].
pub const ENV_SNOWBALL_MAX_ENTRY_BYTES: &str = "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES";
pub const DEFAULT_SNOWBALL_MAX_ENTRY_BYTES: u64 = 1024 * 1024 * 1024;
pub const MAX_SNOWBALL_ENTRY_BYTES: u64 = 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
/// Maximum cumulative unpacked object bytes accepted from one Snowball
/// archive request.
///
/// This does not include tar headers or bounded PAX metadata. The value is
/// expressed in bytes and is clamped to
/// [`MAX_SNOWBALL_UNPACKED_BYTES`].
pub const ENV_SNOWBALL_MAX_UNPACKED_BYTES: &str = "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES";
pub const DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES: u64 = 10 * 1024 * 1024 * 1024;
pub const MAX_SNOWBALL_UNPACKED_BYTES: u64 = 10 * 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
const _: () = assert!(DEFAULT_SNOWBALL_MAX_ENTRY_BYTES <= MAX_SNOWBALL_ENTRY_BYTES);
const _: () = assert!(DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES <= MAX_SNOWBALL_UNPACKED_BYTES);
// =============================================================================
// Concurrent Request Fix - Timeout and Backpressure Configuration
// =============================================================================
@@ -791,6 +834,16 @@ mod remote_version_state_tests {
);
}
#[test]
fn remote_tier_timeout_env_names_are_stable() {
assert_eq!(super::ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS");
assert_eq!(super::ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS");
assert_eq!(
super::ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS,
"RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS"
);
}
#[test]
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
@@ -820,4 +873,10 @@ mod remote_version_state_tests {
assert_eq!(super::ENV_POOL_META_V3_WRITE, "RUSTFS_POOL_META_V3_WRITE");
assert_eq!(super::ENV_POOL_META_V3_FLEET_CONFIRMED, "RUSTFS_POOL_META_V3_FLEET_CONFIRMED");
}
#[test]
fn snowball_limit_environment_names_are_stable() {
assert_eq!(super::ENV_SNOWBALL_MAX_ENTRY_BYTES, "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES");
assert_eq!(super::ENV_SNOWBALL_MAX_UNPACKED_BYTES, "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES");
}
}
+14 -9
View File
@@ -25,7 +25,8 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
| **distributed 4×4** | [`src/distributed/`](src/distributed) | Storage-sensitive PR and nightly `e2e-distributed` lane: S3, object lock/WORM, versioning, bucket/site replication, quota, expand/decommission/rebalance, concurrency, chaos, 4-node upgrade of historical data and IAM AK/SK. Map: [`docs/testing/distributed-e2e.md`](../../docs/testing/distributed-e2e.md) |
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
@@ -169,8 +170,9 @@ 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) |
| Distributed 4-node 4-disk (`e2e-distributed` profile) | `.github/workflows/e2e-distributed.yml` | **Active** (storage-sensitive PR / nightly / dispatch) |
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
@@ -191,6 +193,9 @@ cargo nextest run --profile e2e-smoke -p e2e_test
cargo nextest run --profile e2e-full -p e2e_test
# Cluster fault nightly lane
cargo nextest run --profile e2e-nightly -p e2e_test
# 4-node 4-disk distributed lane (S3 / lock / versioning / replication / decommission / chaos / upgrade)
# Upgrade cases need RUSTFS_UPGRADE_SOURCE_BINARY; without it they fail closed.
cargo nextest run --profile e2e-distributed -p e2e_test
# Replication nightly lane; awscurl is required for STS paths
cargo nextest run --profile e2e-repl-nightly -p e2e_test
# Fixed-port protocol nightly lane
@@ -233,8 +238,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 +276,12 @@ Note on `#[serial]`: nextest runs each test in its own process, so
parallel-safe by construction (random port + isolated temp dir), which the
current subset is.
### Authoritative test inventory
### Test inventory
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
moving e2e tests so acceptance numbers in the test-strategy issues
(backlog#1147#1155) stay auditable. When a profile membership change is
Per-module counts are not committed; list them with
`cargo nextest list -p e2e_test --profile <profile>` (the result is
platform-dependent because some modules are linux-only; the `jq` one-liner is
in `docs/testing/README.md`). When a profile membership change is
intentional, review its JSON listing before updating the matching
`.config/e2e-*-selection.txt` test-ID digest. Update only the platform that
produced the listing:
+331 -1
View File
@@ -22,7 +22,10 @@ mod tests {
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
use aws_sdk_s3::types::{
ChecksumAlgorithm, ChecksumMode, ChecksumType as SdkChecksumType, CompletedMultipartUpload, CompletedPart,
ServerSideEncryption,
};
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
use md5::{Digest as Md5Digest, Md5};
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
@@ -260,6 +263,117 @@ mod tests {
info!("PASSED: HeadObject returns stored SHA256 digest");
}
#[tokio::test]
async fn test_head_object_returns_sse_s3_checksum() {
init_logging();
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server_with_env(
vec![],
&[
("RUSTFS_SSE_S3_MASTER_KEY", "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="),
("RUSTFS_CONSOLE_ENABLE", "false"),
],
)
.await
.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-sse-s3-checksum-head";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
let put = client
.put_object()
.bucket(bucket)
.key("encrypted.txt")
.body(ByteStream::from_static(b"encrypted checksum"))
.server_side_encryption(ServerSideEncryption::Aes256)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("SSE-S3 PutObject with CRC32 failed");
let expected = put.checksum_crc32().expect("PutObject must return CRC32");
let head = client
.head_object()
.bucket(bucket)
.key("encrypted.txt")
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("SSE-S3 HeadObject failed");
assert_eq!(head.checksum_crc32(), Some(expected));
client
.copy_object()
.bucket(bucket)
.key("encrypted-copy.txt")
.copy_source(format!("{bucket}/encrypted.txt"))
.server_side_encryption(ServerSideEncryption::Aes256)
.send()
.await
.expect("SSE-S3 CopyObject failed");
let copy_head = client
.head_object()
.bucket(bucket)
.key("encrypted-copy.txt")
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("SSE-S3 copied HeadObject failed");
assert_eq!(copy_head.checksum_crc32(), Some(expected));
let multipart_key = "encrypted-multipart.txt";
let create = client
.create_multipart_upload()
.bucket(bucket)
.key(multipart_key)
.server_side_encryption(ServerSideEncryption::Aes256)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("SSE-S3 CreateMultipartUpload with CRC32 failed");
let upload_id = create.upload_id().expect("CreateMultipartUpload must return an upload ID");
let part = client
.upload_part()
.bucket(bucket)
.key(multipart_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from_static(b"encrypted multipart checksum"))
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.expect("SSE-S3 UploadPart with CRC32 failed");
let completed_part = CompletedPart::builder()
.part_number(1)
.e_tag(part.e_tag().expect("UploadPart must return an ETag"))
.checksum_crc32(part.checksum_crc32().expect("UploadPart must return CRC32"))
.build();
let complete = client
.complete_multipart_upload()
.bucket(bucket)
.key(multipart_key)
.upload_id(upload_id)
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
.send()
.await
.expect("SSE-S3 CompleteMultipartUpload with CRC32 failed");
let expected_multipart = complete.checksum_crc32().expect("CompleteMultipartUpload must return CRC32");
let multipart_head = client
.head_object()
.bucket(bucket)
.key(multipart_key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("SSE-S3 multipart HeadObject failed");
assert_eq!(multipart_head.checksum_crc32(), Some(expected_multipart));
}
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
/// Uses part size >= 5MB (server minimum) for two parts.
#[tokio::test]
@@ -485,6 +599,222 @@ mod tests {
Some(full_checksum.as_str()),
"Multipart object should report the same full-object CRC64NVME as direct upload"
);
assert_eq!(
multipart_head.checksum_type(),
Some(&SdkChecksumType::FullObject),
"Multipart object with a full-object checksum must report FULL_OBJECT"
);
}
/// Create a CRC32 FULL_OBJECT multipart upload and upload every part, returning
/// the upload id and the `CompletedPart` list ready for CompleteMultipartUpload.
async fn start_full_object_crc32_upload(
client: &Client,
bucket: &str,
key: &str,
parts: &[&Vec<u8>],
) -> (String, Vec<CompletedPart>) {
let create_result = client
.create_multipart_upload()
.bucket(bucket)
.key(key)
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.checksum_type(SdkChecksumType::FullObject)
.send()
.await
.expect("Failed to create multipart upload");
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
let mut completed_parts = Vec::new();
for (index, part) in parts.iter().enumerate() {
let part_number = index as i32 + 1;
let uploaded = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(part_number)
.body(ByteStream::from((*part).clone()))
.checksum_algorithm(ChecksumAlgorithm::Crc32)
.send()
.await
.unwrap_or_else(|e| panic!("Failed to upload part {part_number}: {e:?}"));
completed_parts.push(
CompletedPart::builder()
.part_number(part_number)
.e_tag(uploaded.e_tag().expect("No etag for part"))
.checksum_crc32(uploaded.checksum_crc32().expect("No CRC32 for part"))
.build(),
);
}
(upload_id, completed_parts)
}
/// A multipart upload completed with a **full-object** checksum must report
/// `x-amz-checksum-type: FULL_OBJECT` on both GET and HEAD, the way AWS does.
///
/// `complete_multipart_upload` used to persist the object-level checksum
/// record with the pre-merge checksum type, so the MULTIPART /
/// INCLUDES_MULTIPART flags never reached disk. `rustfs_rio::read_checksums`
/// only emits the FULL_OBJECT entry inside its MULTIPART branch, so these
/// objects came back from GET and HEAD with no checksum-type header at all.
/// Found while root-causing rustfs#6825.
#[tokio::test]
async fn test_full_object_multipart_reports_full_object_checksum_type() {
init_logging();
info!("TEST: full-object multipart upload round-trips x-amz-checksum-type: FULL_OBJECT");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-full-object-checksum-type";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
const PART_SIZE: usize = 5 * 1024 * 1024;
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 241) as u8).collect();
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 29) % 241) as u8).collect();
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
// CRC32 with an explicit FULL_OBJECT type: the object checksum is the
// CRC32 of the whole object, not the composite hash of the part digests.
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
.expect("crc32 checksum")
.encoded;
let key = "full-object-multipart.bin";
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
// Restate the full-object intent and value on CompleteMultipartUpload,
// exactly as an AWS SDK client does: `x-amz-checksum-type: FULL_OBJECT`
// plus `x-amz-checksum-crc32`, with no `x-amz-checksum-algorithm`
// header (CompleteMultipartUpload has no such member).
.checksum_type(SdkChecksumType::FullObject)
.checksum_crc32(full_object_crc32.clone())
.send()
.await
.expect("Failed to complete multipart upload");
let head = client
.head_object()
.bucket(bucket)
.key(key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("Failed to head object");
assert_eq!(
head.checksum_type(),
Some(&SdkChecksumType::FullObject),
"HeadObject must report x-amz-checksum-type: FULL_OBJECT"
);
assert_eq!(
head.checksum_crc32(),
Some(full_object_crc32.as_str()),
"HeadObject must report the full-object CRC32, with no -<parts> suffix"
);
let get = client
.get_object()
.bucket(bucket)
.key(key)
.checksum_mode(ChecksumMode::Enabled)
.send()
.await
.expect("Failed to get object");
assert_eq!(
get.checksum_type(),
Some(&SdkChecksumType::FullObject),
"GetObject must report x-amz-checksum-type: FULL_OBJECT"
);
assert_eq!(
get.checksum_crc32(),
Some(full_object_crc32.as_str()),
"GetObject must report the full-object CRC32, with no -<parts> suffix"
);
let body = get.body.collect().await.expect("Failed to read body").into_bytes();
assert_eq!(body.as_ref(), content.as_slice(), "GetObject body must match the uploaded content");
info!("PASSED: full-object multipart reports FULL_OBJECT on GET and HEAD");
}
/// Declaring a checksum type on CompleteMultipartUpload that contradicts the
/// one recorded at CreateMultipartUpload must be rejected, and rejected as a
/// client error (4xx), not a server error.
#[tokio::test]
async fn test_complete_multipart_rejects_contradicting_checksum_type() {
init_logging();
info!("TEST: CompleteMultipartUpload rejects a checksum type that contradicts the upload");
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
let client = create_s3_client(&env);
let bucket = "test-checksum-type-mismatch";
create_bucket(&client, bucket).await.expect("Failed to create bucket");
const PART_SIZE: usize = 5 * 1024 * 1024;
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 239) as u8).collect();
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 31) % 239) as u8).collect();
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
.expect("crc32 checksum")
.encoded;
let key = "checksum-type-mismatch.bin";
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
let err = client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
// The upload was created as FULL_OBJECT; claiming COMPOSITE here
// contradicts it.
.checksum_type(SdkChecksumType::Composite)
.checksum_crc32(full_object_crc32.clone())
.send()
.await
.expect_err("COMPOSITE on a FULL_OBJECT upload must be rejected");
let service_err = err.into_service_error();
let code = service_err.meta().code().unwrap_or("<no code>").to_string();
let message = service_err.meta().message().unwrap_or_default().to_string();
// Before the fix the storage layer refused the combination with a generic
// error and the caller got `500 InternalError` -- "please try again" for a
// request that can only ever fail.
assert_eq!(
code, "InvalidRequest",
"a contradicting checksum type is a client error, got {code}: {message}"
);
assert!(
message.contains("FULL_OBJECT") && message.contains("COMPOSITE"),
"the message must name the recorded and requested types, got {message}"
);
// The upload is untouched by the rejected completion, so a well-formed
// retry on the same upload id still succeeds.
let listed = client
.list_parts()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.send()
.await
.expect("upload must survive the rejected completion");
assert_eq!(listed.parts().len(), 2, "both parts must still be listed after the rejection");
info!("PASSED: contradicting checksum type rejected as InvalidRequest");
}
/// Integration test for the AWS 2026-04 additional checksum algorithms
+149 -31
View File
@@ -1469,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() {
@@ -1509,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)
@@ -1539,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(())
}
@@ -1689,6 +1699,114 @@ impl RustFSTestClusterEnvironment {
process.wait()?;
Ok(())
}
/// Append a new single-node erasure pool to a stopped multi-pool cluster.
///
/// Used to simulate pool expansion on localhost: every pool already owns
/// exactly one node with `drives_per_node >= 2` (the only multi-pool layout
/// the single-host `RUSTFS_VOLUMES` syntax can express). The new node is
/// allocated a fresh port and empty drive directories; callers must
/// [`Self::start`] afterwards so every process picks up the extended
/// volumes argument. Existing data directories are left untouched.
pub async fn append_single_node_pool(&mut self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
if self.nodes.iter().any(|node| node.process.is_some()) {
return Err("stop the cluster before appending a pool".into());
}
if self.topology.drives_per_node < 2 {
return Err(
"append_single_node_pool requires drives_per_node >= 2 (the server parser rejects a single-drive ellipses pool)"
.into(),
);
}
let mut pools = self.topology.normalized_pools();
for (pool_idx, nodes) in pools.iter().enumerate() {
if nodes.len() != 1 {
return Err(format!(
"pool {pool_idx} spans {} nodes; append_single_node_pool requires one node per pool",
nodes.len()
)
.into());
}
}
let new_idx = self.nodes.len();
let port = RustFSTestEnvironment::find_available_port().await?;
let address = format!("127.0.0.1:{port}");
let data_dirs: Vec<String> = (0..self.topology.drives_per_node)
.map(|drive| format!("{}/node{}/drive{}", self.temp_dir, new_idx, drive))
.collect();
for dir in &data_dirs {
fs::create_dir_all(dir).await?;
}
self.nodes.push(ClusterNode {
url: format!("http://{address}"),
address,
data_dir: data_dirs[0].clone(),
data_dirs,
pool_idx: pools.len(),
process: None,
});
pools.push(vec![new_idx]);
self.topology.node_count = self.nodes.len();
self.topology.pools = pools;
self.node_extra_env.push(Vec::new());
self.node_capture_log_paths.push(None);
self.volume_proxy_addresses.push(None);
if !self.extra_env.iter().any(|(key, _)| key == "RUSTFS_UNSAFE_BYPASS_DISK_CHECK") {
self.extra_env
.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
}
Ok(new_idx)
}
/// Gracefully stop one cluster node and wait for its process to exit.
///
/// 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 {
@@ -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(())
}
}
@@ -0,0 +1,222 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
wait_for_ready, wait_until,
};
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
use crate::common::{build_test_s3_config, init_logging};
use crate::fault_proxy::FaultMode;
use aws_sdk_s3::Client;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Barrier, mpsc};
use tokio::time::timeout;
#[tokio::test]
async fn kill_and_restart_node_preserves_objects() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("killnode");
dist.create_bucket(&bucket).await?;
let body = vec![0x11u8; 128 * 1024];
put_object(&dist.client(0)?, &bucket, "keep.bin", body.clone()).await?;
dist.cluster.stop_node(3)?;
retrying_get_equals(&dist.client(0)?, &bucket, "keep.bin", &body, Duration::from_secs(20)).await?;
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
assert_object_bytes(&dist.client(3)?, &bucket, "keep.bin", &body).await?;
Ok(())
}
#[tokio::test]
async fn full_cluster_restart_preserves_objects() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("pwr");
dist.create_bucket(&bucket).await?;
let body = vec![0x44u8; 64 * 1024];
put_object(&dist.client(1)?, &bucket, "survive.bin", body.clone()).await?;
dist.cluster.stop();
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
for node_idx in 0..dist.cluster.nodes.len() {
assert_object_bytes(&dist.client(node_idx)?, &bucket, "survive.bin", &body).await?;
}
Ok(())
}
#[tokio::test]
async fn fresh_drive_replacement_is_physically_healed_without_data_change() -> TestResult {
init_logging();
let mut dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_HEAL_ENABLED", "true")]).await?;
let bucket = unique_bucket("baddrive");
dist.create_bucket(&bucket).await?;
let body = payload_for("fresh-drive/durable.bin", 8 * 1024 * 1024);
put_object(&dist.client(1)?, &bucket, "durable.bin", body.clone()).await?;
let replaced_drive = PathBuf::from(&dist.cluster.nodes[0].data_dirs[0]);
let baseline = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
assert!(
baseline.is_complete(),
"replacement target did not hold a complete baseline shard: {baseline:?}"
);
assert!(
!baseline.expected_part_numbers.is_empty(),
"replacement witness must use physical part shards: {baseline:?}"
);
dist.cluster.stop_node(0)?;
let format_path = replaced_drive.join(".rustfs.sys/format.json");
let format = std::fs::read(&format_path)?;
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
std::fs::rename(&replaced_drive, &retired_drive)?;
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path omitted parent")?)?;
std::fs::write(&format_path, format)?;
let empty = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
assert!(!empty.has_xl_meta, "fresh replacement unexpectedly retained object metadata: {empty:?}");
dist.cluster.start_node(0).await?;
wait_for_ready(&dist.cluster).await?;
let heal_body =
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[1].url);
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
wait_until(
Duration::from_secs(90),
|| async {
let healed = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
Ok(healed.matches_manifest(&baseline))
},
"fresh replacement contains the original complete shard manifest",
)
.await?;
for node_idx in 0..dist.cluster.nodes.len() {
assert_object_bytes(&dist.client(node_idx)?, &bucket, "durable.bin", &body).await?;
}
Ok(())
}
#[tokio::test]
async fn concurrent_gets_survive_peer_node_kill() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("getkill");
dist.create_bucket(&bucket).await?;
let body = payload_for("inflight/steady.bin", 8 * 1024 * 1024);
put_object(&dist.client(0)?, &bucket, "steady.bin", body.clone()).await?;
let live: Vec<_> = (0..3).map(|idx| dist.client(idx)).collect::<Result<Vec<_>, _>>()?;
let worker_count = 12;
let release = Arc::new(Barrier::new(worker_count + 1));
let (started_tx, mut started_rx) = mpsc::unbounded_channel();
let mut handles = Vec::new();
for idx in 0..worker_count {
let client = live[idx % live.len()].clone();
let bucket = bucket.clone();
let body = body.clone();
let release = release.clone();
let started_tx = started_tx.clone();
handles.push(tokio::spawn(async move {
let response = client.get_object().bucket(&bucket).key("steady.bin").send().await?;
if response.content_length() != Some(body.len() as i64) {
return Err::<(), Box<dyn std::error::Error + Send + Sync>>(
format!("worker {idx} received a wrong content length").into(),
);
}
started_tx.send(idx)?;
release.wait().await;
let actual = response.body.collect().await?.into_bytes();
if actual.as_ref() != body.as_slice() {
return Err(format!("worker {idx} received corrupted bytes after peer kill").into());
}
Ok(())
}));
}
drop(started_tx);
for _ in 0..worker_count {
timeout(Duration::from_secs(30), started_rx.recv())
.await?
.ok_or("a streaming GET exited before reaching the kill barrier")?;
}
dist.cluster.stop_node(3)?;
release.wait().await;
for handle in handles {
handle.await??;
}
dist.cluster.start_node(3).await?;
wait_for_ready(&dist.cluster).await?;
assert_object_bytes(&dist.client(3)?, &bucket, "steady.bin", &body).await?;
Ok(())
}
#[tokio::test]
async fn blackholed_node_client_network_preserves_cluster_availability_and_recovers() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let proxy = crate::fault_proxy::FaultProxy::start(dist.cluster.nodes[3].address.parse()?).await?;
let proxied_url = format!("http://{}", proxy.local_addr());
let proxied_client = Client::from_conf(build_test_s3_config(
&proxied_url,
&dist.cluster.access_key,
&dist.cluster.secret_key,
None,
"distributed-network-chaos",
));
let result: TestResult = async {
let bucket = unique_bucket("netfault");
dist.create_bucket(&bucket).await?;
let baseline = payload_for("network/baseline.bin", 1024 * 1024);
put_object(&dist.client(0)?, &bucket, "baseline.bin", baseline.clone()).await?;
assert_object_bytes(&proxied_client, &bucket, "baseline.bin", &baseline).await?;
proxy.set_mode(FaultMode::Blackhole);
assert_eq!(proxy.mode(), FaultMode::Blackhole);
if let Ok(Ok(_)) = timeout(
Duration::from_secs(5),
proxied_client.get_object().bucket(&bucket).key("baseline.bin").send(),
)
.await
{
return Err("blackholed node endpoint unexpectedly completed a GET".into());
}
let during = payload_for("network/during.bin", 1024 * 1024);
timeout(Duration::from_secs(30), async {
put_object(&dist.client(1)?, &bucket, "during-blackhole.bin", during.clone()).await?;
assert_object_bytes(&dist.client(2)?, &bucket, "baseline.bin", &baseline).await?;
assert_object_bytes(&dist.client(0)?, &bucket, "during-blackhole.bin", &during).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
})
.await??;
proxy.set_mode(FaultMode::Pass);
retrying_get_equals(&proxied_client, &bucket, "during-blackhole.bin", &during, Duration::from_secs(30)).await?;
Ok(())
}
.await;
proxy.set_mode(FaultMode::Pass);
proxy.shutdown().await;
result
}
@@ -0,0 +1,98 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, unique_bucket};
use crate::common::init_logging;
use std::collections::BTreeSet;
use std::sync::Arc;
use tokio::sync::Barrier;
#[tokio::test]
async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("conc");
dist.create_bucket(&bucket).await?;
let clients = Arc::new(dist.clients()?);
let worker_count = 24;
let rounds = 4;
let barrier = Arc::new(Barrier::new(worker_count));
let mut handles = Vec::new();
for idx in 0..worker_count {
let clients = clients.clone();
let barrier = barrier.clone();
let bucket = bucket.clone();
handles.push(tokio::spawn(async move {
barrier.wait().await;
let writer = &clients[idx % clients.len()];
let reader = &clients[(idx + 1) % clients.len()];
let copier = &clients[(idx + 2) % clients.len()];
let mut retained = Vec::with_capacity(rounds);
for round in 0..rounds {
let key = format!("source/worker-{idx:02}-round-{round}.bin");
let copy_key = format!("retained/worker-{idx:02}-round-{round}.bin");
let body = payload_for(&key, 64 * 1024);
put_object(writer, &bucket, &key, body.clone()).await?;
let head = reader.head_object().bucket(&bucket).key(&key).send().await?;
if head.content_length() != Some(body.len() as i64) {
return Err(format!("HEAD returned the wrong size for {key}: {head:?}").into());
}
assert_object_bytes(reader, &bucket, &key, &body).await?;
copier
.copy_object()
.bucket(&bucket)
.key(&copy_key)
.copy_source(format!("{bucket}/{key}"))
.send()
.await?;
assert_object_bytes(writer, &bucket, &copy_key, &body).await?;
writer.delete_object().bucket(&bucket).key(&key).send().await?;
let missing = reader
.head_object()
.bucket(&bucket)
.key(&key)
.send()
.await
.expect_err("deleted source key must not remain visible");
if missing.raw_response().map(|response| response.status().as_u16()) != Some(404) {
return Err(format!("deleted source {key} returned an unexpected result: {missing:?}").into());
}
retained.push((copy_key, body));
}
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(retained)
}));
}
let mut inventory = Vec::new();
for handle in handles {
inventory.extend(handle.await??);
}
let expected_keys: BTreeSet<_> = inventory.iter().map(|(key, _)| key.as_str()).collect();
for (node_idx, client) in clients.iter().enumerate() {
let listed = client.list_objects_v2().bucket(&bucket).prefix("retained/").send().await?;
let listed_keys: BTreeSet<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(listed_keys, expected_keys, "node {node_idx} returned a divergent retained-key listing");
for (key, body) in &inventory {
assert_object_bytes(client, &bucket, key, body)
.await
.map_err(|error| format!("node {node_idx} failed to read {key}: {error}"))?;
}
}
Ok(())
}
@@ -0,0 +1,74 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
decommission_status_json, payload_for, put_inventory_retrying, retrying_get_equals, retrying_put, start_decommission,
unique_bucket, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
};
use crate::common::init_logging;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Barrier;
#[tokio::test]
async fn concurrent_puts_during_decommission_do_not_lose_baseline_or_new_objects() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("concdecom");
dist.create_bucket(&bucket).await?;
let baseline_client = dist.client(0)?;
let inventory = put_inventory_retrying(&baseline_client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
dist.expand_to_four_pools().await?;
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
let clients = Arc::new(dist.clients()?);
let barrier = Arc::new(Barrier::new(17));
let mut handles = Vec::new();
for idx in 0..16 {
let clients = clients.clone();
let barrier = barrier.clone();
let bucket = bucket.clone();
handles.push(tokio::spawn(async move {
barrier.wait().await;
let client = &clients[idx % clients.len()];
let key = format!("live/{idx:02}.bin");
let body = payload_for(&key, 8 * 1024);
retrying_put(client, &bucket, &key, body.clone(), Duration::from_secs(45)).await?;
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((key, body))
}));
}
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
barrier.wait().await;
let mut live_objects = Vec::new();
for handle in handles {
live_objects.push(handle.await??);
}
let status = decommission_status_json(&dist.cluster).await?;
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
return Err(format!("decommission did not remain active across concurrent PUTs: {status}").into());
}
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
let checker = dist.client(2)?;
assert_inventory(&checker, &bucket, &inventory).await?;
for (key, body) in live_objects {
retrying_get_equals(&checker, &bucket, &key, &body, Duration::from_secs(30)).await?;
}
Ok(())
}
@@ -0,0 +1,156 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, enable_versioning, put_inventory_retrying,
sha256_hex, start_decommission, unique_bucket, wait_for_decommission_active, wait_for_decommission_complete,
};
use crate::common::init_logging;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use std::time::Duration;
#[tokio::test]
async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("integrity");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
enable_versioning(&client, &bucket).await?;
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
let before: Vec<(String, String)> = inventory.iter().map(|(key, body)| (key.clone(), sha256_hex(body))).collect();
let versioned_key = "history/versioned.bin";
let version_one = b"historical bytes before data movement".to_vec();
let version_two = b"current bytes before data movement".to_vec();
let version_one_id = client
.put_object()
.bucket(&bucket)
.key(versioned_key)
.body(ByteStream::from(version_one.clone()))
.send()
.await?
.version_id()
.ok_or("historical PUT omitted version ID")?
.to_string();
let version_two_id = client
.put_object()
.bucket(&bucket)
.key(versioned_key)
.body(ByteStream::from(version_two.clone()))
.send()
.await?
.version_id()
.ok_or("current PUT omitted version ID")?
.to_string();
let multipart_key = "multipart/moved.bin";
let first_part = vec![0x31; 5 * 1024 * 1024];
let second_part = vec![0x72; 1024 * 1024];
let upload = client
.create_multipart_upload()
.bucket(&bucket)
.key(multipart_key)
.send()
.await?;
let upload_id = upload.upload_id().ok_or("movement multipart upload omitted upload ID")?;
let uploaded_one = client
.upload_part()
.bucket(&bucket)
.key(multipart_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(first_part.clone()))
.send()
.await?;
let uploaded_two = client
.upload_part()
.bucket(&bucket)
.key(multipart_key)
.upload_id(upload_id)
.part_number(2)
.body(ByteStream::from(second_part.clone()))
.send()
.await?;
client
.complete_multipart_upload()
.bucket(&bucket)
.key(multipart_key)
.upload_id(upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(uploaded_one.e_tag().ok_or("movement part 1 omitted ETag")?)
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(uploaded_two.e_tag().ok_or("movement part 2 omitted ETag")?)
.build(),
)
.build(),
)
.send()
.await?;
dist.expand_to_four_pools().await?;
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
let after_client = dist.client(2)?;
assert_inventory(&after_client, &bucket, &inventory).await?;
for (key, expected_hash) in before {
let got = after_client.get_object().bucket(&bucket).key(&key).send().await?;
let body = got.body.collect().await?.into_bytes();
assert_eq!(sha256_hex(body.as_ref()), expected_hash, "checksum changed for {key} after decommission");
}
for (version_id, expected) in [(&version_one_id, &version_one), (&version_two_id, &version_two)] {
let got = after_client
.get_object()
.bucket(&bucket)
.key(versioned_key)
.version_id(version_id)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(got.as_ref(), expected.as_slice(), "version {version_id} changed after decommission");
}
let mut expected_multipart = first_part;
expected_multipart.extend_from_slice(&second_part);
let got_multipart = after_client
.get_object()
.bucket(&bucket)
.key(multipart_key)
.send()
.await?
.body
.collect()
.await?
.into_bytes();
assert_eq!(
sha256_hex(got_multipart.as_ref()),
sha256_hex(&expected_multipart),
"multipart checksum changed after decommission"
);
Ok(())
}
@@ -0,0 +1,81 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, list_pools_json, put_inventory,
put_inventory_retrying, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_active,
wait_for_decommission_complete, wait_for_rebalance_active, wait_for_rebalance_complete,
};
use crate::common::init_logging;
use std::time::Duration;
#[tokio::test]
async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("expand");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let inventory = put_inventory(&client, &bucket, 64, 256 * 1024).await?;
assert_inventory(&client, &bucket, &inventory).await?;
for expected_nodes in 2..=4 {
let new_node = dist.append_pool_and_restart().await?;
assert_eq!(new_node + 1, expected_nodes);
assert_inventory(&dist.client(new_node)?, &bucket, &inventory).await?;
}
assert_eq!(dist.cluster.nodes.len(), 4);
// Prove that the expanded pool map is durable, and clear any recovery
// latch raised while the newly-added pool replicas converged.
dist.restart_current_binary_gracefully().await?;
let after_expand = dist.client(0)?;
assert_inventory(&after_expand, &bucket, &inventory).await?;
let peer = dist.client(3)?;
assert_inventory(&peer, &bucket, &inventory).await?;
let rebalance_id = start_rebalance(&dist.cluster).await?;
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
assert_inventory(&peer, &bucket, &inventory).await?;
Ok(())
}
#[tokio::test]
async fn four_pool_decommission_moves_objects_without_loss() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("decom");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let inventory = put_inventory_retrying(&client, &bucket, 96, 128 * 1024, Duration::from_secs(30)).await?;
dist.expand_to_four_pools().await?;
let pools_before = list_pools_json(&dist.cluster).await?;
let pool_count = pools_before
.as_array()
.map(Vec::len)
.or_else(|| pools_before.get("pools").and_then(serde_json::Value::as_array).map(Vec::len))
.ok_or_else(|| format!("pool list omitted an array: {pools_before}"))?;
assert_eq!(pool_count, 4, "expected exactly four pools before decommission: {pools_before}");
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
let after = dist.client(2)?;
assert_inventory(&after, &bucket, &inventory).await?;
Ok(())
}
@@ -0,0 +1,149 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket, wait_until,
};
use crate::common::init_logging;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
use std::time::Duration;
#[tokio::test]
async fn four_node_four_drive_multipart_and_cross_node_listing_agree() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("extra");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let key = "multipart.bin";
let part1 = vec![0x41u8; 5 * 1024 * 1024];
let part2 = vec![0x42u8; 5 * 1024 * 1024];
let upload = client.create_multipart_upload().bucket(&bucket).key(key).send().await?;
let upload_id = upload.upload_id().ok_or("missing upload id")?.to_string();
let uploaded1 = client
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(&upload_id)
.part_number(1)
.body(ByteStream::from(part1.clone()))
.send()
.await?;
let uploaded2 = client
.upload_part()
.bucket(&bucket)
.key(key)
.upload_id(&upload_id)
.part_number(2)
.body(ByteStream::from(part2.clone()))
.send()
.await?;
client
.complete_multipart_upload()
.bucket(&bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(
CompletedMultipartUpload::builder()
.parts(
CompletedPart::builder()
.part_number(1)
.e_tag(uploaded1.e_tag().unwrap_or_default())
.build(),
)
.parts(
CompletedPart::builder()
.part_number(2)
.e_tag(uploaded2.e_tag().unwrap_or_default())
.build(),
)
.build(),
)
.send()
.await?;
let mut expected = part1;
expected.extend_from_slice(&part2);
for node_idx in 0..dist.cluster.nodes.len() {
assert_object_bytes(&dist.client(node_idx)?, &bucket, key, &expected).await?;
}
put_object(&client, &bucket, "list/a", b"a".to_vec()).await?;
put_object(&dist.client(2)?, &bucket, "list/b", b"b".to_vec()).await?;
let mut seen = Vec::new();
for node_idx in 0..dist.cluster.nodes.len() {
let listed = dist
.client(node_idx)?
.list_objects_v2()
.bucket(&bucket)
.prefix("list/")
.send()
.await?;
let keys: Vec<String> = listed
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_string))
.collect();
seen.push(keys);
}
for keys in &seen[1..] {
assert_eq!(&seen[0], keys, "list results diverged across nodes: {seen:?}");
}
let got = get_object_bytes(&dist.client(3)?, &bucket, "list/a").await?;
assert_eq!(got, b"a");
Ok(())
}
#[tokio::test]
async fn four_node_list_buckets_agree_across_all_nodes() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("listed");
dist.create_bucket(&bucket).await?;
put_object(&dist.client(0)?, &bucket, "seed.bin", b"seed".to_vec()).await?;
for node_idx in 0..dist.cluster.nodes.len() {
let client = dist.client(node_idx)?;
let name = bucket.clone();
wait_until(
Duration::from_secs(20),
|| {
let client = client.clone();
let name = name.clone();
async move {
let listed = client.list_buckets().send().await?;
Ok(listed.buckets().iter().any(|entry| entry.name() == Some(name.as_str())))
}
},
&format!("node {node_idx} lists {bucket}"),
)
.await?;
wait_until(
Duration::from_secs(20),
|| {
let client = dist.client(node_idx).expect("client");
let name = bucket.clone();
async move { Ok(get_object_bytes(&client, &name, "seed.bin").await.ok() == Some(b"seed".to_vec())) }
},
&format!("node {node_idx} reads seed.bin"),
)
.await?;
}
Ok(())
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! 4-node 4-drive distributed e2e coverage.
//!
//! Selected by `[profile.e2e-distributed]` and run from
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
//! each case starts four real `rustfs` processes.
mod chaos_test;
mod concurrency_stability_test;
mod concurrent_data_movement_test;
mod data_integrity_movement_test;
mod expand_decommission_rebalance_test;
mod extra_test;
mod harness;
mod object_lock_test;
mod observability_test;
mod replication_quota_test;
mod s3_basic_test;
mod s3_during_data_movement_test;
mod site_replication_test;
mod upgrade_test;
mod versioning_test;
@@ -0,0 +1,219 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
use crate::common::init_logging;
use crate::object_lock::common::{
delete_object_with_bypass, put_object_lock_configuration, put_object_with_legal_hold, put_object_with_retention,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::error::SdkError;
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
ObjectLockRule,
};
use chrono::{Duration as ChronoDuration, Utc};
fn delete_denied(error: &SdkError<DeleteObjectError>, context: &str) -> TestResult {
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
if code == Some("AccessDenied") {
Ok(())
} else {
Err(format!("{context}: expected AccessDenied, got {error:?}").into())
}
}
async fn expect_versioned_delete_denied(
client: &Client,
bucket: &str,
key: &str,
version_id: &str,
bypass: bool,
context: &str,
) -> TestResult {
match delete_object_with_bypass(client, bucket, key, Some(version_id), bypass).await {
Ok(_) => Err(format!("{context}: DeleteObject of retained version must be denied").into()),
Err(error) => delete_denied(error.as_ref(), context),
}
}
#[tokio::test]
async fn four_node_four_drive_object_lock_worm_blocks_delete() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let client = dist.client(0)?;
let peer = dist.client(2)?;
let bucket = unique_bucket("objlock");
client
.create_bucket()
.bucket(&bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
let retain_until = Utc::now() + ChronoDuration::days(1);
let compliance_key = "compliance.bin";
let compliance_version = put_object_with_retention(
&client,
&bucket,
compliance_key,
b"locked-compliance",
ObjectLockRetentionMode::Compliance,
retain_until,
)
.await?;
// Unversioned DELETE is allowed: it only creates a delete marker. WORM
// applies to a specific version id.
let marker = peer.delete_object().bucket(&bucket).key(compliance_key).send().await?;
assert_eq!(
marker.delete_marker(),
Some(true),
"unversioned DELETE on a locked object must create a delete marker"
);
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, false, "COMPLIANCE without bypass")
.await?;
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, true, "COMPLIANCE with bypass").await?;
let governance_key = "governance.bin";
let governance_version = put_object_with_retention(
&client,
&bucket,
governance_key,
b"locked-governance",
ObjectLockRetentionMode::Governance,
retain_until,
)
.await?;
expect_versioned_delete_denied(&peer, &bucket, governance_key, &governance_version, false, "GOVERNANCE without bypass")
.await?;
delete_object_with_bypass(&peer, &bucket, governance_key, Some(&governance_version), true).await?;
let deleted_governance = peer
.head_object()
.bucket(&bucket)
.key(governance_key)
.version_id(&governance_version)
.send()
.await
.expect_err("GOVERNANCE bypass must remove the retained version");
assert_eq!(
deleted_governance.raw_response().map(|response| response.status().as_u16()),
Some(404),
"deleted GOVERNANCE version returned an unexpected HEAD result: {deleted_governance:?}"
);
let hold_key = "legal-hold.bin";
let hold_version =
put_object_with_legal_hold(&client, &bucket, hold_key, b"legal-hold", ObjectLockLegalHoldStatus::On).await?;
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, false, "legal hold without bypass").await?;
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, true, "legal hold with bypass").await?;
Ok(())
}
#[tokio::test]
async fn four_node_default_retention_is_visible_and_non_lock_bucket_rejects_configuration() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
let bucket = unique_bucket("default-lock");
writer
.create_bucket()
.bucket(&bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
put_object_lock_configuration(&writer, &bucket, ObjectLockRetentionMode::Governance, Some(1), None).await?;
let key = "default-governance.bin";
let put = writer
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"default retention payload"))
.send()
.await?;
let version_id = put.version_id().ok_or("default-retained PUT omitted version ID")?;
let config = reader.get_object_lock_configuration().bucket(&bucket).send().await?;
let default_retention = config
.object_lock_configuration()
.and_then(|configuration| configuration.rule())
.and_then(|rule| rule.default_retention())
.ok_or("GetObjectLockConfiguration omitted default retention")?;
assert_eq!(default_retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
assert_eq!(default_retention.days(), Some(1));
let retention = reader
.get_object_retention()
.bucket(&bucket)
.key(key)
.version_id(version_id)
.send()
.await?;
let retention = retention.retention().ok_or("GetObjectRetention omitted applied retention")?;
assert_eq!(retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
let retain_until = retention
.retain_until_date()
.ok_or("default retention omitted retain-until date")?;
assert!(retain_until.secs() > Utc::now().timestamp(), "default retention is not in the future");
let versioning = reader.get_bucket_versioning().bucket(&bucket).send().await?;
assert_eq!(versioning.status().map(|status| status.as_str()), Some("Enabled"));
expect_versioned_delete_denied(&reader, &bucket, key, version_id, false, "default GOVERNANCE retention without bypass")
.await?;
let plain_bucket = unique_bucket("no-lock");
dist.create_bucket(&plain_bucket).await?;
let configuration = ObjectLockConfiguration::builder()
.object_lock_enabled(ObjectLockEnabled::Enabled)
.rule(
ObjectLockRule::builder()
.default_retention(
DefaultRetention::builder()
.mode(ObjectLockRetentionMode::Governance)
.days(1)
.build(),
)
.build(),
)
.build();
let error = writer
.put_object_lock_configuration()
.bucket(&plain_bucket)
.object_lock_configuration(configuration)
.send()
.await
.expect_err("an unversioned bucket must reject Object Lock enablement");
let service_error = error
.as_service_error()
.ok_or("non-lock bucket rejection was not an S3 service error")?;
assert_eq!(service_error.code(), Some("InvalidBucketState"), "unexpected error: {error:?}");
assert_eq!(
service_error.message(),
Some("Object Lock configuration cannot be enabled on existing buckets"),
"unexpected error: {error:?}"
);
Ok(())
}
@@ -0,0 +1,236 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, cluster_admin_ok, unique_bucket, wait_for_ready};
use crate::common::{admin_request, init_logging, local_http_client};
use aws_sdk_s3::operation::RequestId;
use aws_sdk_s3::primitives::ByteStream;
use bytes::Bytes;
use http::Method;
use http_body_util::{BodyExt, Empty};
use hyper::body::Incoming;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use local_ip_address::local_ip;
use rustfs_madmin::metrics::RealtimeMetrics;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use serde_json::Value;
use std::convert::Infallible;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::{Instant, timeout};
async fn spawn_audit_collector() -> TestResult<(String, mpsc::UnboundedReceiver<Value>, JoinHandle<()>)> {
let listener = TcpListener::bind("0.0.0.0:0").await?;
let endpoint = format!("http://{}/audit", std::net::SocketAddr::new(local_ip()?, listener.local_addr()?.port()));
let (tx, rx) = mpsc::unbounded_channel();
let handle = tokio::spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let tx = tx.clone();
tokio::spawn(async move {
let service = service_fn(move |request: Request<Incoming>| {
let tx = tx.clone();
async move {
let method = request.method().clone();
if let Ok(body) = request.into_body().collect().await
&& method == Method::POST
&& let Ok(payload) = serde_json::from_slice::<Value>(&body.to_bytes())
{
if let Some(records) = payload["Records"].as_array() {
for entry in records {
let _ = tx.send(entry.clone());
}
} else {
let _ = tx.send(payload);
}
}
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.await;
});
}
});
Ok((endpoint, rx, handle))
}
async fn wait_for_audit_entry(
rx: &mut mpsc::UnboundedReceiver<Value>,
bucket: &str,
key: &str,
request_id: &str,
) -> TestResult<Value> {
let deadline = Instant::now() + Duration::from_secs(30);
let mut seen = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(format!(
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
seen.len()
)
.into());
}
let entry = match timeout(remaining, rx.recv()).await {
Ok(Some(entry)) => entry,
Ok(None) => return Err("audit collector stopped before the expected entry arrived".into()),
Err(_) => {
return Err(format!(
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
seen.len()
)
.into());
}
};
if entry["api"]["name"].as_str() == Some("s3:PutObject")
&& entry["api"]["bucket"].as_str() == Some(bucket)
&& entry["api"]["object"].as_str() == Some(key)
&& entry["requestID"].as_str() == Some(request_id)
{
return Ok(entry);
}
if seen.len() < 8 {
seen.push(format!(
"api={:?} bucket={:?} object={:?} requestID={:?}",
entry["api"]["name"].as_str(),
entry["api"]["bucket"].as_str(),
entry["api"]["object"].as_str(),
entry["requestID"].as_str()
));
}
}
}
#[tokio::test]
async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent() -> TestResult {
init_logging();
let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?;
let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization();
let audit_env = [
("RUSTFS_AUDIT_ENABLE", "true"),
("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"),
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()),
(ENV_OUTBOUND_ALLOW_ORIGINS, audit_origin.as_str()),
];
let mut dist = DistCluster::new_stopped_with_env(DistLayout::FourByFour, &audit_env).await?;
for node_idx in 0..dist.cluster.nodes.len() {
let queue_dir = format!("{}/audit-queue-node-{node_idx}", dist.cluster.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
dist.cluster
.set_node_env(node_idx, "RUSTFS_AUDIT_WEBHOOK_QUEUE_DIR_DISTRIBUTED", queue_dir)?;
}
dist.cluster.start().await?;
wait_for_ready(&dist.cluster).await?;
let http = local_http_client();
for node in &dist.cluster.nodes {
for probe in ["ready", "live"] {
let response = http.get(format!("{}/health/{probe}", node.url)).send().await?;
assert!(
response.status().is_success(),
"node {} {probe} probe failed: {}",
node.address,
response.status()
);
}
}
let info_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/info", None).await?;
let info: Value = serde_json::from_str(&info_body)?;
let servers = info["info"]["servers"]
.as_array()
.ok_or_else(|| format!("admin info omitted servers: {info}"))?;
assert_eq!(servers.len(), 4, "admin info did not report all four nodes: {info}");
let storage_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
let storage: Value = serde_json::from_str(&storage_body)?;
let disks = storage["info"]["disks"]
.as_array()
.ok_or_else(|| format!("storageinfo omitted disks: {storage}"))?;
assert_eq!(disks.len(), 16, "storageinfo did not report all sixteen drives: {storage}");
assert!(
disks.iter().all(|disk| {
disk["state"].as_str().is_some_and(|state| state.eq_ignore_ascii_case("ok"))
&& disk["runtimeState"]
.as_str()
.is_some_and(|state| state.eq_ignore_ascii_case("online"))
}),
"storageinfo reported a drive that was not healthy and online: {storage}"
);
for (node_idx, node) in dist.cluster.nodes.iter().enumerate() {
let (status, metrics_body) = admin_request(
&node.url,
Method::GET,
"/rustfs/admin/v3/metrics?n=1&by-host=true&by-disk=true",
None,
&dist.cluster.access_key,
&dist.cluster.secret_key,
)
.await?;
assert!(status.is_success(), "node {node_idx} metrics failed: {status} {metrics_body}");
let sample: RealtimeMetrics = serde_json::from_str(
metrics_body
.lines()
.next()
.ok_or_else(|| format!("node {node_idx} returned empty metrics"))?,
)?;
assert!(sample.finally, "node {node_idx} metrics sample was not terminal");
assert!(sample.errors.is_empty(), "node {node_idx} metrics reported errors: {:?}", sample.errors);
assert!(!sample.hosts.is_empty(), "node {node_idx} metrics omitted hosts");
}
let targets_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/audit/target/list", None).await?;
let targets: Value = serde_json::from_str(&targets_body)?;
let configured = targets["audit_endpoints"]
.as_array()
.ok_or_else(|| format!("audit target list omitted audit_endpoints: {targets}"))?
.iter()
.any(|target| target["account_id"].as_str() == Some("distributed") && target["service"].as_str() == Some("webhook"));
assert!(configured, "configured audit webhook was missing: {targets}");
let bucket = unique_bucket("audit");
dist.create_bucket(&bucket).await?;
let key = "correlated/audit-object.bin";
let put = dist
.client(2)?
.put_object()
.bucket(&bucket)
.key(key)
.body(ByteStream::from_static(b"distributed audit payload"))
.send()
.await?;
let request_id = put.request_id().ok_or("PutObject response omitted request ID")?;
let audit = wait_for_audit_entry(&mut audit_entries, &bucket, key, request_id).await?;
assert_eq!(
audit["api"]["status_code"].as_i64(),
Some(200),
"audit entry did not report success: {audit}"
);
assert!(
!audit.to_string().contains(&dist.cluster.secret_key),
"audit entry leaked the root secret key"
);
collector.abort();
Ok(())
}
@@ -0,0 +1,191 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{
DistCluster, DistLayout, TestResult, enable_versioning, put_bucket_replication, put_object, retrying_put, set_bucket_quota,
set_remote_target, unique_bucket, wait_for_ready, wait_for_replicated_bytes, wait_until,
};
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, init_logging};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use std::time::Duration;
async fn wait_for_replication_status(
client: &aws_sdk_s3::Client,
bucket: &str,
key: &str,
expected: &[&str],
timeout: Duration,
) -> TestResult {
wait_until(
timeout,
|| async {
let head = client.head_object().bucket(bucket).key(key).send().await?;
Ok(head
.replication_status()
.is_some_and(|status| expected.contains(&status.as_str())))
},
&format!("replication status for {bucket}/{key} in {expected:?}"),
)
.await
}
#[tokio::test]
async fn four_node_bucket_replication_converges_to_peer_cluster() -> TestResult {
init_logging();
let (source, mut target) = DistCluster::start_replication_pair().await?;
let source_bucket = unique_bucket("replsrc");
let target_bucket = unique_bucket("repldst");
source.create_bucket(&source_bucket).await?;
target.create_bucket(&target_bucket).await?;
let source_client = source.client(0)?;
let target_client = target.client(0)?;
enable_versioning(&source_client, &source_bucket).await?;
enable_versioning(&target_client, &target_bucket).await?;
let arn = set_remote_target(&source.cluster, &source_bucket, &target.cluster, &target_bucket).await?;
put_bucket_replication(&source.cluster, &source_bucket, &arn).await?;
let key = "replicated/metadata-and-tags.bin";
let body = b"distributed-bucket-replication".to_vec();
source_client
.put_object()
.bucket(&source_bucket)
.key(key)
.metadata("origin", "four-node-source")
.tagging("suite=distributed&shape=metadata")
.body(ByteStream::from(body.clone()))
.send()
.await?;
wait_for_replicated_bytes(&target_client, &target_bucket, key, &body, Duration::from_secs(45)).await?;
wait_for_replication_status(&source_client, &source_bucket, key, &["COMPLETED"], Duration::from_secs(30)).await?;
let peer_read = target.client(3)?;
wait_for_replicated_bytes(&peer_read, &target_bucket, key, &body, Duration::from_secs(15)).await?;
let replica_head = peer_read.head_object().bucket(&target_bucket).key(key).send().await?;
assert_eq!(
replica_head
.metadata()
.and_then(|metadata| metadata.get("origin"))
.map(String::as_str),
Some("four-node-source")
);
assert_eq!(replica_head.replication_status().map(|status| status.as_str()), Some("REPLICA"));
let replica_tags = peer_read.get_object_tagging().bucket(&target_bucket).key(key).send().await?;
let tags: std::collections::BTreeMap<_, _> = replica_tags.tag_set().iter().map(|tag| (tag.key(), tag.value())).collect();
assert_eq!(tags.get("suite"), Some(&"distributed"));
assert_eq!(tags.get("shape"), Some(&"metadata"));
target.cluster.stop();
let outage_key = "replicated/queued-during-target-outage.bin";
let outage_body = b"retry-after-target-restart".to_vec();
put_object(&source_client, &source_bucket, outage_key, outage_body.clone()).await?;
wait_for_replication_status(
&source_client,
&source_bucket,
outage_key,
&["PENDING", "FAILED"],
Duration::from_secs(30),
)
.await?;
target.cluster.start().await?;
wait_for_ready(&target.cluster).await?;
wait_for_replicated_bytes(&target.client(2)?, &target_bucket, outage_key, &outage_body, Duration::from_secs(90)).await?;
wait_for_replication_status(&source_client, &source_bucket, outage_key, &["COMPLETED"], Duration::from_secs(45)).await?;
Ok(())
}
#[tokio::test]
async fn four_node_four_drive_hard_quota_rejects_over_limit_put() -> TestResult {
init_logging();
let dist = DistCluster::start_with_env(DistLayout::FourByFour, FAST_DATA_USAGE_SCANNER_ENV).await?;
let bucket = unique_bucket("quota");
dist.create_bucket(&bucket).await?;
set_bucket_quota(&dist.cluster, &bucket, 8 * 1024).await?;
let client = dist.client(1)?;
retrying_put(&client, &bucket, "small.bin", vec![0u8; 1024], Duration::from_secs(30)).await?;
wait_until(
Duration::from_secs(30),
|| async {
let (status, body) = super::harness::cluster_admin(
&dist.cluster,
Method::GET,
&format!("/rustfs/admin/v3/quota-stats/{bucket}"),
None,
)
.await?;
if !status.is_success() {
return Ok(false);
}
let stats: serde_json::Value =
serde_json::from_str(&body).map_err(|error| format!("quota stats returned invalid JSON: {error}: {body}"))?;
let usage = stats
.get("current_usage")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| format!("quota stats omitted current_usage: {stats}"))?;
Ok(usage >= 1024)
},
"quota stats observe small object",
)
.await?;
let oversized_key = "too-big.bin";
let error = client
.put_object()
.bucket(&bucket)
.key(oversized_key)
.body(vec![0u8; 16 * 1024].into())
.send()
.await
.expect_err("hard quota must reject the oversized PUT");
let service_error = error
.as_service_error()
.ok_or("quota rejection was not an S3 service error")?;
assert_eq!(
error.raw_response().map(|response| response.status().as_u16()),
Some(400),
"quota rejection must be HTTP 400: {error:?}"
);
assert_eq!(service_error.code(), Some("InvalidRequest"), "unexpected quota error: {error:?}");
assert!(
service_error
.message()
.is_some_and(|message| message.starts_with("Bucket quota exceeded")),
"PUT must fail specifically at quota admission: {error:?}"
);
let missing = client
.head_object()
.bucket(&bucket)
.key(oversized_key)
.send()
.await
.expect_err("an object rejected by quota must not become visible");
assert_eq!(
missing.raw_response().map(|response| response.status().as_u16()),
Some(404),
"quota-rejected object returned an unexpected HEAD result: {missing:?}"
);
let listed = client.list_objects_v2().bucket(&bucket).send().await?;
assert!(
listed.contents().iter().all(|object| object.key() != Some(oversized_key)),
"quota-rejected key leaked into ListObjectsV2"
);
Ok(())
}
@@ -0,0 +1,258 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket};
use crate::common::{init_logging, local_http_client};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{Delete, MetadataDirective, ObjectIdentifier};
use std::time::Duration;
#[tokio::test]
async fn four_node_four_drive_s3_put_get_head_list_copy_rename_delete_and_presign() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("s3basic");
dist.create_bucket(&bucket).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
let key = "dir/object.bin";
let body = vec![0xA5u8; 256 * 1024];
put_object(&writer, &bucket, key, body.clone()).await?;
let head = reader.head_object().bucket(&bucket).key(key).send().await?;
assert_eq!(head.content_length(), Some(body.len() as i64));
assert_object_bytes(&reader, &bucket, key, &body).await?;
let ranged = reader
.get_object()
.bucket(&bucket)
.key(key)
.range("bytes=0-15")
.send()
.await?;
let ranged_body = ranged.body.collect().await?.into_bytes();
assert_eq!(ranged_body.as_ref(), &body[..16]);
let listed = reader.list_objects_v2().bucket(&bucket).prefix("dir/").send().await?;
let keys: Vec<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(keys, vec![key]);
let copy_key = "dir/object-copy.bin";
reader
.copy_object()
.bucket(&bucket)
.key(copy_key)
.copy_source(format!("{bucket}/{key}"))
.metadata_directive(MetadataDirective::Copy)
.send()
.await?;
assert_object_bytes(&writer, &bucket, copy_key, &body).await?;
let moved_key = "dir/object-moved.bin";
writer
.copy_object()
.bucket(&bucket)
.key(moved_key)
.copy_source(format!("{bucket}/{copy_key}"))
.send()
.await?;
writer.delete_object().bucket(&bucket).key(copy_key).send().await?;
match writer.head_object().bucket(&bucket).key(copy_key).send().await {
Ok(_) => return Err("copied source still present after rename delete".into()),
Err(error) if error.as_service_error().is_some_and(|err| err.is_not_found()) => {}
Err(error) => return Err(error.into()),
}
assert_object_bytes(&reader, &bucket, moved_key, &body).await?;
let presigned = writer
.get_object()
.bucket(&bucket)
.key(key)
.presigned(PresigningConfig::expires_in(Duration::from_secs(120))?)
.await?;
let response = local_http_client().get(presigned.uri().to_string()).send().await?;
assert!(response.status().is_success(), "presigned GET failed: {}", response.status());
let presigned_body = response.bytes().await?;
assert_eq!(presigned_body.as_ref(), body.as_slice());
let empty_key = "empty";
put_object(&writer, &bucket, empty_key, Vec::new()).await?;
let empty = get_object_bytes(&reader, &bucket, empty_key).await?;
assert!(empty.is_empty());
let deleted = writer
.delete_objects()
.bucket(&bucket)
.delete(
Delete::builder()
.objects(ObjectIdentifier::builder().key(key).build()?)
.objects(ObjectIdentifier::builder().key(moved_key).build()?)
.objects(ObjectIdentifier::builder().key(empty_key).build()?)
.build()?,
)
.send()
.await?;
assert!(deleted.errors().is_empty(), "DeleteObjects reported failures: {deleted:?}");
assert_eq!(deleted.deleted().len(), 3, "DeleteObjects did not acknowledge every key");
let remaining = reader.list_objects_v2().bucket(&bucket).send().await?;
assert!(remaining.contents().is_empty(), "bucket still has objects after delete");
Ok(())
}
#[tokio::test]
async fn four_node_s3_metadata_tags_special_keys_pagination_and_multipart_abort() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("s3matrix");
dist.create_bucket(&bucket).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
let special_key = "unicode/测试 space+percent%25.txt";
let special_body = b"metadata and tagging survive distributed routing".to_vec();
let put = writer
.put_object()
.bucket(&bucket)
.key(special_key)
.metadata("test-meta", "distributed")
.tagging("purpose=compatibility&scope=four-by-four")
.body(ByteStream::from(special_body.clone()))
.send()
.await?;
let etag = put.e_tag().ok_or("PutObject omitted ETag")?.to_string();
let head = reader.head_object().bucket(&bucket).key(special_key).send().await?;
assert_eq!(
head.metadata()
.and_then(|metadata| metadata.get("test-meta"))
.map(String::as_str),
Some("distributed")
);
assert_eq!(head.e_tag(), Some(etag.as_str()));
let tags = reader.get_object_tagging().bucket(&bucket).key(special_key).send().await?;
let actual_tags: std::collections::BTreeMap<_, _> = tags
.tag_set()
.iter()
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
.collect();
assert_eq!(actual_tags.get("purpose").map(String::as_str), Some("compatibility"));
assert_eq!(actual_tags.get("scope").map(String::as_str), Some("four-by-four"));
let conditional = reader
.get_object()
.bucket(&bucket)
.key(special_key)
.if_match(&etag)
.send()
.await?;
assert_eq!(conditional.body.collect().await?.into_bytes().as_ref(), special_body.as_slice());
let invalid_range = reader
.get_object()
.bucket(&bucket)
.key(special_key)
.range("bytes=999999-1000000")
.send()
.await
.expect_err("an unsatisfiable range must fail");
assert_eq!(
invalid_range.as_service_error().and_then(ProvideErrorMetadata::code),
Some("InvalidRange"),
"unexpected invalid-range error: {invalid_range:?}"
);
let upload_key = "multipart/aborted.bin";
let upload = writer
.create_multipart_upload()
.bucket(&bucket)
.key(upload_key)
.send()
.await?;
let upload_id = upload.upload_id().ok_or("CreateMultipartUpload omitted upload ID")?;
writer
.upload_part()
.bucket(&bucket)
.key(upload_key)
.upload_id(upload_id)
.part_number(1)
.body(ByteStream::from(vec![0x5Au8; 5 * 1024 * 1024]))
.send()
.await?;
let pending = reader
.list_multipart_uploads()
.bucket(&bucket)
.prefix("multipart/")
.send()
.await?;
assert!(pending.uploads().iter().any(|entry| entry.upload_id() == Some(upload_id)));
writer
.abort_multipart_upload()
.bucket(&bucket)
.key(upload_key)
.upload_id(upload_id)
.send()
.await?;
let after_abort = reader
.list_multipart_uploads()
.bucket(&bucket)
.prefix("multipart/")
.send()
.await?;
assert!(after_abort.uploads().iter().all(|entry| entry.upload_id() != Some(upload_id)));
let aborted_head = reader
.head_object()
.bucket(&bucket)
.key(upload_key)
.send()
.await
.expect_err("aborted multipart upload must not create an object");
assert_eq!(
aborted_head.raw_response().map(|response| response.status().as_u16()),
Some(404),
"aborted multipart object returned an unexpected HEAD result: {aborted_head:?}"
);
for index in 0..113 {
let key = format!("page/{index:04}.txt");
put_object(&writer, &bucket, &key, format!("page-{index}").into_bytes()).await?;
}
let mut token = None;
let mut paged_keys = Vec::new();
loop {
let page = reader
.list_objects_v2()
.bucket(&bucket)
.prefix("page/")
.max_keys(37)
.set_continuation_token(token.take())
.send()
.await?;
paged_keys.extend(page.contents().iter().filter_map(|object| object.key().map(str::to_string)));
if page.is_truncated() != Some(true) {
break;
}
token = Some(
page.next_continuation_token()
.ok_or("truncated ListObjectsV2 page omitted next continuation token")?
.to_string(),
);
}
assert_eq!(paged_keys.len(), 113);
let expected: Vec<_> = (0..113).map(|index| format!("page/{index:04}.txt")).collect();
assert_eq!(paged_keys, expected, "pagination lost, duplicated, or reordered keys");
Ok(())
}
@@ -0,0 +1,94 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
};
use crate::common::init_logging;
use std::time::Duration;
#[tokio::test]
async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResult {
init_logging();
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
let bucket = unique_bucket("s3move");
dist.create_bucket(&bucket).await?;
let client = dist.client(0)?;
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
dist.expand_to_four_pools().await?;
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
let live = dist.client(2)?;
retrying_put(
&live,
&bucket,
"during-decommission.bin",
b"written-while-decommissioning".to_vec(),
Duration::from_secs(30),
)
.await?;
retrying_get_equals(
&live,
&bucket,
"during-decommission.bin",
b"written-while-decommissioning",
Duration::from_secs(30),
)
.await?;
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
assert!(
listed
.contents()
.iter()
.any(|object| object.key() == Some("during-decommission.bin")),
"list during decommission missed the newly written key"
);
let status = decommission_status_json(&dist.cluster).await?;
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
return Err(format!("decommission did not remain active across the S3 operations: {status}").into());
}
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
assert_inventory(&live, &bucket, &inventory).await?;
let rebalance_id = start_rebalance(&dist.cluster).await?;
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
retrying_put(
&live,
&bucket,
"during-rebalance.bin",
b"written-while-rebalancing".to_vec(),
Duration::from_secs(30),
)
.await?;
retrying_get_equals(
&live,
&bucket,
"during-rebalance.bin",
b"written-while-rebalancing",
Duration::from_secs(30),
)
.await?;
let status = rebalance_status_json(&dist.cluster).await?;
if !rebalance_running_with_progress(&status, &rebalance_id)? {
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
}
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
Ok(())
}
@@ -0,0 +1,128 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{
DistCluster, TestResult, cluster_admin_ok, enable_versioning, put_object, unique_bucket, wait_for_replicated_bytes,
wait_until,
};
use crate::common::{init_logging, signed_request};
use http::{Method, StatusCode};
use rustfs_madmin::{PeerSite, ReplicateAddStatus, SiteReplicationInfo, SyncStatus};
use std::time::Duration;
async fn site_replication_add(
cluster: &crate::common::RustFSTestClusterEnvironment,
sites: &[PeerSite],
) -> TestResult<ReplicateAddStatus> {
let url = format!("{}/rustfs/admin/v3/site-replication/add?replicateILMExpiry=false", cluster.nodes[0].url);
let response = signed_request(
Method::PUT,
&url,
&cluster.access_key,
&cluster.secret_key,
Some(serde_json::to_vec(sites)?),
Some("application/json"),
)
.await?;
if response.status() != StatusCode::OK {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("site replication add failed: {status} {body}").into());
}
Ok(serde_json::from_slice(&response.bytes().await?)?)
}
async fn site_replication_info(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult<SiteReplicationInfo> {
let body = cluster_admin_ok(cluster, Method::GET, "/rustfs/admin/v3/site-replication/info", None).await?;
Ok(serde_json::from_str(&body)?)
}
async fn wait_for_site_replication_enabled(cluster: &crate::common::RustFSTestClusterEnvironment) -> TestResult {
wait_until(
Duration::from_secs(30),
|| async {
let info = site_replication_info(cluster).await?;
Ok(info.enabled && info.sites.len() == 2 && info.sites.iter().all(|site| site.sync_state == SyncStatus::Enable))
},
"site replication enabled with two synchronized sites",
)
.await
}
#[tokio::test]
async fn four_node_site_replication_replicates_object_to_peer_site() -> TestResult {
init_logging();
let (site_a, site_b) = DistCluster::start_replication_pair().await?;
let bucket = unique_bucket("siterepl");
site_a.create_bucket(&bucket).await?;
site_b.create_bucket(&bucket).await?;
let client_a = site_a.client(0)?;
let client_b = site_b.client(0)?;
enable_versioning(&client_a, &bucket).await?;
enable_versioning(&client_b, &bucket).await?;
let sites = vec![
PeerSite {
name: "site-a".to_string(),
endpoint: site_a.cluster.nodes[0].url.clone(),
access_key: site_a.cluster.access_key.clone(),
secret_key: site_a.cluster.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "site-b".to_string(),
endpoint: site_b.cluster.nodes[0].url.clone(),
access_key: site_b.cluster.access_key.clone(),
secret_key: site_b.cluster.secret_key.clone(),
..Default::default()
},
];
let add_status = site_replication_add(&site_a.cluster, &sites).await?;
assert!(
add_status.success && add_status.err_detail.is_empty() && add_status.initial_sync_error_message.is_empty(),
"site replication add reported failure: {add_status:?}"
);
wait_for_site_replication_enabled(&site_a.cluster).await?;
wait_for_site_replication_enabled(&site_b.cluster).await?;
let info_a = site_replication_info(&site_a.cluster).await?;
let remote = info_a
.sites
.iter()
.find(|site| site.name == "site-b")
.ok_or_else(|| format!("site A info omitted the configured site-b peer: {info_a:?}"))?;
assert_eq!(remote.endpoint, site_b.cluster.nodes[0].url);
let deployment_ids: std::collections::BTreeSet<_> = info_a.sites.iter().map(|site| site.deployment_id.as_str()).collect();
assert!(
deployment_ids.iter().all(|deployment_id| !deployment_id.is_empty()) && deployment_ids.len() == 2,
"site peers must have two distinct non-empty deployment IDs: {info_a:?}"
);
assert!(info_a.retry_stats.is_none(), "site A has pending replication retries: {info_a:?}");
assert!(info_a.pending_operation.is_none(), "site A has a pending operation: {info_a:?}");
let key = "site-object.bin";
let body = b"four-node-site-replication".to_vec();
put_object(&client_a, &bucket, key, body.clone()).await?;
wait_for_replicated_bytes(&client_b, &bucket, key, &body, Duration::from_secs(60)).await?;
let peer_b = site_b.client(3)?;
wait_for_replicated_bytes(&peer_b, &bucket, key, &body, Duration::from_secs(20)).await?;
let reverse_key = "reverse/site-object.bin";
let reverse_body = b"site-b-to-site-a".to_vec();
put_object(&site_b.client(2)?, &bucket, reverse_key, reverse_body.clone()).await?;
wait_for_replicated_bytes(&site_a.client(3)?, &bucket, reverse_key, &reverse_body, Duration::from_secs(60)).await?;
Ok(())
}
@@ -0,0 +1,345 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! 4-node upgrade coverage for historical objects and IAM AK/SK.
//!
//! Complements `upgrade_compatibility_test` (single-node SSE/multipart and
//! mixed-version listing). This module pins the distributed contract the
//! hardware upgrade chain is meant to catch: after a 4-node upgrade, objects
//! written on the previous release still read back, and IAM user credentials
//! created before the upgrade still authenticate.
//!
//! Requires `RUSTFS_UPGRADE_SOURCE_BINARY` pointing at the pinned previous
//! release. The `e2e-distributed` workflow downloads that binary; a local run
//! without it fails closed rather than skipping.
use super::harness::{
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, enable_versioning, get_object_bytes, put_object,
unique_bucket, wait_until,
};
use crate::common::{
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use std::path::{Path, PathBuf};
use std::time::Duration;
use uuid::Uuid;
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
const IAM_SECRET: &str = "UpgradeTestSecretKey1";
const WRONG_SECRET: &str = "WrongSecretKey000000";
const CREDENTIAL_TIMEOUT: Duration = Duration::from_secs(30);
struct UpgradeSeed {
history_bucket: String,
history_key: &'static str,
history_body: Vec<u8>,
versioned_bucket: String,
versioned_key: &'static str,
version1: String,
version1_body: Vec<u8>,
version2: String,
version2_body: Vec<u8>,
iam_bucket: String,
iam_key: &'static str,
iam_body: Vec<u8>,
iam_user: String,
iam_secret: &'static str,
}
fn source_binary() -> TestResult<PathBuf> {
let path = std::env::var_os(SOURCE_BINARY_ENV).map(PathBuf::from).ok_or_else(|| {
format!(
"{SOURCE_BINARY_ENV} must point to the pinned previous release binary (the e2e-distributed workflow downloads it)"
)
})?;
if !path.is_file() {
return Err(format!("upgrade source binary does not exist: {}", path.display()).into());
}
Ok(path)
}
fn capture_upgrade_logs(cluster: &mut DistCluster, label: &str) -> TestResult {
let Some(log_dir) = std::env::var_os("RUSTFS_E2E_LOG_DIR") else {
return Ok(());
};
std::fs::create_dir_all(&log_dir)?;
for node_idx in 0..cluster.cluster.nodes.len() {
let path = Path::new(&log_dir).join(format!("{label}-node-{node_idx}.log"));
cluster
.cluster
.set_node_capture_log_path(node_idx, path.to_string_lossy().into_owned())?;
}
Ok(())
}
fn iam_rw_policy(bucket: &str) -> String {
serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["s3:*"],
"Resource": [
format!("arn:aws:s3:::{bucket}"),
format!("arn:aws:s3:::{bucket}/*")
]
}]
})
.to_string()
}
async fn create_iam_user(dist: &DistCluster, user: &str, secret: &str, policy_name: &str, bucket: &str) -> TestResult {
let url = &dist.cluster.nodes[0].url;
let access = &dist.cluster.access_key;
let admin_secret = &dist.cluster.secret_key;
admin_create_user_via(AdminTransport::Signed, url, access, admin_secret, user, secret).await?;
admin_add_canned_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, &iam_rw_policy(bucket)).await?;
admin_attach_user_policy_via(AdminTransport::Signed, url, access, admin_secret, policy_name, user).await?;
Ok(())
}
async fn wait_for_put(client: &Client, bucket: &str, key: &str, body: Vec<u8>, label: &str) -> TestResult {
wait_until(
CREDENTIAL_TIMEOUT,
|| {
let client = client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
let body = body.clone();
async move {
put_object(&client, &bucket, &key, body).await?;
Ok(true)
}
},
label,
)
.await
}
async fn wait_for_bytes(client: &Client, bucket: &str, key: &str, expected: &[u8], label: &str) -> TestResult {
wait_until(
CREDENTIAL_TIMEOUT,
|| {
let client = client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
let expected = expected.to_vec();
async move {
let got = get_object_bytes(&client, &bucket, &key).await?;
Ok(got == expected)
}
},
label,
)
.await
}
async fn seed_history_and_iam(dist: &DistCluster) -> TestResult<UpgradeSeed> {
let history_bucket = unique_bucket("upg-hist");
let versioned_bucket = unique_bucket("upg-ver");
let iam_bucket = unique_bucket("upg-iam");
dist.create_bucket(&history_bucket).await?;
dist.create_bucket(&versioned_bucket).await?;
dist.create_bucket(&iam_bucket).await?;
let root = dist.client(0)?;
enable_versioning(&root, &versioned_bucket).await?;
let history_key = "plain-history.bin";
let history_body = b"written by the previous 4-node release".to_vec();
put_object(&root, &history_bucket, history_key, history_body.clone()).await?;
let versioned_key = "versioned-history.txt";
let version1_body = b"version-one-before-upgrade".to_vec();
let version1 = root
.put_object()
.bucket(&versioned_bucket)
.key(versioned_key)
.body(aws_sdk_s3::primitives::ByteStream::from(version1_body.clone()))
.send()
.await?
.version_id()
.ok_or("first versioned PUT omitted version ID")?
.to_string();
let version2_body = b"version-two-before-upgrade".to_vec();
let version2 = root
.put_object()
.bucket(&versioned_bucket)
.key(versioned_key)
.body(aws_sdk_s3::primitives::ByteStream::from(version2_body.clone()))
.send()
.await?
.version_id()
.ok_or("second versioned PUT omitted version ID")?
.to_string();
let iam_user = format!("upg{}", &Uuid::new_v4().simple().to_string()[..8]);
let policy_name = format!("upgpol{}", &Uuid::new_v4().simple().to_string()[..8]);
create_iam_user(dist, &iam_user, IAM_SECRET, &policy_name, &iam_bucket).await?;
let iam_key = "iam-history.bin";
let iam_body = b"written with pre-upgrade IAM AK/SK".to_vec();
let iam_client = dist.client_with_credentials(1, &iam_user, IAM_SECRET)?;
wait_for_put(&iam_client, &iam_bucket, iam_key, iam_body.clone(), "IAM user PUT before upgrade").await?;
Ok(UpgradeSeed {
history_bucket,
history_key,
history_body,
versioned_bucket,
versioned_key,
version1,
version1_body,
version2,
version2_body,
iam_bucket,
iam_key,
iam_body,
iam_user,
iam_secret: IAM_SECRET,
})
}
async fn assert_history_and_iam(dist: &DistCluster, seed: &UpgradeSeed, context: &str) -> TestResult {
let root_a = dist.client(0)?;
let root_b = dist.client(3)?;
wait_for_bytes(
&root_b,
&seed.history_bucket,
seed.history_key,
&seed.history_body,
&format!("{context}: root GET historical object"),
)
.await?;
assert_object_bytes(&root_a, &seed.history_bucket, seed.history_key, &seed.history_body).await?;
let v1 = root_b
.get_object()
.bucket(&seed.versioned_bucket)
.key(seed.versioned_key)
.version_id(&seed.version1)
.send()
.await?;
let v1_body = v1.body.collect().await?.into_bytes();
if v1_body.as_ref() != seed.version1_body.as_slice() {
return Err(format!("{context}: version 1 bytes changed after upgrade").into());
}
let v2 = root_a
.get_object()
.bucket(&seed.versioned_bucket)
.key(seed.versioned_key)
.version_id(&seed.version2)
.send()
.await?;
let v2_body = v2.body.collect().await?.into_bytes();
if v2_body.as_ref() != seed.version2_body.as_slice() {
return Err(format!("{context}: version 2 bytes changed after upgrade").into());
}
let users = cluster_admin_ok(&dist.cluster, http::Method::GET, "/rustfs/admin/v3/list-users", None).await?;
if !users.contains(&seed.iam_user) {
return Err(format!("{context}: list-users lost IAM user {}: {users}", seed.iam_user).into());
}
let iam_on_upgraded = dist.client_with_credentials(0, &seed.iam_user, seed.iam_secret)?;
let iam_on_peer = dist.client_with_credentials(3, &seed.iam_user, seed.iam_secret)?;
wait_for_bytes(
&iam_on_upgraded,
&seed.iam_bucket,
seed.iam_key,
&seed.iam_body,
&format!("{context}: IAM GET historical object on node 0"),
)
.await?;
wait_for_bytes(
&iam_on_peer,
&seed.iam_bucket,
seed.iam_key,
&seed.iam_body,
&format!("{context}: IAM GET historical object on node 3"),
)
.await?;
let post_key = format!("after-upgrade-{context}.txt");
let post_body = format!("{context}: written with the same IAM AK/SK after upgrade").into_bytes();
wait_for_put(
&iam_on_peer,
&seed.iam_bucket,
&post_key,
post_body.clone(),
&format!("{context}: IAM PUT after upgrade"),
)
.await?;
assert_object_bytes(&iam_on_upgraded, &seed.iam_bucket, &post_key, &post_body).await?;
let bad = dist.client_with_credentials(1, &seed.iam_user, WRONG_SECRET)?;
match bad.get_object().bucket(&seed.iam_bucket).key(seed.iam_key).send().await {
Ok(_) => return Err(format!("{context}: wrong secret must not read the IAM object").into()),
Err(error) => {
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
let rejected = code == Some("SignatureDoesNotMatch")
|| code == Some("InvalidAccessKeyId")
|| code == Some("AccessDenied")
|| code == Some("InvalidArgument")
|| error.raw_response().is_some_and(|response| response.status().as_u16() == 403);
if !rejected {
return Err(format!("{context}: wrong secret failed with unexpected error {error:?}").into());
}
}
}
let post_root_key = format!("root-after-{context}.bin");
let post_root_body = format!("{context}: root write after upgrade").into_bytes();
put_object(&root_a, &seed.history_bucket, &post_root_key, post_root_body.clone()).await?;
assert_object_bytes(&root_b, &seed.history_bucket, &post_root_key, &post_root_body).await?;
Ok(())
}
#[tokio::test]
async fn four_node_direct_upgrade_preserves_history_and_iam_credentials() -> TestResult {
init_logging();
let previous = source_binary()?;
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
capture_upgrade_logs(&mut dist, "direct-upgrade")?;
dist.start_from_binary(&previous).await?;
let seed = seed_history_and_iam(&dist).await?;
dist.restart_with_current_binary().await?;
assert_history_and_iam(&dist, &seed, "direct").await?;
Ok(())
}
#[tokio::test]
async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> TestResult {
init_logging();
let previous = source_binary()?;
let mut dist = DistCluster::new_stopped(DistLayout::FourNodeFourDisk).await?;
capture_upgrade_logs(&mut dist, "rolling-upgrade")?;
dist.start_from_binary(&previous).await?;
let seed = seed_history_and_iam(&dist).await?;
dist.replace_node_with_current_binary(0).await?;
assert_history_and_iam(&dist, &seed, "one-current-node").await?;
for node_idx in [1, 2] {
dist.replace_node_with_current_binary(node_idx).await?;
}
assert_history_and_iam(&dist, &seed, "one-previous-node").await?;
dist.replace_node_with_current_binary(3).await?;
assert_history_and_iam(&dist, &seed, "homogeneous-current").await?;
Ok(())
}
@@ -0,0 +1,188 @@
// Copyright 2026 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use super::harness::{DistCluster, DistLayout, TestResult, enable_versioning, get_object_bytes, put_object, unique_bucket};
use crate::common::init_logging;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
#[tokio::test]
async fn four_node_four_drive_versioning_put_list_get_delete_marker() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("version");
dist.create_bucket(&bucket).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
enable_versioning(&writer, &bucket).await?;
let key = "versioned.txt";
let v1_id = writer
.put_object()
.bucket(&bucket)
.key(key)
.body(b"v1".to_vec().into())
.send()
.await?
.version_id()
.ok_or("v1 PUT omitted version ID")?
.to_string();
let v2_id = writer
.put_object()
.bucket(&bucket)
.key(key)
.body(b"v2".to_vec().into())
.send()
.await?
.version_id()
.ok_or("v2 PUT omitted version ID")?
.to_string();
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
let matching_versions: Vec<_> = versions
.versions()
.iter()
.filter(|version| version.key() == Some(key))
.collect();
assert_eq!(matching_versions.len(), 2, "fresh key must have exactly two versions: {versions:?}");
assert!(versions.delete_markers().is_empty(), "fresh key unexpectedly has a delete marker");
assert!(
matching_versions
.iter()
.any(|version| version.version_id() == Some(v1_id.as_str()) && version.is_latest() != Some(true)),
"v1 was not the historical version: {versions:?}"
);
assert!(
matching_versions
.iter()
.any(|version| version.version_id() == Some(v2_id.as_str()) && version.is_latest() == Some(true)),
"v2 was not the latest version: {versions:?}"
);
let latest = get_object_bytes(&reader, &bucket, key).await?;
assert_eq!(latest, b"v2");
let older = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
let older_body = older.body.collect().await?.into_bytes();
assert_eq!(older_body.as_ref(), b"v1");
let deleted = writer.delete_object().bucket(&bucket).key(key).send().await?;
assert_eq!(deleted.delete_marker(), Some(true));
let marker_id = deleted.version_id().ok_or("DeleteObject omitted delete-marker version ID")?;
let after_delete = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
let matching_markers: Vec<_> = after_delete
.delete_markers()
.iter()
.filter(|marker| marker.key() == Some(key))
.collect();
assert_eq!(
matching_markers.len(),
1,
"delete marker missing or duplicated after current-version delete: {after_delete:?}"
);
assert!(
matching_markers[0].version_id() == Some(marker_id) && matching_markers[0].is_latest() == Some(true),
"DeleteObject response and ListObjectVersions disagree about the marker: {after_delete:?}"
);
let latest_after_delete = reader.get_object().bucket(&bucket).key(key).send().await;
match latest_after_delete {
Ok(_) => return Err("current version should be a delete marker".into()),
Err(error)
if error
.as_service_error()
.and_then(ProvideErrorMetadata::code)
.is_some_and(|code| code == "NoSuchKey" || code == "NotFound") => {}
Err(error) => return Err(error.into()),
}
let restored = reader.get_object().bucket(&bucket).key(key).version_id(&v1_id).send().await?;
let restored_body = restored.body.collect().await?.into_bytes();
assert_eq!(restored_body.as_ref(), b"v1");
writer
.delete_object()
.bucket(&bucket)
.key(key)
.version_id(marker_id)
.send()
.await?;
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"v2");
Ok(())
}
#[tokio::test]
async fn four_node_versioning_suspension_keeps_one_null_version_and_history() -> TestResult {
init_logging();
let dist = DistCluster::start(DistLayout::FourByFour).await?;
let bucket = unique_bucket("suspend");
dist.create_bucket(&bucket).await?;
let writer = dist.client(0)?;
let reader = dist.client(3)?;
enable_versioning(&writer, &bucket).await?;
let key = "suspended.txt";
let original = writer
.put_object()
.bucket(&bucket)
.key(key)
.body(b"enabled-history".to_vec().into())
.send()
.await?
.version_id()
.ok_or("enabled PUT omitted version ID")?
.to_string();
writer
.put_bucket_versioning()
.bucket(&bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Suspended)
.build(),
)
.send()
.await?;
put_object(&writer, &bucket, key, b"null-one".to_vec()).await?;
put_object(&writer, &bucket, key, b"null-two".to_vec()).await?;
assert_eq!(get_object_bytes(&reader, &bucket, key).await?, b"null-two");
let versions = reader.list_object_versions().bucket(&bucket).prefix(key).send().await?;
let matching: Vec<_> = versions
.versions()
.iter()
.filter(|version| version.key() == Some(key))
.collect();
assert!(matching.iter().any(|version| version.version_id() == Some(original.as_str())));
let null_version_count = matching
.iter()
.filter(|version| {
matches!(
version.version_id(),
None | Some("") | Some("null") | Some("00000000-0000-0000-0000-000000000000")
)
})
.count();
assert_eq!(null_version_count, 1, "suspended overwrites must keep one null version: {versions:?}");
let historical = reader
.get_object()
.bucket(&bucket)
.key(key)
.version_id(&original)
.send()
.await?;
assert_eq!(historical.body.collect().await?.into_bytes().as_ref(), b"enabled-history");
Ok(())
}
+10 -4
View File
@@ -1,11 +1,17 @@
# Programmable fake S3 target
This module is the shared failure-injection boundary for replication end-to-end tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
This module is the shared failure-injection boundary for replication end-to-end tests and the programmable external source for on-demand-migration (ODM) tests. It runs an in-process, path-style S3 endpoint backed by `s3s`; no production crate depends on it.
`FakeS3Target::start()` creates the listener. Add target buckets with `create_bucket`, point a RustFS remote target at `address()`, use `FAKE_ACCESS_KEY` / `FAKE_SECRET_KEY`, then enqueue per-operation faults with `inject`. Faults for one operation are consumed in FIFO order and do not consume faults queued for another operation. A fault is consumed only after `s3s` verifies the full request signature, so anonymous, other-access-key, and bad-signature traffic cannot disturb a script.
Supported data operations are HeadBucket, GetBucketVersioning, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
Supported data operations are HeadBucket, GetBucketVersioning, ListObjectsV2, PUT/GET/HEAD/DELETE Object, Get/Put/Delete ObjectTagging (tags live per version; Put replaces the whole set, Delete clears it), and create/upload/complete/abort multipart upload. `create_bucket` models general-purpose buckets in S3's shared global namespace; account-regional namespace buckets and their `-an` names are intentionally out of scope. Buckets created with `create_bucket` are versioned: PUT creates a version, DELETE without `versionId` creates a delete marker, and DELETE with `versionId` removes exactly that version. Internal source version IDs must be UUIDs and are stored canonically. Source mtime is honored only for source-replication PUT/DELETE requests; absent or invalid values use receipt time, matching RustFS, while multipart completion always uses receipt time. Replicated versions are ordered newest-first by source mtime so late older versions and delete markers do not become current. Equal mtimes prefer objects over delete markers, then canonical UUID order; RustFS's internal FileMeta signature tie-break is intentionally out of scope because it is not part of the target S3 protocol. Multipart part numbers follow S3's `1..=10000` range, and every completed part except the final part must be at least 5 MiB.
Fault actions cover HTTP 401/403/503 responses, pre-dispatch delay, connection abort when a logical request-body threshold is reached, streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions. Each record also journals a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
`create_bucket_with_object_lock(name)` creates a versioned bucket whose GetObjectLockConfiguration reports `Enabled`; every other bucket answers `ObjectLockConfigurationNotFoundError`, the code RustFS's replication-check classifies as "not enabled". Three switches model remote-target behaviors the fleet has shown, so the outbound target matrix (`crates/e2e_test/src/replication_target_matrix_test.rs`) can replicate every object shape against each: `assign_own_version_ids(true)` ignores the source version id and mints its own (AWS S3 / Wasabi); `reject_aws_chunked_uploads(true)` refuses any PutObject or UploadPart announcing `aws-chunked` framing (`Content-Encoding: aws-chunked`, an `x-amz-trailer`, or a `STREAMING-*` payload hash) with `InvalidRequest` before the body is read (SeaweedFS 3.97, rustfs#6853); `require_checksum_for_object_lock(true)` rejects a PutObject carrying any `x-amz-object-lock-*` header unless it also carries `Content-MD5`, an `x-amz-checksum-*` header, or `x-amz-sdk-checksum-algorithm` (AWS S3 / MinIO, rustfs#7082). Independently of that switch, a `Content-MD5` header is always verified against the body and a mismatch answers `BadDigest`.
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type at 1 KiB. A PUT or uploaded part is capped at 64 MiB; a completed multipart object and all stored object/part data are capped at 128 MiB. Body drain, body-permit waits, delay, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
`create_bucket_with_mode(name, BucketMode::Unversioned)` models a plain migration source: PUT overwrites in place, DELETE removes the key without a delete marker, GetBucketVersioning reports no status, and no `x-amz-version-id` is returned by PUT, GET, HEAD, tagging, or multipart completion. The only `versionId` such a bucket accepts is `null`; any other value is rejected with `InvalidArgument`. The mode is fixed at creation.
ListObjectsV2 lists current versions only (a key whose newest version is a delete marker is hidden) in byte order and supports `prefix`, `delimiter`, `max-keys` (clamped to 1000), `start-after`, and `continuation-token`; common prefixes count toward `max-keys`, `IsTruncated` / `NextContinuationToken` / `KeyCount` follow S3, and continuation tokens are opaque. `encoding-type` and `fetch-owner` are accepted but ignored, and ListObjects (v1) is not implemented. GET and HEAD honor `Range` in the `bytes=first-last`, `bytes=first-`, and `bytes=-suffix` forms with a 206 status, exact `Content-Range`, and `Accept-Ranges: bytes`; unsatisfiable ranges answer 416 `InvalidRange` with `Content-Range: bytes */<length>`. PUT and CreateMultipartUpload accept `Content-Type`, `Content-Encoding`, `Content-Disposition`, `Content-Language`, `Cache-Control`, `Expires`, and `x-amz-meta-*` (names stored lowercased), and HEAD/GET replay them verbatim together with `Last-Modified` and the ETag (hex MD5 for single PUTs, `<md5-of-part-md5s>-<parts>` for multipart objects). `put_seed_object` stores an object directly, bypassing the wire, the fault script, and the journal, so a source can be seeded without polluting the assertions a scenario later makes.
Fault actions cover HTTP 401/403/503 responses (`Status`), any 4xx/5xx status paired with the matching S3 error code (`ResponseStatus`), pre-dispatch delay, holding a fully computed successful response before its first byte (`Stall`), connection abort when a logical request-body threshold is reached, GetObject bodies cut off after N bytes while `Content-Length` announces the full size (`TruncateBodyAt`), GetObject bodies delivered in fixed slices with a pause between them (`SlowSendBody`, a mid-body stall rather than a first-byte one), streaming slow drain, and a deliberately wrong response ETag (including multipart-complete XML). `requests()` returns the ordered, credential-free request journal for assertions and `count_requests(operation, key)` counts entries for one exact key. Each record journals the `Range` and `User-Agent` request headers, the ListObjectsV2 `prefix` and `continuation-token` query values, a `TransportSnapshot` — whether the body was announced as `aws-chunked`, the verbatim `Content-MD5`, the sorted `x-amz-checksum-*` / `x-amz-sdk-checksum-algorithm` header names, and whether any `x-amz-object-lock-*` header was present — and a `ProxyHeaderSnapshot` — the read-proxy anti-loop marker (`x-{rustfs,minio}-source-proxy-request`), the replication-check exemption header, and the client SSE-C header family (algorithm and key-MD5 values; for the key itself only its presence) — so proxy tests can pin the exact wire contract.
The listener is loopback-only. It admits at most 64 active connections and two concurrently buffered request bodies; authenticated multipart-complete XML collection and assembly take both body permits. Keep-alive is disabled, request-header reads are bounded to 30 seconds, a parsed request is bounded to 65 seconds, and the complete connection lifetime is bounded to 100 seconds. It retains at most 256 buckets, 4,096 journal entries, 4,096 scripted faults, 4,096 object versions, 256 multipart uploads, and 10,000 multipart parts. Retained identifiers are capped at 1 KiB, user metadata at 2 KiB, and content type and each standard object header at 1 KiB. By default a PUT or uploaded part is capped at 64 MiB and a completed multipart object and all stored object/part data are capped at 128 MiB; `FakeS3Target::start_with_options(FakeS3TargetOptions { max_object_bytes })` raises the object cap up to 256 MiB, and the total budget then becomes twice the object cap (never below 128 MiB). Body drain, body-permit waits, delay, stall, and slow-drain execution are bounded to 30 seconds; each slow-drain slice delay must be below that bound.
File diff suppressed because it is too large Load Diff
@@ -17,15 +17,115 @@
#[cfg(test)]
mod tests {
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging};
use crate::common::{
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
};
use crate::storage_api::RUSTFS_META_BUCKET;
use aws_sdk_s3::primitives::ByteStream;
use http::Method;
use std::collections::HashSet;
use std::error::Error;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::net::TcpStream;
use tokio::time::{Duration, Instant, sleep, timeout};
use tracing::info;
const POOL_METADATA_OBJECT: &str = "pool.bin";
struct TcpPortBlackhole {
port: u16,
comment: String,
use_sudo: bool,
active: bool,
}
impl TcpPortBlackhole {
fn install(address: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
let address = address.parse::<SocketAddr>()?;
if !address.ip().is_loopback() {
return Err(format!("refusing to install a test firewall rule for non-loopback address {address}").into());
}
let id = Command::new("id").arg("-u").output()?;
if !id.status.success() {
return Err(format!("failed to determine the test process uid: {}", String::from_utf8_lossy(&id.stderr)).into());
}
let use_sudo = String::from_utf8_lossy(&id.stdout).trim() != "0";
let mut blackhole = Self {
port: address.port(),
comment: format!("rustfs-e2e-{}", uuid::Uuid::new_v4()),
use_sudo,
active: false,
};
blackhole.run_iptables(true)?;
blackhole.active = true;
Ok(blackhole)
}
fn restore(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
if !self.active {
return Ok(());
}
self.run_iptables(false)?;
self.active = false;
Ok(())
}
fn run_iptables(&self, insert: bool) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut command = if self.use_sudo {
let mut command = Command::new("sudo");
command.args(["-n", "iptables"]);
command
} else {
Command::new("iptables")
};
command.args(["-w", "5"]);
if insert {
command.args(["-I", "OUTPUT", "1"]);
} else {
command.args(["-D", "OUTPUT"]);
}
let port = self.port.to_string();
let output = command
.args([
"-p",
"tcp",
"-d",
"127.0.0.1/32",
"--dport",
&port,
"-m",
"comment",
"--comment",
&self.comment,
"-j",
"DROP",
])
.output()?;
if !output.status.success() {
let action = if insert { "install" } else { "remove" };
return Err(format!(
"failed to {action} endpoint blackhole rule for port {}: stdout={}, stderr={}",
self.port,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.into());
}
Ok(())
}
}
impl Drop for TcpPortBlackhole {
fn drop(&mut self) {
if let Err(error) = self.restore() {
eprintln!("failed to remove {} firewall rule during test cleanup: {error}", self.comment);
}
}
}
fn has_file_under(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(path) else {
return false;
@@ -91,6 +191,62 @@ mod tests {
.count()
}
async fn assert_all_nodes_list_exact_keys(
clients: &[aws_sdk_s3::Client],
bucket: &str,
expected_keys: &HashSet<String>,
) -> Result<(), Box<dyn Error + Send + Sync>> {
const PAGE_SIZE: i32 = 10;
for (node_index, client) in clients.iter().enumerate() {
let mut listed_keys = Vec::new();
let mut continuation_token = None;
let max_pages = expected_keys.len().div_ceil(PAGE_SIZE as usize) + 1;
let mut page_count = 0;
loop {
page_count += 1;
if page_count > max_pages {
return Err(format!("node {node_index} listing exceeded the bounded {max_pages}-page budget").into());
}
let response = timeout(
Duration::from_secs(15),
client
.list_objects_v2()
.bucket(bucket)
.max_keys(PAGE_SIZE)
.set_continuation_token(continuation_token.clone())
.send(),
)
.await??;
listed_keys.extend(
response
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_owned)),
);
if !response.is_truncated().unwrap_or(false) {
break;
}
let next_token = response
.next_continuation_token()
.filter(|token| Some(*token) != continuation_token.as_deref())
.ok_or_else(|| format!("node {node_index} returned a truncated listing without a new continuation token"))?;
continuation_token = Some(next_token.to_owned());
}
let listed_key_set = listed_keys.iter().cloned().collect::<HashSet<_>>();
assert_eq!(
listed_keys.len(),
listed_key_set.len(),
"node {node_index} returned duplicate keys after recovery: {listed_keys:?}"
);
assert_eq!(
&listed_key_set, expected_keys,
"node {node_index} did not expose the complete recovered namespace"
);
}
Ok(())
}
fn heal_task_status_diagnostic(body: &str) -> String {
let Ok(status) = serde_json::from_str::<serde_json::Value>(body) else {
return body.to_string();
@@ -125,11 +281,12 @@ mod tests {
&& operations["retryingTasks"].as_u64() == Some(0)
}
// Queued low-priority repairs cannot execute while the single admin slot is
// occupied; ownership is determined by active and retrying tasks only.
fn only_admin_heal_is_active(status: &serde_json::Value) -> bool {
let operations = &status["healOperations"];
status["clusterStatusComplete"] == serde_json::Value::Bool(true)
&& status["state"].as_str() == Some("active")
&& operations["queueLength"].as_u64() == Some(0)
&& operations["activeTasks"].as_u64() == Some(1)
&& operations["retryingTasks"].as_u64() == Some(0)
&& operations["activeBySource"]["admin"].as_u64() == Some(1)
@@ -547,41 +704,156 @@ mod tests {
.into())
}
async fn wait_for_scanner_cycle_after(
cluster: &RustFSTestClusterEnvironment,
previous_cycle_end: u64,
) -> Result<u64, Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let mut latest_cycle_end = 0;
let mut versions_observed = false;
let mut observations = Vec::with_capacity(cluster.nodes.len());
for (node_index, node) in cluster.nodes.iter().enumerate() {
let (status, body) = timeout(
Duration::from_secs(5),
admin_request(
&node.url,
Method::GET,
"/rustfs/admin/v3/scanner/status",
None,
&cluster.access_key,
&cluster.secret_key,
),
)
.await??;
assert_eq!(status, 200, "scanner status must be available: {body}");
let status: serde_json::Value = serde_json::from_str(&body)?;
assert_eq!(status["enabled"].as_bool(), Some(true), "scanner must stay enabled: {status}");
let metrics = &status["metrics"];
let cycle_end = metrics["last_cycle_end_unix_secs"]
.as_u64()
.ok_or("scanner status is missing its completed-cycle timestamp")?;
let versions_scanned = metrics["versions_scanned"]
.as_u64()
.ok_or("scanner status is missing its version-coverage counter")?;
latest_cycle_end = latest_cycle_end.max(cycle_end);
versions_observed |= versions_scanned > 0;
observations.push(format!(
"node{node_index}: end={cycle_end}, versions={versions_scanned}, cycle={}, active={}, leader={}, result={}",
metrics["current_cycle"],
metrics["current_cycle_active"],
metrics["leader_lock_state"],
metrics["last_cycle_result"],
));
}
// The coordinator records cycle completion, but remote workers
// record scanned versions. Both witnesses need not share a node.
if latest_cycle_end > previous_cycle_end && versions_observed {
return Ok(latest_cycle_end);
}
if Instant::now() >= deadline {
return Err(format!(
"enabled scanner did not complete an object-scanning cycle after {previous_cycle_end}: {observations:?}"
)
.into());
}
sleep(Duration::from_millis(250)).await;
}
}
// Keep the original unformatted-disk scenario above. This case retains the
// format identity so only the explicit admin task can rebuild missing data.
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_resumes_missing_remote_shards_after_node_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{
run_cluster_root_heal_interruption(InterruptionScenario::IsolatedTargetRestart).await
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_restart()
-> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestart),
)
.await?
}
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
{
timeout(
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundCoordinatorRestart),
)
.await?
}
#[cfg(target_os = "linux")]
#[tokio::test(flavor = "multi_thread")]
async fn test_cluster_root_heal_recovers_after_target_endpoint_blackhole() -> Result<(), Box<dyn Error + Send + Sync>> {
timeout(
Duration::from_secs(420),
run_cluster_root_heal_interruption(InterruptionScenario::TargetEndpointBlackhole),
)
.await?
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum InterruptionScenario {
IsolatedTargetRestart,
BackgroundTargetRestart,
BackgroundCoordinatorRestart,
TargetEndpointBlackhole,
}
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
let (background_enabled, interruption_node, interruption_kind) = match scenario {
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
};
init_logging();
info!(
event = "heal_restart_started",
event = "heal_interruption_started",
component = "e2e_test",
subsystem = "heal",
"Starting root-heal restart test"
background_enabled,
interruption_node,
interruption_kind,
"Starting root-heal interruption test"
);
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
cluster.set_env("RUSTFS_HEAL_AUTO_HEAL_ENABLE", "false");
cluster.set_env("RUSTFS_HEAL_MRF_ENABLE", "false");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "false");
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_HEALS", "1");
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_PER_SET", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
// Keep all storage nodes' Heal runtimes enabled so their disk services
// complete normal registration after restart. Scanner, auto-heal and
// MRF are disabled; the pre-root idle barrier below drains the direct
// outage-object repair before the explicit admin task starts.
// Heal control uses the first lexicographically sorted grid host.
// Keep that coordinator distinct from the remote target at index 1.
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
cluster.set_env("RUSTFS_HEAL_AUTO_HEAL_ENABLE", background_enabled.to_string());
cluster.set_env("RUSTFS_HEAL_MRF_ENABLE", background_enabled.to_string());
cluster.set_env("RUSTFS_SCANNER_ENABLED", background_enabled.to_string());
if background_enabled {
// Only the scanner cadence is accelerated. Keep normal Heal
// concurrency and every automatic recovery owner enabled.
for &(key, value) in FAST_DATA_USAGE_SCANNER_ENV {
cluster.set_env(key, value);
}
} else {
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_HEALS", "1");
cluster.set_env("RUSTFS_HEAL_MAX_CONCURRENT_PER_SET", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
}
// Keep every node's Heal runtime enabled for normal disk registration.
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
cluster.set_env("RUST_LOG", server_rust_log);
if let Ok(log_dir) = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR") {
std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
}
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| format!("{}/logs", cluster.temp_dir));
std::fs::create_dir_all(&log_dir)?;
for node_index in 0..cluster.nodes.len() {
cluster.set_node_capture_log_path(node_index, format!("{log_dir}/node{node_index}.log"))?;
}
cluster.start().await?;
let clients = cluster.create_all_clients()?;
@@ -634,6 +906,18 @@ mod tests {
});
}
let expected_pool_metadata = if background_enabled {
let census = census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
assert!(
census.is_complete(),
"target must hold complete pool metadata before the fault: {census:?}"
);
wait_for_scanner_cycle_after(&cluster, 0).await?;
Some(census)
} else {
None
};
cluster.stop_node(1)?;
std::fs::remove_dir_all(&replaced_disk)?;
std::fs::create_dir_all(
@@ -691,25 +975,25 @@ mod tests {
.find(|index| !outage_peer_erasure_indices.contains(index))
.ok_or("online outage-object shards leave no erasure index for the replacement target")?;
// The PUT path may have admitted a direct Internal object repair while
// node 1 was offline. Cancel the isolated bucket path before the target
// returns; otherwise it could rebuild the outage object and invalidate
// the explicit-root ownership assertion below.
let cancel_outage_heal_path = format!("/rustfs/admin/v3/heal/{bucket}?forceStop=true");
let (cancel_status, cancel_body) = admin_request(
&cluster.nodes[0].url,
Method::POST,
&cancel_outage_heal_path,
Some(
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#
.to_string(),
),
&cluster.access_key,
&cluster.secret_key,
)
.await?;
if !cancel_status.is_success() {
return Err(format!("cancel outage heal failed: {cancel_status} {cancel_body}").into());
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
if !background_enabled {
// The PUT path may have admitted a direct Internal object repair while
// node 1 was offline. Cancel the isolated bucket path before the target
// returns; otherwise it could rebuild the outage object and invalidate
// the explicit-root ownership assertion below.
let cancel_outage_heal_path = format!("/rustfs/admin/v3/heal/{bucket}?forceStop=true");
let (cancel_status, cancel_body) = admin_request(
&cluster.nodes[0].url,
Method::POST,
&cancel_outage_heal_path,
Some(heal_body.to_string()),
&cluster.access_key,
&cluster.secret_key,
)
.await?;
if !cancel_status.is_success() {
return Err(format!("cancel outage heal failed: {cancel_status} {cancel_body}").into());
}
}
cluster.start_node(1).await?;
@@ -724,7 +1008,12 @@ mod tests {
);
let recovered: serde_json::Value = serde_json::from_str(&status_body)
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
if cluster_heal_is_idle(&recovered) {
let ready = if background_enabled {
recovered["clusterStatusComplete"] == serde_json::Value::Bool(true)
} else {
cluster_heal_is_idle(&recovered)
};
if ready {
break;
}
if Instant::now() >= recovery_deadline {
@@ -732,23 +1021,24 @@ mod tests {
}
sleep(Duration::from_millis(250)).await;
}
assert_eq!(
matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?,
0,
"non-admin Heal is disabled, so the replacement target must remain empty before the explicit root heal"
);
assert!(
!census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?.has_xl_meta,
"the object written during the outage must be absent before the explicit root heal"
);
let pre_heal_replacement = replacement_recovery_status(&cluster).await?;
assert_eq!(
pre_heal_replacement["cluster"]["records"].as_array().map(Vec::len),
Some(0),
"isolated target must not retain an automatic replacement generation: {pre_heal_replacement}"
);
if !background_enabled {
assert_eq!(
matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?,
0,
"non-admin Heal is disabled, so the replacement target must remain empty before the explicit root heal"
);
assert!(
!census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?.has_xl_meta,
"the object written during the outage must be absent before the explicit root heal"
);
assert_eq!(
pre_heal_replacement["cluster"]["records"].as_array().map(Vec::len),
Some(0),
"isolated target must not retain an automatic replacement generation: {pre_heal_replacement}"
);
}
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
let heal_url = format!("{}/rustfs/admin/v3/heal/?forceStart=true", cluster.nodes[0].url);
let heal_start_body = signed_admin_post(&heal_url, Some(heal_body), &cluster.access_key, &cluster.secret_key).await?;
let heal_start: serde_json::Value = serde_json::from_str(&heal_start_body)
@@ -764,24 +1054,39 @@ mod tests {
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(60);
let partial_deadline = Instant::now() + Duration::from_secs(partial_timeout_secs);
let pre_interrupt_status = loop {
loop {
let status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let active_status: serde_json::Value = serde_json::from_str(&status_body)
.map_err(|err| format!("background heal status is not JSON ({err}): {status_body}"))?;
if only_admin_heal_is_active(&active_status) {
break active_status;
let active = if background_enabled {
active_status["state"].as_str() == Some("active")
&& active_status["healOperations"]["activeBySource"]["admin"].as_u64() == Some(1)
} else {
only_admin_heal_is_active(&active_status)
};
if active {
break;
}
if Instant::now() >= partial_deadline {
return Err(format!("root heal never became active within {partial_timeout_secs}s: {active_status}").into());
}
sleep(Duration::from_millis(50)).await;
};
let partial_count = loop {
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
if matching > 0 && matching < expected_manifests.len() {
break matching;
}
let (partial_count, partial_manifest) = loop {
// Hash one committed shard to prove progress without letting a
// full-corpus hash pass consume the interruption window.
let materialized = metadata_count(&replaced_disk, bucket, &expected_manifests);
if materialized > 0
&& materialized < expected_manifests.len()
&& let Some(expected) = expected_manifests
.iter()
.find(|expected| object_metadata_exists_on_disk(&replaced_disk, bucket, &expected.key))
&& census_object_version_on_disk(&replaced_disk, bucket, &expected.key, None)?
.matches_manifest(&expected.shard_census)
{
break (materialized, expected);
}
if matching == expected_manifests.len() {
if materialized == expected_manifests.len() {
return Err(format!(
"root heal rebuilt all {} baseline objects before the target could be interrupted",
expected_manifests.len()
@@ -796,30 +1101,195 @@ mod tests {
}
sleep(Duration::from_millis(10)).await;
};
let pre_interrupt_status_body = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let pre_interrupt_status: serde_json::Value = serde_json::from_str(&pre_interrupt_status_body)
.map_err(|err| format!("pre-interrupt background heal status is not JSON ({err}): {pre_interrupt_status_body}"))?;
let pre_interrupt_replacement = replacement_recovery_status(&cluster).await?;
let coordinator_log = std::fs::read_to_string(format!("{log_dir}/node0.log"))?;
assert!(
coordinator_log
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.any(|event| {
event["event"] == "heal_task_state"
&& event["task_id"] == client_token
&& event["heal_type"] == "cluster"
&& event["state"] == "started"
}),
"node 0 must have started the exact admin task before interruption"
);
let pre_interrupt_operations = &pre_interrupt_status["healOperations"];
assert_eq!(
pre_interrupt_operations["activeBySource"]["admin"].as_u64(),
Some(1),
"interruption must occur while the single admin task is active: {pre_interrupt_status}"
);
if !background_enabled {
assert!(
only_admin_heal_is_active(&pre_interrupt_status),
"isolated interruption must retain only the admin task: {pre_interrupt_status}"
);
assert_eq!(
pre_interrupt_replacement["cluster"]["records"].as_array().map(Vec::len),
Some(0),
"root-heal interruption point must not retain an automatic replacement generation: {pre_interrupt_replacement}"
);
}
info!(
event = "heal_restart_checkpoint",
event = "heal_interruption_checkpoint",
component = "e2e_test",
subsystem = "heal",
partial_count,
"Verified unique admin owner before target interruption"
background_enabled,
interruption_node,
interruption_kind,
partial_metadata_count = partial_count,
verified_key = partial_manifest.key,
"Observed partial rebuild before interruption"
);
cluster.stop_node(1)?;
let stopped_count = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
assert!(
stopped_count > 0 && stopped_count < expected_manifests.len(),
"the target must stop after a partial rebuild, observed before stop={partial_count}, after stop={stopped_count}, total={}",
expected_manifests.len()
);
let unclean_shutdown_marker = replaced_disk.join(".rustfs.sys").join("unclean-shutdown");
match std::fs::remove_file(&unclean_shutdown_marker) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!("failed to isolate unclean recovery marker {unclean_shutdown_marker:?}: {error}").into());
let target_pid = cluster.nodes[1].process.as_ref().ok_or("target process is not running")?.id();
if scenario == InterruptionScenario::TargetEndpointBlackhole {
let node_pids = cluster
.nodes
.iter()
.map(|node| {
node.process
.as_ref()
.ok_or("cluster process is not running")
.map(std::process::Child::id)
})
.collect::<Result<Vec<_>, _>>()?;
timeout(Duration::from_secs(2), TcpStream::connect(&cluster.nodes[1].address)).await??;
let mut blackhole = TcpPortBlackhole::install(&cluster.nodes[1].address)?;
let blocked_connect = timeout(Duration::from_millis(500), TcpStream::connect(&cluster.nodes[1].address)).await;
assert!(
blocked_connect.is_err(),
"target endpoint connection must time out while the OUTPUT DROP rule is active: {blocked_connect:?}"
);
let stable_window_secs = std::env::var("RUSTFS_HEAL_CHAOS_BLACKHOLE_STABLE_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(2)
.clamp(1, 5);
let blackhole_timeout_secs = std::env::var("RUSTFS_HEAL_CHAOS_BLACKHOLE_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(20)
.clamp(stable_window_secs + 1, 60);
let blackhole_deadline = Instant::now() + Duration::from_secs(blackhole_timeout_secs);
let mut stable_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
let mut stable_since = Instant::now();
loop {
for (node_index, (node, expected_pid)) in cluster.nodes.iter_mut().zip(&node_pids).enumerate() {
let process = node
.process
.as_mut()
.ok_or_else(|| format!("node {node_index} process disappeared"))?;
assert_eq!(process.id(), *expected_pid, "node {node_index} PID changed during endpoint blackhole");
assert!(process.try_wait()?.is_none(), "node {node_index} exited during endpoint blackhole");
}
let current_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
if current_count == expected_manifests.len() {
return Err("root heal completed before the endpoint blackhole became observable".into());
}
if current_count != stable_count {
stable_count = current_count;
stable_since = Instant::now();
}
if stable_since.elapsed() >= Duration::from_secs(stable_window_secs) {
break;
}
if Instant::now() >= blackhole_deadline {
return Err(format!(
"target rebuild never remained stable for {stable_window_secs}s during the endpoint blackhole: last_count={stable_count}, total={}",
expected_manifests.len()
)
.into());
}
sleep(Duration::from_millis(100)).await;
}
let blocked_task_body = timeout(
Duration::from_secs(5),
signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key),
)
.await??;
let blocked_task: serde_json::Value = serde_json::from_str(&blocked_task_body)
.map_err(|err| format!("blackholed task status is not JSON ({err}): {blocked_task_body}"))?;
assert_eq!(
blocked_task["summary"].as_str(),
Some("running"),
"the original admin task must remain resumable during the endpoint blackhole: {blocked_task}"
);
assert!(
census_object_version_on_disk(&replaced_disk, bucket, &partial_manifest.key, None)?
.matches_manifest(&partial_manifest.shard_census),
"the witnessed complete shard must survive the endpoint blackhole"
);
blackhole.restore()?;
timeout(Duration::from_secs(2), TcpStream::connect(&cluster.nodes[1].address)).await??;
info!(
event = "heal_endpoint_blackhole_restored",
component = "e2e_test",
subsystem = "heal",
interruption_kind,
stable_metadata_count = stable_count,
stable_window_secs,
"Restored target endpoint forwarding"
);
} else {
cluster.stop_node(interruption_node)?;
let stopped_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
assert!(
stopped_count > 0 && stopped_count < expected_manifests.len(),
"node {interruption_node} must stop during a partial rebuild, observed before stop={partial_count}, after stop={stopped_count}, total={}",
expected_manifests.len()
);
assert!(
census_object_version_on_disk(&replaced_disk, bucket, &partial_manifest.key, None)?
.matches_manifest(&partial_manifest.shard_census),
"the witnessed complete shard must survive interruption"
);
let unclean_shutdown_marker = Path::new(&cluster.nodes[interruption_node].data_dir)
.join(".rustfs.sys")
.join("unclean-shutdown");
if background_enabled {
assert!(
unclean_shutdown_marker.is_file(),
"background restart must retain the real unclean-shutdown marker"
);
} else {
match std::fs::remove_file(&unclean_shutdown_marker) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(
format!("failed to isolate unclean recovery marker {unclean_shutdown_marker:?}: {error}").into()
);
}
}
}
cluster.start_node(interruption_node).await?;
if interruption_node == 0 {
let target = cluster.nodes[1]
.process
.as_mut()
.ok_or("target process disappeared during coordinator restart")?;
assert_eq!(target.id(), target_pid, "coordinator restart must not replace the target process");
assert!(target.try_wait()?.is_none(), "the target must remain alive during coordinator restart");
}
}
cluster.start_node(1).await?;
let scanner_cycle_floor = if background_enabled {
Some(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_secs())
} else {
None
};
let heal_timeout_secs = std::env::var("RUSTFS_HEAL_REPLACED_DISK_TIMEOUT_SECS")
.ok()
@@ -832,13 +1302,22 @@ mod tests {
{
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
if matching == expected_manifests.len() && outage_census.is_complete() {
let pool_metadata_matches = match &expected_pool_metadata {
Some(expected) => {
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?
.matches_manifest(expected)
}
None => true,
};
if matching == expected_manifests.len() && outage_census.is_complete() && pool_metadata_matches {
break;
}
}
if Instant::now() >= heal_deadline {
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
let pool_metadata =
census_object_version_on_disk(&replaced_disk, RUSTFS_META_BUCKET, POOL_METADATA_OBJECT, None)?;
let final_status = signed_admin_post(&status_url, None, &cluster.access_key, &cluster.secret_key)
.await
.unwrap_or_else(|err| format!("status request failed: {err}"));
@@ -858,7 +1337,7 @@ mod tests {
Err(_) => "replacement status request exceeded 5s diagnostic budget".to_string(),
};
return Err(format!(
"root heal did not resume after target restart within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, replacement_status={replacement_status}",
"root heal did not recover after {interruption_kind} within {heal_timeout_secs}s: baseline={matching}/{}, outage={outage_census:?}, pool_metadata={pool_metadata:?}, status={final_status}, task_status={task_status}, pre_interrupt_status={pre_interrupt_status}, pre_heal_replacement={pre_heal_replacement}, pre_interrupt_replacement={pre_interrupt_replacement}, replacement_status={replacement_status}",
expected_manifests.len()
)
.into());
@@ -885,6 +1364,17 @@ mod tests {
"the outage object must be rebuilt into its own missing erasure slot"
);
if let Some(cycle_end) = scanner_cycle_floor {
wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
}
let mut expected_keys = expected_manifests
.iter()
.map(|manifest| manifest.key.clone())
.collect::<HashSet<_>>();
assert!(expected_keys.insert(outage_key.to_string()));
assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
let target_client = cluster.create_s3_client(1)?;
for expected in &expected_manifests {
let response = target_client.get_object().bucket(bucket).key(&expected.key).send().await?;
@@ -914,6 +1404,30 @@ mod tests {
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
if interruption_node == 0 {
// Admin tasks are process-local. Physical and queue convergence
// above establish recovery; a lost task must not report success.
assert_eq!(
task_status["summary"].as_str(),
Some("notFound"),
"interrupted task status: {task_status}"
);
assert_eq!(
task_status["detail"].as_str(),
Some("heal task not found or expired"),
"interrupted admin task must be explicitly unavailable: {task_status}"
);
info!(
event = "heal_interruption_recovered",
component = "e2e_test",
subsystem = "heal",
interruption_node,
interruption_kind,
task_state = "not_found",
"Physical recovery completed after coordinator restart"
);
return Ok(());
}
if task_status["summary"].as_str() != Some("finished") {
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
}
@@ -560,18 +560,12 @@ async fn test_multipart_encryption_type(
.set_parts(Some(completed_parts))
.build();
let mut complete_request = s3_client
let complete_request = s3_client
.complete_multipart_upload()
.bucket(bucket)
.key(object_key)
.upload_id(upload_id)
.multipart_upload(completed_multipart_upload);
if matches!(encryption_type, EncryptionType::SSEC) {
complete_request = complete_request
.sse_customer_algorithm("AES256")
.sse_customer_key(sse_c_key.as_ref().unwrap())
.sse_customer_key_md5(sse_c_md5.as_ref().unwrap());
}
let _complete_output = complete_request.send().await?;
// Download and verify
+28 -1
View File
@@ -23,10 +23,17 @@ pub mod common;
#[cfg(test)]
pub mod chaos;
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8).
// Programmable S3 target for replication failure-path tests (backlog#1147 repl-8)
// and on-demand-migration source scenarios (backlog#2151).
#[cfg(test)]
pub mod fake_s3_target;
// On-demand migration (backlog#2147): shared two-server environment, admin
// wrappers, and the harness self-test (backlog#2151). Behavior scenarios are
// added by later ODM tasks.
#[cfg(test)]
pub mod on_demand_migration;
// Socket-level network fault-injection proxy for black-box cluster tests
// (backlog#1325 network fault-injection block): latency / blackhole / one-way
// partition on the wire between nodes. Serves #1312/#1319 (lock-plane one-way
@@ -73,6 +80,12 @@ mod upgrade_compatibility_test;
#[cfg(test)]
mod replication_lww_receiver_test;
// Outbound target matrix: every object shape against every remote-target
// failure mode the fake target models (SOP:
// docs/postmortems/2026-09-03-replication-checksum-default-regression.md).
#[cfg(test)]
mod replication_target_matrix_test;
// Data usage regression tests
#[cfg(test)]
mod data_usage_test;
@@ -205,6 +218,10 @@ mod cluster_multidrive_pool_test;
#[cfg(test)]
mod inline_fast_path_cluster_test;
// backlog#2207: two-node gate for the cluster-authoritative tier stats contract.
#[cfg(test)]
mod tier_stats_cluster_test;
// PutObject / MultipartUpload with checksum (Content-MD5, x-amz-checksum-*)
#[cfg(test)]
mod checksum_upload_test;
@@ -348,6 +365,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;
@@ -356,6 +378,11 @@ mod bucket_stats_regression_test;
#[cfg(test)]
mod distributed_startup_regression_test;
// 4-node / 4-disk distributed Actions suite (S3, lock, versioning, replication,
// quota, observability, expand/decommission/rebalance, site replication, chaos).
#[cfg(test)]
mod distributed;
// P1 regression: tier/ILM transition (rustfs#5218, #5130, #5011, #4826, #5024)
#[cfg(test)]
mod tier_transition_regression_test;
+64 -2
View File
@@ -225,8 +225,8 @@ fn encode_unsigned_aws_chunked_with_sha256_trailer(decoded: &[u8]) -> Vec<u8> {
let checksum = sha256_base64(decoded);
let mut encoded = format!("{:x}\r\n", decoded.len()).into_bytes();
encoded.extend_from_slice(decoded);
encoded.extend_from_slice(b"\r\n0\r\n\r\n");
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}").as_bytes());
encoded.extend_from_slice(b"\r\n0\r\n");
encoded.extend_from_slice(format!("x-amz-checksum-sha256:{checksum}\r\n\r\n").as_bytes());
encoded
}
@@ -549,6 +549,68 @@ async fn tampered_upload_part_payload_is_rejected() -> Result<(), Box<dyn std::e
Ok(())
}
/// s3s v0.16 validates the aws-chunked decoded length while RustFS consumes the
/// body stream. Mismatches are client body errors and must not leak as 500s.
#[tokio::test]
async fn aws_chunked_decoded_length_mismatch_returns_incomplete_body() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
setup(&mut env).await?;
for (key, declared_len) in [
("decoded-length-overrun.bin", 3_usize),
("decoded-length-shortfall.bin", 9_usize),
] {
let decoded = b"decoded";
assert_ne!(declared_len, decoded.len(), "test case must exercise a mismatch");
let encoded_body = encode_unsigned_aws_chunked_with_sha256_trailer(decoded);
let decoded_content_length = declared_len.to_string();
let path = format!("/{BUCKET}/{key}");
let signer = SigV4::new(&env);
let extra_signed_headers = [
("content-encoding", "aws-chunked"),
("x-amz-decoded-content-length", decoded_content_length.as_str()),
("x-amz-trailer", "x-amz-checksum-sha256"),
];
let headers = signer.sign_with_extra_headers("PUT", &path, "", UNSIGNED_PAYLOAD_TRAILER, &extra_signed_headers);
let response = local_http_client()
.put(format!("{}{}", env.url, path))
.header("authorization", &headers.authorization)
.header("content-encoding", "aws-chunked")
.header("x-amz-content-sha256", &headers.content_sha256)
.header("x-amz-date", &headers.amz_date)
.header("x-amz-decoded-content-length", &decoded_content_length)
.header("x-amz-trailer", "x-amz-checksum-sha256")
.body(encoded_body)
.timeout(std::time::Duration::from_secs(10))
.send()
.await?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"decoded length mismatch must be a client error, body:\n{body}"
);
assert_error_code(&body, "IncompleteBody");
let absent = env
.create_s3_client()
.get_object()
.bucket(BUCKET)
.key(key)
.send()
.await
.expect_err("decoded length mismatch must not publish an object");
assert_eq!(absent.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(absent.as_service_error().and_then(ProvideErrorMetadata::code), Some("NoSuchKey"));
}
env.stop_server();
Ok(())
}
/// (e) A request whose `x-amz-date` is skewed beyond the server's tolerance
/// (s3s default 900s / 15 min) must be rejected with RequestTimeTooSkewed /
/// 403. The signature is otherwise valid: the credential-scope date and
@@ -0,0 +1,254 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! On-demand migration backfill job scenarios (ODM-12, rustfs/backlog#2159):
//! a full backfill of a small-object source, cancellation, and resuming from
//! the persisted continuation token after a server restart.
use super::common::{BackfillOp, BackfillRequest, ODM_SERVER_ENV, OdmSourceSpec, OdmTestEnv, SeedObject};
use crate::fake_s3_target::Operation;
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const SOURCE_BUCKET: &str = "odm-backfill-source";
const LOCAL_BUCKET: &str = "odm-backfill-local";
const KEY_PREFIX: &str = "cold/";
/// Keys per scenario. The fake source retains at most 4,096 object versions
/// and 4,096 journal entries, and one pull is a HEAD plus a GET, so a
/// scenario that asserts on the journal stays below ~2,000 keys. The job
/// lists 1,000 keys per page, so this still spans several pages and exercises
/// the continuation token, which is what the scenarios are about.
const SEEDED_KEYS: usize = 1500;
fn key(i: usize) -> String {
format!("{KEY_PREFIX}{i:05}")
}
/// Content that identifies the key so a mis-stored object is caught.
fn body(i: usize) -> Bytes {
Bytes::from(format!("object-{i:05}-payload"))
}
fn seed(env: &OdmTestEnv, count: usize) {
let objects: Vec<SeedObject> = (0..count).map(|i| SeedObject::new(key(i), body(i))).collect();
let etags = env.seed_source(SOURCE_BUCKET, &objects);
assert_eq!(etags.len(), count);
}
async fn configure(env: &OdmTestEnv, spec: &OdmSourceSpec) -> TestResult {
let response = env.configure_source(LOCAL_BUCKET, spec).await?;
assert_eq!(response.status, 200, "configure: {}", response.body);
// The probe issued a one-key listing; count only the job's traffic.
env.source.take_requests();
Ok(())
}
fn source_lists(env: &OdmTestEnv) -> Vec<Option<String>> {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::ListObjectsV2)
.map(|record| record.continuation_token)
.collect()
}
fn source_gets(env: &OdmTestEnv) -> usize {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::GetObject)
.count()
}
fn counter(job: &serde_json::Value, name: &str) -> u64 {
job[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {job}"))
}
#[tokio::test]
async fn backfill_pulls_every_source_object_across_list_pages() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
configure(&env, &env.fake_source_spec(SOURCE_BUCKET)).await?;
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
let job = started.json()?["job"].clone();
assert_eq!(job["state"], "running");
assert_eq!(job["skip_existing"], "always");
assert_eq!(job["dry_run"], false);
let job_id = job["job_id"].as_str().expect("job id").to_string();
// A second start while the job holds its lease is a conflict.
let again = env
.backfill(LOCAL_BUCKET, BackfillOp::Start(BackfillRequest::default()))
.await?;
assert_eq!(again.status, 409, "second start: {}", again.body);
assert!(again.body.contains("OnDemandMigrationBackfillRunning"), "{}", again.body);
let done = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(240), |job| job["state"] == "completed")
.await?;
assert_eq!(done["job_id"], job_id.as_str());
assert_eq!(counter(&done, "listed"), COUNT as u64);
assert_eq!(counter(&done, "enqueued"), COUNT as u64);
assert_eq!(counter(&done, "pulled"), COUNT as u64);
assert_eq!(counter(&done, "failed"), 0);
assert_eq!(counter(&done, "skipped_existing"), 0);
assert!(done["continuation_token"].is_null(), "a finished job carries no cursor");
assert_eq!(done["last_key"], key(COUNT - 1));
assert!(done["failed_keys"].as_array().is_some_and(Vec::is_empty));
let expected_bytes: u64 = (0..COUNT).map(|i| body(i).len() as u64).sum();
assert_eq!(counter(&done, "bytes"), expected_bytes);
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
for i in [0, 999, 1000, 1200, COUNT - 1] {
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
}
let lists = source_lists(&env);
assert_eq!(lists.len(), COUNT.div_ceil(1000), "{COUNT} keys at 1000 per page: {lists:?}");
assert!(lists[0].is_none(), "the first page starts without a cursor");
assert!(lists[1..].iter().all(Option::is_some), "every later page carries the cursor");
assert_eq!(source_gets(&env), COUNT, "every object is fetched exactly once");
// The status endpoint summarises the same job.
let status = env.status(LOCAL_BUCKET).await?;
assert_eq!(status.status, 200);
let summary = status.json()?["backfill"].clone();
assert_eq!(summary["job_id"], job_id.as_str());
assert_eq!(summary["state"], "completed");
assert_eq!(counter(&summary, "pulled"), COUNT as u64);
Ok(())
}
#[tokio::test]
async fn backfill_cancel_stops_enqueueing_and_persists_cancelled() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.max_concurrent_pulls = 1;
configure(&env, &spec).await?;
// Cancelling before any job exists is a 404, not a silent success.
let nothing = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(nothing.status, 404, "cancel without a job: {}", nothing.body);
assert!(nothing.body.contains("NoSuchBackfillJob"), "{}", nothing.body);
let unread = env.backfill(LOCAL_BUCKET, BackfillOp::Status).await?;
assert_eq!(unread.status, 404, "status without a job: {}", unread.body);
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
env.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(60), |job| {
job["state"] == "running" && counter(job, "enqueued") > 0
})
.await?;
let cancelled = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(cancelled.status, 200, "cancel: {}", cancelled.body);
let job = cancelled.json()?["job"].clone();
assert_eq!(job["state"], "cancelled");
let enqueued_at_cancel = counter(&job, "enqueued");
assert!(enqueued_at_cancel < COUNT as u64, "the job was cancelled mid-way: {job}");
// Nothing is queued after the cancel: the checkpoint and the source
// traffic both stop moving once the few in-flight pulls drain.
tokio::time::sleep(Duration::from_secs(2)).await;
let persisted = env.backfill_job(LOCAL_BUCKET).await?.expect("checkpoint kept for inspection");
assert_eq!(persisted["state"], "cancelled");
assert_eq!(counter(&persisted, "enqueued"), enqueued_at_cancel);
let gets_after_drain = source_gets(&env);
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(source_gets(&env), gets_after_drain, "no source GET after the cancel drained");
assert!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await? < COUNT);
// Cancel is idempotent and the status endpoint reports the final state.
let again = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
assert_eq!(again.status, 200, "second cancel: {}", again.body);
assert_eq!(again.json()?["job"]["state"], "cancelled");
let status = env.status(LOCAL_BUCKET).await?;
assert_eq!(status.json()?["backfill"]["state"], "cancelled");
// A cancelled job releases the bucket: a new job can start.
let restarted = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(restarted.status, 200, "restart after cancel: {}", restarted.body);
assert_ne!(restarted.json()?["job"]["job_id"], job["job_id"]);
let _ = env.backfill(LOCAL_BUCKET, BackfillOp::Cancel).await?;
Ok(())
}
#[tokio::test]
async fn backfill_resumes_from_continuation_token_after_restart() -> TestResult {
const COUNT: usize = SEEDED_KEYS;
let mut env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
env.rustfs.create_test_bucket(LOCAL_BUCKET).await?;
seed(&env, COUNT);
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.max_concurrent_pulls = 2;
configure(&env, &spec).await?;
let started = env.start_backfill(LOCAL_BUCKET, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start: {}", started.body);
let job_id = started.json()?["job"]["job_id"].as_str().expect("job id").to_string();
// Wait for the first page to be committed (cursor persisted), then kill
// the server while the job is still running.
let mid = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(120), |job| {
job["state"] == "running" && job["continuation_token"].is_string()
})
.await?;
assert!(counter(&mid, "listed") >= 1000 && counter(&mid, "listed") < COUNT as u64, "{mid}");
env.source.take_requests();
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
let done = env
.wait_for_backfill(LOCAL_BUCKET, Duration::from_secs(300), |job| job["state"] == "completed")
.await?;
assert_eq!(done["job_id"], job_id.as_str(), "the same job continues after the restart");
assert_eq!(counter(&done, "failed"), 0);
assert!(
counter(&done, "listed") >= COUNT as u64,
"the resumed job listed the rest (the interrupted page is listed twice): {done}"
);
// Keys pulled before the crash are re-listed and skipped, never re-pulled;
// a pull whose report died with the old process is counted neither way,
// so only the lower bound and the queue accounting are exact.
assert!(counter(&done, "pulled") + counter(&done, "skipped_existing") >= COUNT as u64, "{done}");
assert!(counter(&done, "pulled") <= counter(&done, "enqueued"), "{done}");
assert_eq!(env.local_key_count(LOCAL_BUCKET, KEY_PREFIX).await?, COUNT);
for i in [0, 500, 999, 1000, COUNT - 1] {
env.assert_local_present(LOCAL_BUCKET, &key(i), &body(i)).await;
}
let lists = source_lists(&env);
assert!(!lists.is_empty(), "the resumed job listed the source");
assert!(
lists.iter().all(Option::is_some),
"after the restart every source listing carries a continuation-token: {lists:?}"
);
assert!(
lists.len() <= COUNT.div_ceil(1000),
"the listing did not start over from the first page: {lists:?}"
);
Ok(())
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,214 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Concurrency limits of on-demand migration (rustfs/backlog#2158):
//! single-flight on one key, the `max_concurrent_pulls` ceiling, and a full
//! background pull queue.
//!
//! The point of each case is what the source is spared, so the source
//! journal (`count_requests`) carries the assertion in every one of them.
use super::common::{BoxError, OdmTestEnv, RawResponse, SeedObject, start_configured_env};
use crate::fake_s3_target::{FaultAction, Operation};
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
use futures::{StreamExt, TryStreamExt};
use std::time::Duration;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-concurrency-source";
/// Background pulls land after the response that queued them.
const SETTLE: Duration = Duration::from_secs(120);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
fn source_get_count(env: &OdmTestEnv, key: &str) -> usize {
env.source.count_requests(Operation::GetObject, key)
}
/// Case 9: 32 concurrent misses on one key coalesce into a single-flight
/// pull. At most two source GETs are allowed: the leader plus one follower
/// that gave up waiting and streamed through.
#[tokio::test]
async fn test_odm_concurrent_misses_on_one_key_coalesce() -> TestResult {
let bucket = "odm-concurrency-singleflight";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let key = "singleflight/asset.bin";
let body = payload(512 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let responses: Vec<RawResponse> = futures::future::try_join_all((0..32).map(|_| env.raw_get(bucket, key))).await?;
for (index, response) in responses.iter().enumerate() {
assert_eq!(response.status, 200, "reader {index}: {}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body, "reader {index} received different bytes");
}
let source_gets = source_get_count(&env, key);
assert!(
(1..=2).contains(&source_gets),
"32 concurrent misses must not become {source_gets} source GETs"
);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the leader stores the object");
env.assert_local_present(bucket, key, &body).await;
let versions = env.client.list_object_versions().bucket(bucket).prefix(key).send().await?;
assert_eq!(
versions.versions().len(),
1,
"the coalesced pull commits exactly one version: {:?}",
versions.versions()
);
assert_eq!(
source_get_count(&env, key),
source_gets,
"nothing pulls the object again once it is local"
);
Ok(())
}
/// Case 10: 64 misses on distinct keys never exceed `max_concurrent_pulls`
/// in flight, and all of them eventually land.
#[tokio::test]
async fn test_odm_concurrent_pulls_respect_the_configured_ceiling() -> TestResult {
let bucket = "odm-concurrency-ceiling";
const MAX_CONCURRENT_PULLS: u32 = 4;
const KEYS: usize = 64;
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.max_concurrent_pulls = MAX_CONCURRENT_PULLS;
})
.await?;
let body = payload(256 * 1024);
let keys: Vec<String> = (0..KEYS).map(|index| format!("ceiling/object-{index:03}.bin")).collect();
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
env.seed_source(SOURCE_BUCKET, &seeds);
let reads = futures::future::try_join_all(keys.iter().map(|key| env.raw_get(bucket, key)));
let (responses, peak_inflight) = env.peak_inflight_pulls(bucket, reads).await?;
let responses = responses?;
for (key, response) in keys.iter().zip(&responses) {
assert_eq!(response.status, 200, "{key}: {}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body, "{key} received different bytes");
}
assert!(
peak_inflight <= u64::from(MAX_CONCURRENT_PULLS),
"in-flight pulls peaked at {peak_inflight}, above the configured {MAX_CONCURRENT_PULLS}"
);
assert!(
peak_inflight >= 1,
"the poll never observed a pull in flight, so the ceiling assertion proves nothing"
);
for key in &keys {
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{key} must be stored locally");
assert_eq!(source_get_count(&env, key), 1, "{key} is pulled exactly once");
}
assert_eq!(env.status_counter(bucket, "/inflight_pulls").await?, 0, "every pull slot is released");
Ok(())
}
/// Case 11: with a small background queue, a burst of Range reads overflows
/// it. The overflow is counted and dropped, never turned into a client
/// failure: every reader still gets its 206 from the source.
#[tokio::test]
async fn test_odm_range_burst_overflows_the_pull_queue_without_failing_clients() -> TestResult {
let bucket = "odm-concurrency-queue-full";
const REQUESTS: usize = 100;
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.pull_queue_capacity = 8;
spec.policy.max_concurrent_pulls = 1;
})
.await?;
let body = payload(128 * 1024);
let blocker = "queue/blocker.bin";
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(blocker, body.clone())]);
// The one-chunk range completes immediately; its full background pull
// occupies the only slot while the remaining requests fill the queue.
env.source.inject_for_key(
Operation::GetObject,
blocker,
FaultAction::SlowSendBody {
chunk_bytes: 1024,
delay: Duration::from_millis(100),
},
2,
);
let response = env
.raw_object_request(http::Method::GET, bucket, blocker, &[("range", "bytes=0-1023")])
.await?;
assert_eq!(response.status, 206);
assert_eq!(response.body, body.slice(0..1024));
env.wait_for_status_counter(bucket, "/inflight_pulls", 1, SETTLE).await?;
let keys: Vec<String> = (0..REQUESTS).map(|index| format!("queue/object-{index:03}.bin")).collect();
let seeds: Vec<SeedObject> = keys.iter().map(|key| SeedObject::new(key.clone(), body.clone())).collect();
env.seed_source(SOURCE_BUCKET, &seeds);
// Bound source connections below the fixture's limit while still
// submitting all 100 requests to the eight-slot background queue.
let responses: Vec<RawResponse> = futures::stream::iter(
keys.iter()
.map(|key| env.raw_object_request(http::Method::GET, bucket, key, &[("range", "bytes=0-1023")])),
)
.buffered(16)
.try_collect()
.await?;
for (key, response) in keys.iter().zip(&responses) {
assert_eq!(response.status, 206, "{key}: {}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body.slice(0..1024), "{key} served the wrong range");
assert_eq!(
response.header("content-range"),
Some(format!("bytes 0-1023/{}", body.len()).as_str()),
"{key}"
);
}
let queue_full = env
.wait_for_status_counter(bucket, "/counters/pull_failures_total/queue_full", 1, SETTLE)
.await?;
assert!(queue_full > 0, "a 100-deep burst must overflow an 8-slot queue");
let queue_full = usize::try_from(queue_full)?;
assert!(queue_full <= REQUESTS);
env.wait_for_status_counter(
bucket,
"/counters/pulled_objects_total/background",
u64::try_from(REQUESTS + 1 - queue_full)?,
SETTLE,
)
.await?;
let ranged_reads: usize = keys.iter().map(|key| source_get_count(&env, key)).sum();
assert!(
ranged_reads >= REQUESTS,
"every reader is served from the source: {ranged_reads} GETs for {REQUESTS} readers"
);
let dropped = keys.iter().filter(|key| source_get_count(&env, key) == 1).count();
assert_eq!(dropped, queue_full, "only overflowed keys remain without a background GET");
Ok(())
}
@@ -0,0 +1,551 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Source-failure scenarios for on-demand migration (rustfs/backlog#2158):
//! access denied, the circuit breaker, first-byte and mid-body stream
//! failures (a cut body and a stalled one), ETag integrity, the negative
//! cache, and an unsupported (SSE-C) source object.
//!
//! Every case asserts what the source was asked for, not only what the
//! client received: a fault that silently turned into a second source
//! request would otherwise pass.
use super::common::{BoxError, OdmTestEnv, SeedObject, start_configured_env};
use crate::fake_s3_target::{FaultAction, Operation};
use bytes::Bytes;
use std::time::{Duration, Instant};
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-fault-source";
/// Header the GET/HEAD paths add when the answer came from the source.
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Status of the `SourceUnavailable` error the `propagate` policy returns.
const SOURCE_UNAVAILABLE_STATUS: u16 = 424;
/// Background pulls and their counters land after the response.
const SETTLE: Duration = Duration::from_secs(60);
/// Consecutive counted source failures that open the breaker
/// (`BREAKER_FAILURE_THRESHOLD` in ecstore).
const BREAKER_FAILURE_THRESHOLD: usize = 5;
/// Position-dependent payload so a misaligned or truncated copy is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// A source object with a well-formed but deliberately wrong single-part
/// ETag: the fake source retains `x-rustfs-source-etag` verbatim, so HEAD
/// and GET advertise an MD5 the body does not have.
async fn seed_with_etag(env: &OdmTestEnv, key: &str, body: Bytes, etag: &str) -> TestResult {
let response = env
.source_client()
.put_object()
.bucket(SOURCE_BUCKET)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.customize()
.mutate_request({
let etag = etag.to_string();
move |request| {
request.headers_mut().insert("x-rustfs-source-etag", etag.clone());
}
})
.send()
.await?;
assert_eq!(
response.e_tag(),
Some(format!("\"{etag}\"").as_str()),
"the fake source stores the announced ETag"
);
Ok(())
}
/// A source object that reports SSE-C: the fake source echoes the customer
/// algorithm it captured from the replication passthrough transport header.
async fn seed_with_ssec(env: &OdmTestEnv, key: &str, body: Bytes) -> TestResult {
env.source_client()
.put_object()
.bucket(SOURCE_BUCKET)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.customize()
.mutate_request(|request| {
request.headers_mut().insert("x-rustfs-replication-ssec-algorithm", "AES256");
})
.send()
.await?;
Ok(())
}
/// Case 1: a 403 from the source is a configuration error, not a health
/// signal. `propagate` answers 424 and records the class; `not_found` hides
/// it as a 404. Neither counts toward the breaker.
#[tokio::test]
async fn test_odm_source_access_denied_propagates_without_opening_the_breaker() -> TestResult {
let propagating = "odm-fault-denied-propagate";
let hiding = "odm-fault-denied-notfound";
let env = start_configured_env(propagating, SOURCE_BUCKET, |_| {}).await?;
let mut hiding_spec = env.fake_source_spec(SOURCE_BUCKET);
hiding_spec.policy.source_error = "not_found".to_string();
env.configure_and_wait(hiding, &hiding_spec).await?;
let propagate_key = "denied/propagate.bin";
let hidden_key = "denied/hidden.bin";
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(propagate_key, payload(4096)),
SeedObject::new(hidden_key, payload(4096)),
],
);
env.source
.inject_for_key(Operation::HeadObject, propagate_key, FaultAction::ResponseStatus(403), 1);
let denied = env.raw_get(propagating, propagate_key).await?;
assert_eq!(denied.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&denied.body));
assert!(
String::from_utf8_lossy(&denied.body).contains("SourceUnavailable"),
"the propagated error names the ODM source code: {}",
String::from_utf8_lossy(&denied.body)
);
assert_eq!(env.source.count_requests(Operation::HeadObject, propagate_key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, propagate_key),
0,
"a denied HEAD never reaches the body"
);
let status = env.status_json(propagating).await?;
assert_eq!(
status.pointer("/last_source_error/class").and_then(|v| v.as_str()),
Some("access_denied"),
"{status}"
);
assert_eq!(
status.pointer("/breaker/state").and_then(|v| v.as_str()),
Some("closed"),
"a configuration error must not open the breaker: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/source_error")
.and_then(|v| v.as_u64()),
Some(1),
"{status}"
);
env.source
.inject_for_key(Operation::HeadObject, hidden_key, FaultAction::ResponseStatus(403), 1);
let hidden = env.raw_get(hiding, hidden_key).await?;
assert_eq!(hidden.status, 404, "{}", String::from_utf8_lossy(&hidden.body));
assert_eq!(env.source.count_requests(Operation::HeadObject, hidden_key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, hidden_key), 0);
env.assert_local_absent(propagating, propagate_key).await;
env.assert_local_absent(hiding, hidden_key).await;
Ok(())
}
/// Case 2: repeated transport failures open the breaker; while it is open
/// the source is not touched at all, and the half-open probe after the open
/// window closes it again. The open window is a compiled-in 30 s constant
/// (`BREAKER_OPEN_DURATION`), so this case waits in real time.
///
/// The source client disables SDK retries, so one logical source call is
/// exactly one wire request: the script is exactly as deep as the number of
/// breaker failures it has to produce, and the scripted fault count and the
/// observed source request count must agree.
#[tokio::test]
async fn test_odm_repeated_source_errors_open_the_breaker_and_recover() -> TestResult {
let bucket = "odm-fault-breaker";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "breaker/doc.bin";
let body = payload(8192);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
env.source
.inject_for_key(Operation::HeadObject, key, FaultAction::ResponseStatus(503), BREAKER_FAILURE_THRESHOLD);
for attempt in 1..=BREAKER_FAILURE_THRESHOLD {
let response = env.raw_get(bucket, key).await?;
assert_eq!(
response.status,
SOURCE_UNAVAILABLE_STATUS,
"attempt {attempt}: {}",
String::from_utf8_lossy(&response.body)
);
}
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/breaker/state")
.and_then(|v| v.as_str()),
Some("open"),
"the threshold of consecutive source failures must open the breaker"
);
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
BREAKER_FAILURE_THRESHOLD,
"every counted failure is exactly one source request"
);
// With the script cleared, the only thing that can still fail a read is
// the open breaker itself.
env.source.clear_faults();
let source_requests = env.source.count_requests(Operation::HeadObject, key);
let rejected = env.raw_get(bucket, key).await?;
assert_eq!(rejected.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&rejected.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
source_requests,
"an open breaker never touches the source"
);
assert!(
env.status_counter(bucket, "/counters/requests_total/get/breaker_open")
.await?
>= 1,
"the rejected request is counted as breaker_open"
);
// Half-open admits exactly one probe once the open window elapses.
let deadline = Instant::now() + Duration::from_secs(120);
let recovered = loop {
let response = env.raw_get(bucket, key).await?;
if response.status == 200 {
break response;
}
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS);
assert!(Instant::now() < deadline, "the breaker never left the open state");
tokio::time::sleep(Duration::from_secs(1)).await;
};
assert_eq!(recovered.body, body, "the recovered read serves the source bytes");
assert_eq!(recovered.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
source_requests + 1,
"only the half-open probe reached the source"
);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/breaker/state")
.and_then(|v| v.as_str()),
Some("closed"),
"a successful probe closes the breaker"
);
Ok(())
}
/// Case 3: a source that holds the response past `first_byte_ms` is a
/// timeout, and the client never sees a 200 head. One logical source call is
/// one wire request, so a single scripted stall is enough to fail the read.
#[tokio::test]
async fn test_odm_source_stall_times_out_before_the_first_byte() -> TestResult {
let bucket = "odm-fault-stall";
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.source_timeout.first_byte_ms = 500;
})
.await?;
let key = "stall/doc.bin";
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(4096))]);
env.source
.inject_for_key(Operation::HeadObject, key, FaultAction::Stall(Duration::from_secs(5)), 1);
let started = Instant::now();
let response = env.raw_get(bucket, key).await?;
let elapsed = started.elapsed();
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
1,
"the stalled HEAD is the only source request"
);
assert!(
elapsed < Duration::from_secs(5),
"the read timeout must cut the attempt short, took {elapsed:?}"
);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
0,
"a timed-out HEAD never starts a body read"
);
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/last_source_error/class")
.and_then(|v| v.as_str()),
Some("timeout"),
);
env.assert_local_absent(bucket, key).await;
Ok(())
}
/// Case 4: the source cuts the body of an inline pull. The client sees a
/// short read, nothing is stored, and no multipart upload is left behind.
#[tokio::test]
async fn test_odm_inline_pull_aborts_when_the_source_body_is_cut() -> TestResult {
let bucket = "odm-fault-inline-cut";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "cut/inline.bin";
let body = payload(256 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
env.source
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(1024), 1);
// The client sees a transport failure while reading the body: the
// announced Content-Length is never delivered.
env.raw_get(bucket, key)
.await
.expect_err("a cut source body must not read back as a complete object");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"an aborted inline pull is not retried on the same request"
);
// Give a stray background pull time to appear before asserting absence.
tokio::time::sleep(Duration::from_secs(3)).await;
env.assert_local_absent(bucket, key).await;
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
assert!(
uploads.uploads().is_empty(),
"an aborted pull leaves no multipart upload: {:?}",
uploads.uploads()
);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"nothing re-reads the source afterwards"
);
Ok(())
}
/// Case 5: the source answers, sends part of the body and then goes quiet
/// for longer than `source_timeout.idle_ms`. The inline tee must end both
/// ends: the client gets a short read rather than a silently truncated 200,
/// the pull is counted as a source timeout, and nothing (object or multipart
/// upload) is left behind locally.
#[tokio::test]
async fn test_odm_inline_pull_aborts_when_the_source_body_stalls() -> TestResult {
let bucket = "odm-fault-inline-stall";
const IDLE_MS: u64 = 1_000;
let idle = Duration::from_millis(IDLE_MS);
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.source_timeout.idle_ms = IDLE_MS;
})
.await?;
let key = "stall/inline.bin";
let body = payload(256 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
// The head and the first slice arrive at once; the source then pauses for
// four times the idle budget, which is what a stalled source looks like.
env.source.inject_for_key(
Operation::GetObject,
key,
FaultAction::SlowSendBody {
chunk_bytes: 32 * 1024,
delay: idle * 4,
},
1,
);
let started = Instant::now();
env.raw_get(bucket, key)
.await
.expect_err("a stalled source body must not read back as a complete object");
let elapsed = started.elapsed();
assert!(
elapsed < idle * 4,
"the idle budget, not the source's own pause, must end the read (took {elapsed:?})"
);
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"an aborted inline pull is not retried on the same request"
);
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/source_timeout", 1, SETTLE)
.await?;
// The leader releases its slot just after it records the failure.
let deadline = Instant::now() + SETTLE;
loop {
let inflight = env
.status_json(bucket)
.await?
.pointer("/inflight_pulls")
.and_then(|value| value.as_u64());
if inflight == Some(0) {
break;
}
assert!(Instant::now() < deadline, "the aborted pull never released its slot: {inflight:?}");
tokio::time::sleep(Duration::from_millis(200)).await;
}
env.assert_local_absent(bucket, key).await;
let uploads = env.client.list_multipart_uploads().bucket(bucket).send().await?;
assert!(
uploads.uploads().is_empty(),
"a stalled pull leaves no multipart upload: {:?}",
uploads.uploads()
);
Ok(())
}
/// Case 6: the background pull of a large object hits a cut body, counts the
/// failure, and the retry stores the object.
#[tokio::test]
async fn test_odm_background_pull_retries_a_truncated_source_body() -> TestResult {
let bucket = "odm-fault-background-cut";
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| spec.policy.inline_max_bytes = 4096).await?;
let key = "cut/background.bin";
let body = payload(512 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
// The faults are consumed in order by the two GETs the large-object path
// makes: the passthrough that answers the client (unaffected), then the
// background pull (cut).
env.source
.inject_for_key(Operation::GetObject, key, FaultAction::Delay(Duration::ZERO), 1);
env.source
.inject_for_key(Operation::GetObject, key, FaultAction::TruncateBodyAt(2048), 1);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body, "the passthrough is unaffected by the pull's fault");
// The cut body ends the pull attempt as a retryable source transport
// failure; the retry stores the object, so the pull as a whole succeeds
// and no failure is counted (only a pull that gives up is).
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the retry must store the object");
env.assert_local_present(bucket, key, &body).await;
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
3,
"one passthrough, one cut pull, one successful retry"
);
let status = env.status_json(bucket).await?;
assert_eq!(
status
.pointer("/counters/pulled_objects_total/background")
.and_then(|v| v.as_u64()),
Some(1),
"{status}"
);
assert_eq!(
status
.pointer("/counters/pull_failures_total")
.and_then(|failures| failures.as_object())
.map(|failures| failures.values().filter_map(serde_json::Value::as_u64).sum::<u64>()),
Some(0),
"a retried attempt is not a failed pull: {status}"
);
Ok(())
}
/// Case 7: the source advertises an ETag its bytes do not match. The client
/// still gets every byte; the write-back is discarded as an integrity
/// failure and nothing is stored.
#[tokio::test]
async fn test_odm_wrong_source_etag_discards_the_write_back() -> TestResult {
let bucket = "odm-fault-etag";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "etag/mismatch.bin";
let body = payload(64 * 1024);
seed_with_etag(&env, key, body.clone(), "0123456789abcdef0123456789abcdef").await?;
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body, "the client receives the complete source bytes");
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/etag_mismatch", 1, SETTLE)
.await?;
env.assert_local_absent(bucket, key).await;
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"a discarded write-back is not re-read"
);
Ok(())
}
/// Case 8: a source miss is remembered for `negative_cache_ttl_secs`, and
/// re-checked once the entry expires.
#[tokio::test]
async fn test_odm_source_not_found_is_negative_cached_for_the_ttl() -> TestResult {
let bucket = "odm-fault-negative-cache";
let ttl = Duration::from_secs(3);
let env = start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.negative_cache_ttl_secs = ttl.as_secs();
})
.await?;
let key = "negative/nowhere.bin";
for attempt in 1..=10 {
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 404, "attempt {attempt}: {}", String::from_utf8_lossy(&response.body));
}
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
1,
"nine of the ten misses stop at the negative cache"
);
assert!(
env.status_counter(bucket, "/counters/requests_total/get/negative_cached")
.await?
>= 9,
"the cached misses are counted"
);
tokio::time::sleep(ttl + Duration::from_secs(2)).await;
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 404);
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
2,
"an expired entry re-checks the source once"
);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
Ok(())
}
/// Case 9: an SSE-C source object cannot be migrated (the key belongs to the
/// source's client), so the read fails as unsupported without a body read.
#[tokio::test]
async fn test_odm_ssec_source_object_is_unsupported() -> TestResult {
let bucket = "odm-fault-ssec";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let key = "ssec/secret.bin";
seed_with_ssec(&env, key, payload(4096)).await?;
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, SOURCE_UNAVAILABLE_STATUS, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
0,
"an unsupported object is rejected on the HEAD"
);
assert_eq!(
env.status_json(bucket)
.await?
.pointer("/counters/requests_total/get/unsupported")
.and_then(|v| v.as_u64()),
Some(1),
);
env.assert_local_absent(bucket, key).await;
Ok(())
}
@@ -0,0 +1,298 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Basic read-through scenarios (rustfs/backlog#2156): inline pull and
//! local persistence, large-object passthrough with background backfill,
//! Range passthrough, source 404, `versionId` reads, a disabled bucket, and
//! the HEAD passthrough that stores nothing (rustfs/backlog#2155).
//! Every source-side expectation is asserted on the fake source's journal.
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject};
use crate::fake_s3_target::{BucketMode, Operation};
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-get-source";
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Background pulls run after the response; generous for a loaded CI host.
const BACKFILL_WAIT: Duration = Duration::from_secs(60);
/// Position-dependent payload so a misaligned or truncated copy is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// RustFS with `local_bucket` migrating from `SOURCE_BUCKET` on the fake
/// source (unversioned, like a plain migration source); `adjust` tweaks the
/// policy before it is installed. Returns once the runtime consults the
/// source.
async fn configured_env(local_bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
env.rustfs.create_test_bucket(local_bucket).await?;
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
adjust(&mut spec);
let response = env.configure_source(local_bucket, &spec).await?;
assert_eq!(response.status, 200, "configure on-demand migration: {}", response.body);
env.wait_until_source_consulted(local_bucket).await?;
Ok(env)
}
fn source_get_ranges(env: &OdmTestEnv, key: &str) -> Vec<Option<String>> {
env.source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::GetObject && record.key.as_deref() == Some(key))
.map(|record| record.range)
.collect()
}
#[tokio::test]
async fn get_miss_pulls_inline_and_serves_locally_afterwards() -> TestResult {
let bucket = "odm-get-inline";
let env = configured_env(bucket, |_| {}).await?;
let key = "inline/report.bin";
let body = payload(200 * 1024);
let etag = env
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
.remove(0);
let quoted_etag = format!("\"{etag}\"");
let first = env.raw_get(bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
assert_eq!(first.header("etag"), Some(quoted_etag.as_str()), "inline answers carry the source ETag");
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(first.header("accept-ranges"), Some("bytes"));
assert_eq!(first.body, body, "the client receives the source bytes");
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "exactly one source GET");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert!(
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
"the inline pull must store the object locally"
);
let second = env.raw_get(bucket, key).await?;
assert_eq!(second.status, 200);
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
assert_eq!(second.body, body, "the local copy is the source bytes");
assert_eq!(second.header("etag"), Some(quoted_etag.as_str()), "preserve_etag keeps the source ETag");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the second GET is served locally"
);
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
Ok(())
}
#[tokio::test]
async fn get_large_object_streams_through_and_backfills_in_background() -> TestResult {
let bucket = "odm-get-large";
let env = configured_env(bucket, |spec| spec.policy.inline_max_bytes = 4096).await?;
let key = "large/archive.bin";
let body = payload(512 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(response.body, body, "the passthrough streams the whole object");
assert!(
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
"the background pull must store the object locally"
);
env.assert_local_present(bucket, key, &body).await;
assert_eq!(
source_get_ranges(&env, key),
vec![None, None],
"one passthrough GET plus one background pull, both unranged"
);
Ok(())
}
#[tokio::test]
async fn get_range_streams_206_and_backfills_the_whole_object() -> TestResult {
let bucket = "odm-get-range";
let env = configured_env(bucket, |_| {}).await?;
let key = "range/video.bin";
let body = payload(100_000);
let etag = env
.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())])
.remove(0);
let response = env
.client
.get_object()
.bucket(bucket)
.key(key)
.range("bytes=10-19")
.send()
.await?;
assert_eq!(response.content_range(), Some("bytes 10-19/100000"), "the source's 206 is passed through");
assert_eq!(response.content_length(), Some(10));
assert_eq!(response.e_tag(), Some(format!("\"{etag}\"").as_str()));
assert_eq!(response.body.collect().await?.into_bytes(), body.slice(10..20));
assert_eq!(
source_get_ranges(&env, key),
vec![Some("bytes=10-19".to_string())],
"the Range is forwarded"
);
assert!(
env.wait_local_listed(bucket, key, BACKFILL_WAIT).await?,
"serve_and_backfill must pull the whole object"
);
env.assert_local_present(bucket, key, &body).await;
assert_eq!(
source_get_ranges(&env, key),
vec![Some("bytes=10-19".to_string()), None],
"the background pull fetches the whole object"
);
Ok(())
}
#[tokio::test]
async fn get_source_not_found_is_404_and_negative_cached() -> TestResult {
let bucket = "odm-get-missing";
let env = configured_env(bucket, |_| {}).await?;
let key = "missing/nowhere.bin";
for attempt in 1..=2 {
let err = env
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("a key missing on both sides is 404");
assert_eq!(err.code(), Some("NoSuchKey"), "attempt {attempt}: {err:?}");
}
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a source miss never pulls");
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
1,
"the second miss stops at the negative cache"
);
env.assert_local_absent(bucket, key).await;
Ok(())
}
#[tokio::test]
async fn get_with_version_id_does_not_consult_the_source() -> TestResult {
let bucket = "odm-get-versioned";
let env = configured_env(bucket, |_| {}).await?;
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let key = "versioned/doc.bin";
let body = payload(1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let err = env
.client
.get_object()
.bucket(bucket)
.key(key)
.version_id("11111111-2222-4333-8444-555555555555")
.send()
.await
.expect_err("a version read cannot be answered by the source");
assert!(
matches!(err.code(), Some("NoSuchVersion") | Some("NoSuchKey")),
"unexpected error: {err:?}"
);
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
env.assert_local_absent(bucket, key).await;
// The same key without versionId is still migrated: the gate is per request.
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
Ok(())
}
#[tokio::test]
async fn get_after_disable_does_not_consult_the_source() -> TestResult {
let bucket = "odm-get-disabled";
let env = configured_env(bucket, |_| {}).await?;
let key = "disabled/doc.bin";
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, payload(1024))]);
let response = env.disable(bucket).await?;
assert_eq!(response.status, 204, "{}", response.body);
let err = env
.client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("a disabled bucket answers locally");
assert_eq!(err.code(), Some("NoSuchKey"), "{err:?}");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 0);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
env.assert_local_absent(bucket, key).await;
Ok(())
}
/// A HEAD miss is answered from the source but must not store anything: the
/// key stays absent locally, so a second HEAD consults the source again. This
/// is the smoke-lane guard for the HEAD passthrough (rustfs/backlog#2155).
#[tokio::test]
async fn head_miss_answers_from_the_source_without_persisting() -> TestResult {
let bucket = "odm-head-passthrough";
let env = configured_env(bucket, |_| {}).await?;
let key = "head/report.bin";
let body = payload(32 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let head = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
assert_eq!(head.status, 200, "{}", String::from_utf8_lossy(&head.body));
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
assert!(head.body.is_empty(), "a HEAD answer carries no body");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0, "a HEAD must never pull the body");
env.assert_local_absent(bucket, key).await;
let again = env.raw_object_request(http::Method::HEAD, bucket, key, &[]).await?;
assert_eq!(again.status, 200, "{}", String::from_utf8_lossy(&again.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
2,
"nothing was written back, so the second HEAD consults the source again"
);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 0);
env.assert_local_absent(bucket, key).await;
Ok(())
}
@@ -0,0 +1,606 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Self-test of the ODM harness (rustfs/backlog#2151): the fake source's
//! migration-facing surface (ListObjectsV2 paging, `Range`, unversioned
//! buckets, metadata replay, fault actions) and the two-server environment.
//! No ODM behavior is exercised here.
use super::common::{OdmTestEnv, SeedObject, fake_source_client, start_source_rustfs};
use crate::fake_s3_target::{BucketMode, FakeS3Target, FakeS3TargetOptions, FaultAction, Operation, SeedMetadata};
use aws_sdk_s3::Client;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::primitives::{ByteStream, DateTime};
use bytes::Bytes;
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
const SOURCE_BUCKET: &str = "odm-source";
/// Position-dependent payload so a misaligned range read is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
async fn fake_source() -> Result<(FakeS3Target, Client), Box<dyn std::error::Error + Send + Sync>> {
let source = FakeS3Target::start().await?;
source.create_bucket(SOURCE_BUCKET);
let client = fake_source_client(&source);
Ok((source, client))
}
/// Full ListObjectsV2 traversal. Returns `(keys, common prefixes, pages)` and
/// checks the page shape on the way: every page except the last is full and
/// truncated, the last carries no continuation token.
async fn list_all(
client: &Client,
prefix: Option<&str>,
delimiter: Option<&str>,
start_after: Option<&str>,
max_keys: i32,
) -> Result<(Vec<String>, Vec<String>, usize), Box<dyn std::error::Error + Send + Sync>> {
let mut keys = Vec::new();
let mut prefixes = Vec::new();
let mut pages = 0usize;
let mut token: Option<String> = None;
loop {
let page = client
.list_objects_v2()
.bucket(SOURCE_BUCKET)
.set_prefix(prefix.map(str::to_string))
.set_delimiter(delimiter.map(str::to_string))
.set_start_after(start_after.map(str::to_string))
.max_keys(max_keys)
.set_continuation_token(token.clone())
.send()
.await?;
pages += 1;
let page_keys: Vec<String> = page
.contents()
.iter()
.filter_map(|object| object.key().map(str::to_string))
.collect();
let page_prefixes: Vec<String> = page
.common_prefixes()
.iter()
.filter_map(|common| common.prefix().map(str::to_string))
.collect();
let entries = page_keys.len() + page_prefixes.len();
assert_eq!(page.key_count(), Some(entries as i32), "KeyCount must count keys and prefixes");
assert_eq!(page.continuation_token(), token.as_deref(), "the request token must be echoed");
keys.extend(page_keys);
prefixes.extend(page_prefixes);
if page.is_truncated() == Some(true) {
assert_eq!(entries as i32, max_keys, "every truncated page must be full");
token = Some(
page.next_continuation_token()
.expect("truncated page must carry a continuation token")
.to_string(),
);
} else {
assert!(page.next_continuation_token().is_none(), "final page must not carry a token");
return Ok((keys, prefixes, pages));
}
}
}
#[tokio::test]
async fn fake_source_list_objects_v2_paginates_with_delimiter() -> TestResult {
let (source, client) = fake_source().await?;
let mut expected_keys = BTreeSet::new();
for directory in 0..30 {
for file in 0..30 {
expected_keys.insert(format!("d{directory:02}/k{file:03}"));
}
}
for index in 0..100 {
expected_keys.insert(format!("top-{index:03}"));
}
assert_eq!(expected_keys.len(), 1000);
for key in &expected_keys {
source.put_seed_object(SOURCE_BUCKET, key.clone(), Bytes::from(key.clone()), &SeedMetadata::new());
}
// A key whose current version is a delete marker must stay hidden.
client
.put_object()
.bucket(SOURCE_BUCKET)
.key("hidden/marker")
.body(ByteStream::from_static(b"gone"))
.send()
.await?;
client
.delete_object()
.bucket(SOURCE_BUCKET)
.key("hidden/marker")
.send()
.await?;
let expected_sorted: Vec<String> = expected_keys.iter().cloned().collect();
let expected_prefixes: Vec<String> = (0..30).map(|directory| format!("d{directory:02}/")).collect();
let expected_top: Vec<String> = (0..100).map(|index| format!("top-{index:03}")).collect();
// Flat traversal in byte order, 1000 keys in pages of 7.
let (keys, prefixes, pages) = list_all(&client, None, None, None, 7).await?;
assert_eq!(keys, expected_sorted);
assert!(prefixes.is_empty());
assert_eq!(pages, 143);
// Delimiter folding: 30 common prefixes then 100 top-level keys, pages of 7.
let (keys, prefixes, pages) = list_all(&client, None, Some("/"), None, 7).await?;
assert_eq!(prefixes, expected_prefixes);
assert_eq!(keys, expected_top);
assert_eq!(pages, 19);
// Empty prefix equals no prefix.
let (keys, _, _) = list_all(&client, Some(""), None, None, 1000).await?;
assert_eq!(keys, expected_sorted);
// No match: empty, not truncated, no token.
let (keys, prefixes, pages) = list_all(&client, Some("zzz/"), Some("/"), None, 7).await?;
assert!(keys.is_empty() && prefixes.is_empty());
assert_eq!(pages, 1);
let (keys, _, _) = list_all(&client, Some("hidden/"), None, None, 7).await?;
assert!(keys.is_empty(), "a current delete marker must hide its key");
// Exact page boundary: 30 keys under one directory, max-keys=30 -> one
// untruncated page.
let (keys, prefixes, pages) = list_all(&client, Some("d05/"), Some("/"), None, 30).await?;
assert_eq!(keys.len(), 30);
assert!(prefixes.is_empty());
assert_eq!(pages, 1);
// start-after skips keys at or before the marker.
let (keys, _, _) = list_all(&client, None, None, Some("top-097"), 1000).await?;
assert_eq!(keys, ["top-098", "top-099"]);
// max-keys is clamped to 1000; exactly 1000 keys fit in one page.
let (keys, _, pages) = list_all(&client, None, None, None, 5000).await?;
assert_eq!(keys.len(), 1000);
assert_eq!(pages, 1);
let listings: Vec<_> = source
.requests()
.into_iter()
.filter(|record| record.operation == Operation::ListObjectsV2)
.collect();
assert!(listings.len() >= 143 + 19);
assert!(listings.iter().any(|record| record.prefix.as_deref() == Some("d05/")));
assert!(
listings.iter().any(|record| record.continuation_token.is_some()),
"resumed pages must journal their continuation token"
);
assert!(listings.iter().all(|record| record.user_agent.is_some()));
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_range_get_variants_and_416() -> TestResult {
let (source, client) = fake_source().await?;
let body = payload(1000);
source.put_seed_object(SOURCE_BUCKET, "ranged", body.clone(), &SeedMetadata::new());
for (range, expected_range, expected_slice) in [
("bytes=10-19", "bytes 10-19/1000", &body[10..20]),
("bytes=990-", "bytes 990-999/1000", &body[990..]),
("bytes=-5", "bytes 995-999/1000", &body[995..]),
("bytes=0-5000", "bytes 0-999/1000", &body[..]),
] {
let output = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("ranged")
.range(range)
.send()
.await?;
assert_eq!(output.content_range(), Some(expected_range), "{range}");
assert_eq!(output.accept_ranges(), Some("bytes"), "{range}");
assert_eq!(output.content_length(), Some(expected_slice.len() as i64), "{range}");
let collected = output.body.collect().await?.into_bytes();
assert_eq!(collected.as_ref(), expected_slice, "{range}");
}
let head = client
.head_object()
.bucket(SOURCE_BUCKET)
.key("ranged")
.range("bytes=10-19")
.send()
.await?;
assert_eq!(head.content_range(), Some("bytes 10-19/1000"));
assert_eq!(head.content_length(), Some(10));
for range in ["bytes=1000-", "bytes=-0"] {
let error = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("ranged")
.range(range)
.send()
.await
.expect_err("unsatisfiable range must fail");
let response = error.raw_response().expect("416 must retain the raw response");
assert_eq!(response.status().as_u16(), 416, "{range}");
assert_eq!(response.headers().get("content-range"), Some("bytes */1000"), "{range}");
assert_eq!(error.code(), Some("InvalidRange"), "{range}");
}
let ranged = source
.requests()
.into_iter()
.find(|record| record.operation == Operation::GetObject && record.range.as_deref() == Some("bytes=10-19"))
.expect("the Range header must be journaled verbatim");
assert_eq!(ranged.key.as_deref(), Some("ranged"));
assert!(source.count_requests(Operation::GetObject, "ranged") >= 6);
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_unversioned_bucket_overwrites_and_deletes() -> TestResult {
let (source, client) = fake_source().await?;
source.create_bucket_with_mode("plain-source", BucketMode::Unversioned);
let versioning = client.get_bucket_versioning().bucket("plain-source").send().await?;
assert!(versioning.status().is_none(), "unversioned bucket must report no versioning status");
let first = client
.put_object()
.bucket("plain-source")
.key("doc")
.body(ByteStream::from_static(b"first"))
.send()
.await?;
assert!(first.version_id().is_none());
let second = client
.put_object()
.bucket("plain-source")
.key("doc")
.body(ByteStream::from_static(b"second"))
.send()
.await?;
assert!(second.version_id().is_none());
let get = client.get_object().bucket("plain-source").key("doc").send().await?;
assert!(get.version_id().is_none(), "GET must not return x-amz-version-id");
assert_eq!(get.body.collect().await?.into_bytes().as_ref(), b"second");
let head = client.head_object().bucket("plain-source").key("doc").send().await?;
assert!(head.version_id().is_none(), "HEAD must not return x-amz-version-id");
assert_eq!(source.stored_versions("plain-source", "doc").len(), 1, "overwrite must replace in place");
let deleted = client.delete_object().bucket("plain-source").key("doc").send().await?;
assert!(deleted.delete_marker().is_none() && deleted.version_id().is_none());
let missing = client
.get_object()
.bucket("plain-source")
.key("doc")
.send()
.await
.expect_err("deleted object must be gone");
assert_eq!(missing.raw_response().map(|response| response.status().as_u16()), Some(404));
assert_eq!(missing.code(), Some("NoSuchKey"));
let missing_head = client
.head_object()
.bucket("plain-source")
.key("doc")
.send()
.await
.expect_err("deleted object must fail HEAD");
assert_eq!(missing_head.raw_response().map(|response| response.status().as_u16()), Some(404));
assert!(source.stored_versions("plain-source", "doc").is_empty(), "DELETE must not leave a marker");
// The versioned bucket on the same target keeps its version ids.
let versioned = client
.put_object()
.bucket(SOURCE_BUCKET)
.key("doc")
.body(ByteStream::from_static(b"versioned"))
.send()
.await?;
assert!(versioned.version_id().is_some());
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_replays_standard_and_user_metadata() -> TestResult {
let (source, client) = fake_source().await?;
let body = payload(4096);
let expected_etag = format!("\"{}\"", {
use md5::Digest as _;
hex_simd::encode_to_string(md5::Md5::digest(&body), hex_simd::AsciiCase::Lower)
});
// 2026-01-01T00:00:00Z rendered as an HTTP date by the SDK.
let expires = DateTime::from_secs(1_767_225_600);
client
.put_object()
.bucket(SOURCE_BUCKET)
.key("meta")
.body(ByteStream::from(body.clone()))
.content_type("application/x-odm")
.content_encoding("gzip")
.content_disposition("attachment; filename=\"meta.bin\"")
.content_language("en-US")
.cache_control("max-age=60")
.expires(expires)
.metadata("Foo-Bar", "mixed case name")
.metadata("UPPER", "upper name")
.metadata("already-lower", "lower name")
.send()
.await?;
let head = client.head_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
let get = client.get_object().bucket(SOURCE_BUCKET).key("meta").send().await?;
for (label, content_type, content_encoding, content_disposition, content_language, cache_control, expires_string, e_tag) in [
(
"HEAD",
head.content_type(),
head.content_encoding(),
head.content_disposition(),
head.content_language(),
head.cache_control(),
head.expires_string(),
head.e_tag(),
),
(
"GET",
get.content_type(),
get.content_encoding(),
get.content_disposition(),
get.content_language(),
get.cache_control(),
get.expires_string(),
get.e_tag(),
),
] {
assert_eq!(content_type, Some("application/x-odm"), "{label}");
assert_eq!(content_encoding, Some("gzip"), "{label}");
assert_eq!(content_disposition, Some("attachment; filename=\"meta.bin\""), "{label}");
assert_eq!(content_language, Some("en-US"), "{label}");
assert_eq!(cache_control, Some("max-age=60"), "{label}");
assert_eq!(expires_string, Some("Thu, 01 Jan 2026 00:00:00 GMT"), "{label}");
assert_eq!(e_tag, Some(expected_etag.as_str()), "{label}");
}
for metadata in [head.metadata(), get.metadata()] {
let metadata = metadata.expect("user metadata must be replayed");
assert_eq!(metadata.get("foo-bar").map(String::as_str), Some("mixed case name"));
assert_eq!(metadata.get("upper").map(String::as_str), Some("upper name"));
assert_eq!(metadata.get("already-lower").map(String::as_str), Some("lower name"));
assert!(!metadata.contains_key("Foo-Bar") && !metadata.contains_key("UPPER"));
}
assert!(head.last_modified().is_some());
assert_eq!(head.last_modified(), get.last_modified());
assert_eq!(head.content_length(), Some(4096));
assert_eq!(get.body.collect().await?.into_bytes(), body);
// Seeded objects replay the same way.
let seeded_etag = source.put_seed_object(
SOURCE_BUCKET,
"seeded",
Bytes::from_static(b"seeded"),
&SeedMetadata::new()
.content_type("text/plain")
.content_encoding("identity")
.cache_control("no-store")
.user_metadata("Origin", "seed"),
);
let seeded = client.head_object().bucket(SOURCE_BUCKET).key("seeded").send().await?;
assert_eq!(seeded.e_tag(), Some(format!("\"{seeded_etag}\"").as_str()));
assert_eq!(seeded.content_type(), Some("text/plain"));
assert_eq!(seeded.content_encoding(), Some("identity"));
assert_eq!(seeded.cache_control(), Some("no-store"));
assert_eq!(
seeded
.metadata()
.and_then(|metadata| metadata.get("origin"))
.map(String::as_str),
Some("seed")
);
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_fault_actions_truncate_stall_and_status() -> TestResult {
let (source, client) = fake_source().await?;
let body = payload(4096);
source.put_seed_object(SOURCE_BUCKET, "faulty", body.clone(), &SeedMetadata::new());
// TruncateBodyAt: headers promise 4096 bytes, the body ends after 100.
source.inject_for_key(Operation::GetObject, "faulty", FaultAction::TruncateBodyAt(100), 1);
let truncated = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert_eq!(truncated.content_length(), Some(4096));
let short_read = truncated
.body
.collect()
.await
.expect_err("a truncated body must fail to collect");
let short_read = short_read.to_string();
assert!(!short_read.is_empty());
// ResponseStatus: arbitrary status with the matching S3 error code.
for (code, expected_code) in [
(429u16, "SlowDown"),
(404, "NoSuchKey"),
(500, "InternalError"),
(503, "ServiceUnavailable"),
] {
source.inject(Operation::GetObject, FaultAction::ResponseStatus(code), 1);
let error = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("faulty")
.send()
.await
.expect_err("scripted status must fail");
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(code));
assert_eq!(error.code(), Some(expected_code));
}
// Stall: the fully computed response is held before its first byte.
source.inject(Operation::HeadObject, FaultAction::Stall(Duration::from_millis(400)), 1);
let started = Instant::now();
let stalled = client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert!(started.elapsed() >= Duration::from_millis(350), "stall must delay the first byte");
assert_eq!(stalled.content_length(), Some(4096));
let post_stall_started = Instant::now();
client.head_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert!(post_stall_started.elapsed() < Duration::from_millis(350), "stall is consumed once");
// The object is intact once the script is drained.
let intact = client.get_object().bucket(SOURCE_BUCKET).key("faulty").send().await?;
assert_eq!(intact.body.collect().await?.into_bytes(), body);
assert_eq!(source.count_requests(Operation::GetObject, "faulty"), 6);
assert_eq!(source.count_requests(Operation::HeadObject, "faulty"), 2);
assert_eq!(source.count_requests(Operation::GetObject, "other"), 0);
let records = source.requests();
assert!(
records.iter().all(|record| record
.user_agent
.as_deref()
.is_some_and(|agent| agent.contains("aws-sdk-rust"))),
"the SDK user agent must be journaled"
);
assert!(
records
.iter()
.any(|record| record.fault == Some(FaultAction::TruncateBodyAt(100)))
);
assert!(
records
.iter()
.any(|record| record.fault == Some(FaultAction::Stall(Duration::from_millis(400))))
);
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn fake_source_raised_object_cap_accepts_large_put() -> TestResult {
let source = FakeS3Target::start_with_options(FakeS3TargetOptions {
max_object_bytes: 96 * 1024 * 1024,
})
.await?;
source.create_bucket(SOURCE_BUCKET);
let client = fake_source_client(&source);
let len = 64 * 1024 * 1024 + 1;
client
.put_object()
.bucket(SOURCE_BUCKET)
.key("large")
.body(ByteStream::from(vec![7u8; len]))
.send()
.await?;
let head = client.head_object().bucket(SOURCE_BUCKET).key("large").send().await?;
assert_eq!(head.content_length(), Some(len as i64));
let tail = client
.get_object()
.bucket(SOURCE_BUCKET)
.key("large")
.range("bytes=-1")
.send()
.await?;
assert_eq!(tail.content_range(), Some(format!("bytes {}-{}/{len}", len - 1, len - 1).as_str()));
source.shutdown().await;
Ok(())
}
#[tokio::test]
async fn odm_env_starts_rustfs_and_fake_source() -> TestResult {
let env = OdmTestEnv::start().await?;
env.source.create_bucket(SOURCE_BUCKET);
let local_bucket = "odm-local";
env.rustfs.create_test_bucket(local_bucket).await?;
let etags = env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("seed/a", Bytes::from_static(b"alpha")),
SeedObject::new("seed/b", Bytes::from_static(b"beta"))
.with_metadata(SeedMetadata::new().content_type("text/plain").user_metadata("Kind", "seed")),
],
);
assert_eq!(etags.len(), 2);
assert!(env.source.requests().is_empty(), "seeding must not touch the journal");
let source_client = env.source_client();
let seeded = source_client.head_object().bucket(SOURCE_BUCKET).key("seed/b").send().await?;
assert_eq!(seeded.content_type(), Some("text/plain"));
assert_eq!(seeded.e_tag(), Some(format!("\"{}\"", etags[1]).as_str()));
assert_eq!(env.source.count_requests(Operation::HeadObject, "seed/b"), 1);
env.assert_local_absent(local_bucket, "seed/a").await;
env.client
.put_object()
.bucket(local_bucket)
.key("seed/a")
.body(ByteStream::from_static(b"alpha"))
.send()
.await?;
env.assert_local_present(local_bucket, "seed/a", b"alpha").await;
env.assert_local_absent(local_bucket, "seed/b").await;
let spec = env.fake_source_spec(SOURCE_BUCKET).to_json();
assert_eq!(spec["version"], 1);
assert_eq!(spec["enabled"], true);
assert_eq!(spec["source"]["provider"], "s3");
assert_eq!(spec["source"]["endpoint"], env.source.endpoint());
assert_eq!(spec["source"]["bucket"], SOURCE_BUCKET);
assert_eq!(spec["source"]["credentials"]["secret_key"], "fake-secret");
assert_eq!(spec["policy"]["source_timeout"]["first_byte_ms"], 15_000);
assert!(spec["policy"]["bandwidth_limit_bytes_per_sec"].is_null());
let debug = format!("{:?}", env.fake_source_spec(SOURCE_BUCKET));
assert!(!debug.contains("fake-secret"), "Debug output must redact the secret");
Ok(())
}
#[tokio::test]
async fn start_source_rustfs_round_trips_put_get() -> TestResult {
let env = OdmTestEnv::start().await?;
let source = start_source_rustfs().await?;
assert_ne!(source.url, env.rustfs.url, "the source must be a separate instance");
source.create_test_bucket(SOURCE_BUCKET).await?;
let source_client = source.create_s3_client();
let body = payload(70_000);
let put = source_client
.put_object()
.bucket(SOURCE_BUCKET)
.key("real/object")
.body(ByteStream::from(body.clone()))
.content_type("application/octet-stream")
.send()
.await?;
assert!(put.e_tag().is_some());
let get = source_client
.get_object()
.bucket(SOURCE_BUCKET)
.key("real/object")
.send()
.await?;
assert_eq!(get.content_type(), Some("application/octet-stream"));
assert_eq!(get.body.collect().await?.into_bytes(), body);
let visible_to_primary = env
.client
.list_buckets()
.send()
.await?
.buckets()
.iter()
.any(|bucket| bucket.name() == Some(SOURCE_BUCKET));
assert!(!visible_to_primary, "the two servers must not share state");
let spec = super::common::OdmSourceSpec::for_rustfs_source(&source, SOURCE_BUCKET).to_json();
assert_eq!(spec["source"]["provider"], "rustfs");
assert_eq!(spec["source"]["endpoint"], source.url);
Ok(())
}
@@ -0,0 +1,823 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! How on-demand migration composes with the rest of the bucket surface
//! (rustfs/backlog#2158): default encryption, Object Lock, quota,
//! notifications, replication, versioning and delete markers, the disable
//! switch, and the admin view.
//!
//! A pulled object goes through the internal put path, so it must be
//! indistinguishable from a client PUT. Each case pins both the resulting
//! local object and what the source was asked for.
use super::common::{
AdminResponse, BoxError, OdmEnvOptions, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env,
start_configured_env_with,
};
use crate::common::{RustFSTestEnvironment, replication_fast_env, signed_request};
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
use crate::object_lock::common::put_object_lock_configuration;
use aws_sdk_s3::error::ProvideErrorMetadata;
use aws_sdk_s3::types::{
BucketVersioningStatus, Event, FilterRule, FilterRuleName, NotificationConfiguration, NotificationConfigurationFilter,
ObjectLockRetentionMode, QueueConfiguration, S3KeyFilter, ServerSideEncryption, ServerSideEncryptionByDefault,
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, VersioningConfiguration,
};
use bytes::Bytes;
use local_ip_address::local_ip;
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
use serde_json::Value;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::mpsc;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-interaction-source";
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// `userIdentity.principalId` every write-back event carries.
const ODM_PRINCIPAL_ID: &str = "rustfs-on-demand-migration";
const SETTLE: Duration = Duration::from_secs(120);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
async fn admin(
env: &RustFSTestEnvironment,
method: http::Method,
path: &str,
body: Option<Value>,
) -> Result<AdminResponse, BoxError> {
let url = format!("{}{path}", env.url);
let body = body.map(|value| serde_json::to_vec(&value)).transpose()?;
let content_type = body.is_some().then_some("application/json");
let response = signed_request(method, &url, &env.access_key, &env.secret_key, body, content_type).await?;
Ok(AdminResponse {
status: response.status().as_u16(),
body: response.text().await?,
})
}
async fn enable_versioning(env: &OdmTestEnv, bucket: &str) -> TestResult {
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
Ok(())
}
/// Case 12: a bucket that encrypts by default stores the pulled object
/// encrypted, and it reads back as plaintext afterwards without touching the
/// source again.
#[tokio::test]
async fn test_odm_pulled_object_uses_bucket_default_encryption() -> TestResult {
let bucket = "odm-interaction-sse";
let env = start_configured_env_with(
OdmEnvOptions {
local_kms: true,
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
env.client
.put_bucket_encryption()
.bucket(bucket)
.server_side_encryption_configuration(
ServerSideEncryptionConfiguration::builder()
.rules(
ServerSideEncryptionRule::builder()
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::Aes256)
.build()?,
)
.build(),
)
.build()?,
)
.send()
.await?;
let key = "sse/report.bin";
let body = payload(128 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let first = env.raw_get(bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(first.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let second = env.raw_get(bucket, key).await?;
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "the second read is local");
assert_eq!(
second.header("x-amz-server-side-encryption"),
Some("AES256"),
"the write-back honours the bucket default encryption"
);
assert_eq!(second.body, body, "the encrypted copy reads back as the source bytes");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the encrypted local copy serves the second read"
);
Ok(())
}
/// Case 13: a pulled object inherits the bucket's default Object Lock
/// retention, so it cannot be deleted while the retention holds.
#[tokio::test]
async fn test_odm_pulled_object_inherits_object_lock_retention() -> TestResult {
let bucket = "odm-interaction-object-lock";
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(SOURCE_BUCKET, BucketMode::Unversioned);
env.client
.create_bucket()
.bucket(bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
put_object_lock_configuration(&env.client, bucket, ObjectLockRetentionMode::Compliance, Some(1), None).await?;
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(bucket, &spec).await?;
let key = "locked/record.bin";
let body = payload(32 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let pulled = env.raw_get(bucket, key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert_eq!(pulled.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let head = env.client.head_object().bucket(bucket).key(key).send().await?;
assert_eq!(
head.object_lock_mode().map(|mode| mode.as_str()),
Some("COMPLIANCE"),
"the default retention mode is applied to the pulled object"
);
assert!(head.object_lock_retain_until_date().is_some(), "a retain-until date is set");
let version_id = head.version_id().ok_or("an Object Lock bucket is versioned")?.to_string();
let error = env
.client
.delete_object()
.bucket(bucket)
.key(key)
.version_id(&version_id)
.send()
.await
.expect_err("a COMPLIANCE-retained version cannot be deleted");
assert_eq!(error.code(), Some("AccessDenied"), "{error:?}");
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the rejected delete never consults the source"
);
Ok(())
}
/// Case 14: the write-back obeys the bucket quota. The client is still
/// served from the source, but nothing is stored and the failure is counted.
#[tokio::test]
async fn test_odm_write_back_respects_the_bucket_quota() -> TestResult {
let bucket = "odm-interaction-quota";
let env = start_configured_env_with(
OdmEnvOptions {
env: vec![("RUSTFS_SCANNER_CYCLE", "1"), ("RUSTFS_SCANNER_START_DELAY_SECS", "0")],
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
// Fill the bucket past the quota it is about to get, so the write-back's
// admission check has to reject it.
let filler = payload(2 * 1024 * 1024);
env.client
.put_object()
.bucket(bucket)
.key("quota/filler.bin")
.body(aws_sdk_s3::primitives::ByteStream::from(filler.clone()))
.send()
.await?;
wait_for_bucket_usage(&env, bucket, filler.len() as u64).await?;
set_bucket_quota(&env, bucket, 1024 * 1024).await?;
let key = "quota/oversized.bin";
let body = payload(2 * 1024 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(response.body, body, "a full bucket still serves the client from the source");
env.wait_for_status_counter(bucket, "/counters/pull_failures_total/quota", 1, SETTLE)
.await?;
env.assert_local_absent(bucket, key).await;
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"the rejected write-back is not retried against the source"
);
Ok(())
}
/// The quota route answers 503 until the durable-quota capability is
/// confirmed on the fresh single-node deployment, so the write is retried.
async fn set_bucket_quota(env: &OdmTestEnv, bucket: &str, quota_bytes: u64) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = admin(
&env.rustfs,
http::Method::PUT,
&format!("/rustfs/admin/v3/quota/{bucket}"),
Some(serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" })),
)
.await?;
if response.status < 300 {
return Ok(());
}
if response.status != 503 || Instant::now() >= deadline {
return Err(format!("set quota for {bucket}: {} {}", response.status, response.body).into());
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
}
async fn wait_for_bucket_usage(env: &OdmTestEnv, bucket: &str, at_least: u64) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
let response = admin(&env.rustfs, http::Method::GET, &format!("/rustfs/admin/v3/quota-stats/{bucket}"), None).await?;
if response.status == 200 {
let usage = serde_json::from_str::<Value>(&response.body)?
.get("current_usage")
.and_then(Value::as_u64)
.unwrap_or(0);
if usage >= at_least {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(format!("bucket usage for {bucket} did not reach {at_least} bytes: {}", response.body).into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Case 15: a pull emits an ordinary creation event attributed to the
/// migration principal, and `emit_events=false` silences it.
#[tokio::test]
async fn test_odm_pull_emits_object_created_events_unless_disabled() -> TestResult {
let emitting = "odm-interaction-events";
let silent = "odm-interaction-events-off";
// The collector binds first: the outbound guard rejects a webhook
// endpoint on a private address unless its origin is allowed at startup.
let (endpoint, mut events) = spawn_event_collector().await?;
let allowed_origin = reqwest::Url::parse(&endpoint)?.origin().ascii_serialization();
let env = start_configured_env_with(
OdmEnvOptions {
env: vec![(ENV_OUTBOUND_ALLOW_ORIGINS, allowed_origin.as_str())],
..OdmEnvOptions::default()
},
emitting,
SOURCE_BUCKET,
|_| {},
)
.await?;
let mut silent_spec = env.fake_source_spec(SOURCE_BUCKET);
silent_spec.policy.emit_events = false;
env.configure_and_wait(silent, &silent_spec).await?;
let target = "odm-events";
let switches = admin(
&env.rustfs,
http::Method::PUT,
"/rustfs/admin/v3/module-switches",
Some(serde_json::json!({ "notify_enabled": true, "audit_enabled": false })),
)
.await?;
assert_eq!(switches.status, 200, "{}", switches.body);
let queue_dir = format!("{}/notify-queue-{target}", env.rustfs.temp_dir);
tokio::fs::create_dir_all(&queue_dir).await?;
let configured = admin(
&env.rustfs,
http::Method::PUT,
&format!("/rustfs/admin/v3/target/notify_webhook/{target}"),
Some(serde_json::json!({
"key_values": [
{ "key": "endpoint", "value": endpoint },
{ "key": "queue_dir", "value": queue_dir },
]
})),
)
.await?;
assert_eq!(configured.status, 200, "{}", configured.body);
wait_for_target_online(&env.rustfs, target).await?;
for bucket in [emitting, silent] {
put_notification_config(&env, bucket, target).await?;
}
// Control: an ordinary client PUT must produce an event, so a missing
// one below is about the write-back and not about the pipeline.
let control_key = "events/control.bin";
env.client
.put_object()
.bucket(emitting)
.key(control_key)
.body(aws_sdk_s3::primitives::ByteStream::from(payload(1024)))
.send()
.await?;
let control = wait_for_event(&mut events, emitting, control_key, Duration::from_secs(60))
.await
.ok_or("the notification pipeline delivered no event for a plain PUT")?;
assert_eq!(
control.pointer("/eventName").and_then(Value::as_str),
Some("s3:ObjectCreated:Put"),
"{control}"
);
let emitting_key = "events/pulled.bin";
let silent_key = "events/quiet.bin";
let body = payload(16 * 1024);
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(emitting_key, body.clone()),
SeedObject::new(silent_key, body.clone()),
],
);
for (bucket, key) in [(emitting, emitting_key), (silent, silent_key)] {
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{bucket}: {}", String::from_utf8_lossy(&response.body));
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "{bucket}/{key} must be stored");
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1, "{bucket}/{key}");
}
let record = wait_for_event(&mut events, emitting, emitting_key, Duration::from_secs(60))
.await
.ok_or("no creation event for the pulled object")?;
assert_eq!(
record.pointer("/eventName").and_then(Value::as_str),
Some("s3:ObjectCreated:Put"),
"{record}"
);
assert_eq!(
record.pointer("/userIdentity/principalId").and_then(Value::as_str),
Some(ODM_PRINCIPAL_ID),
"{record}"
);
// The silent bucket's object landed before the event above was observed,
// so a missing event here is a decision, not a race.
assert!(
wait_for_event(&mut events, silent, silent_key, Duration::from_secs(5))
.await
.is_none(),
"emit_events=false must not publish a creation event"
);
Ok(())
}
async fn put_notification_config(env: &OdmTestEnv, bucket: &str, target: &str) -> TestResult {
let queue = QueueConfiguration::builder()
.id(format!("{bucket}-rule"))
.queue_arn(format!("arn:rustfs:sqs:us-east-1:{target}:webhook"))
.events(Event::from("s3:ObjectCreated:*"))
.filter(
NotificationConfigurationFilter::builder()
.key(
S3KeyFilter::builder()
.filter_rules(FilterRule::builder().name(FilterRuleName::Prefix).value("events/").build())
.build(),
)
.build(),
)
.build()?;
env.client
.put_bucket_notification_configuration()
.bucket(bucket)
.notification_configuration(NotificationConfiguration::builder().queue_configurations(queue).build())
.send()
.await?;
Ok(())
}
async fn wait_for_target_online(env: &RustFSTestEnvironment, target: &str) -> TestResult {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
let response = admin(env, http::Method::GET, "/rustfs/admin/v3/target/list", None).await?;
if response.status == 200 {
let body: Value = serde_json::from_str(&response.body)?;
let online = body["notification_endpoints"].as_array().is_some_and(|endpoints| {
endpoints.iter().any(|endpoint| {
endpoint["account_id"].as_str() == Some(target) && endpoint["status"].as_str() == Some("online")
})
});
if online {
return Ok(());
}
}
if Instant::now() >= deadline {
return Err(format!("webhook target {target} did not come online: {}", response.body).into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Minimal HTTP receiver: answers everything 200 (so the target's
/// reachability probe reports online) and forwards parsed POST bodies.
async fn spawn_event_collector() -> Result<(String, mpsc::UnboundedReceiver<Value>), BoxError> {
let listener = TcpListener::bind("0.0.0.0:0").await?;
let port = listener.local_addr()?.port();
let endpoint = format!("http://{}/events", std::net::SocketAddr::new(local_ip()?, port));
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let tx = tx.clone();
tokio::spawn(async move {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 4096];
let mut content_length = 0usize;
let mut header_end = None;
while header_end.is_none() {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
header_end = buffer.windows(4).position(|window| window == b"\r\n\r\n");
}
let header_end = header_end.expect("loop exits only with a header end");
let headers = String::from_utf8_lossy(&buffer[..header_end]).to_string();
for line in headers.split("\r\n").skip(1) {
if let Some((name, value)) = line.split_once(':')
&& name.trim().eq_ignore_ascii_case("content-length")
{
content_length = value.trim().parse().unwrap_or(0);
}
}
let body_offset = header_end + 4;
while buffer.len() - body_offset < content_length {
match stream.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(read) => buffer.extend_from_slice(&chunk[..read]),
}
}
let _ = stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\nconnection: close\r\n\r\n")
.await;
let _ = stream.shutdown().await;
if let Ok(value) = serde_json::from_slice::<Value>(&buffer[body_offset..body_offset + content_length]) {
let _ = tx.send(value);
}
});
}
});
Ok((endpoint, rx))
}
/// The first delivered record for `bucket`/`key`, or `None` on timeout.
async fn wait_for_event(
events: &mut mpsc::UnboundedReceiver<Value>,
bucket: &str,
key: &str,
timeout: Duration,
) -> Option<Value> {
let deadline = Instant::now() + timeout;
loop {
let remaining = deadline.checked_duration_since(Instant::now())?;
let envelope = tokio::time::timeout(remaining, events.recv()).await.ok()??;
for record in envelope["Records"].as_array().into_iter().flatten() {
// S3 event notifications URL-encode the object key.
let record_key = record.pointer("/s3/object/key").and_then(Value::as_str).map(|raw| {
urlencoding::decode(raw)
.map(|decoded| decoded.into_owned())
.unwrap_or_else(|_| raw.to_string())
});
if record.pointer("/s3/bucket/name").and_then(Value::as_str) == Some(bucket) && record_key.as_deref() == Some(key) {
return Some(record.clone());
}
}
}
}
/// Case 16: a pulled object enters the replication pipeline like any other
/// write, and a configuration whose source is one of the bucket's own
/// replication targets is rejected.
#[tokio::test]
async fn test_odm_pulled_object_replicates_and_target_as_source_is_rejected() -> TestResult {
let bucket = "odm-interaction-replication";
let replica_bucket = "odm-replica";
let fast_env = replication_fast_env();
let env = start_configured_env_with(
OdmEnvOptions {
env: fast_env.clone(),
..OdmEnvOptions::default()
},
bucket,
SOURCE_BUCKET,
|_| {},
)
.await?;
let replica = FakeS3Target::start().await?;
replica.create_bucket(replica_bucket);
enable_versioning(&env, bucket).await?;
let arn = set_remote_target(&env.rustfs, bucket, &replica.address(), replica_bucket).await?;
put_bucket_replication(&env.rustfs, bucket, &arn).await?;
let key = "replicated/asset.bin";
let body = payload(64 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, body.clone())]);
let response = env.raw_get(bucket, key).await?;
assert_eq!(response.status, 200, "{}", String::from_utf8_lossy(&response.body));
assert_eq!(response.body, body);
assert!(env.wait_local_listed(bucket, key, SETTLE).await?, "the pull must store the object");
let deadline = Instant::now() + SETTLE;
while !replica.has_object(replica_bucket, key) {
assert!(Instant::now() < deadline, "the pulled object was never replicated to the target");
tokio::time::sleep(Duration::from_millis(200)).await;
}
assert_eq!(
env.source.count_requests(Operation::GetObject, key),
1,
"replication reads the local copy, never the migration source"
);
let looping = OdmSourceSpec::for_fake_source(&replica, replica_bucket);
let rejected = env.configure_source(bucket, &looping).await?;
assert_eq!(
rejected.status, 400,
"a bucket may not migrate from its own replication target: {}",
rejected.body
);
Ok(())
}
async fn set_remote_target(
env: &RustFSTestEnvironment,
bucket: &str,
endpoint: &str,
target_bucket: &str,
) -> Result<String, BoxError> {
let response = admin(
env,
http::Method::PUT,
&format!("/rustfs/admin/v3/set-remote-target?bucket={}", urlencoding::encode(bucket)),
Some(serde_json::json!({
"endpoint": endpoint,
"credentials": { "accessKey": FAKE_ACCESS_KEY, "secretKey": FAKE_SECRET_KEY },
"targetbucket": target_bucket,
"secure": false,
"skipTlsVerify": false,
"type": "replication"
})),
)
.await?;
if response.status != 200 {
return Err(format!("set remote target: {} {}", response.status, response.body).into());
}
Ok(serde_json::from_str(&response.body)?)
}
async fn put_bucket_replication(env: &RustFSTestEnvironment, bucket: &str, arn: &str) -> TestResult {
let body = format!(
r#"<ReplicationConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<Role></Role>
<Rule>
<ID>odm-rule</ID>
<Priority>1</Priority>
<Status>Enabled</Status>
<DeleteMarkerReplication><Status>Enabled</Status></DeleteMarkerReplication>
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
<Destination><Bucket>{arn}</Bucket></Destination>
</Rule>
</ReplicationConfiguration>"#
);
let url = format!("{}/{bucket}?replication", env.url);
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body.into_bytes()),
Some("application/xml"),
)
.await?;
if response.status() != 200 {
let status = response.status();
return Err(format!("put bucket replication: {status} {}", response.text().await.unwrap_or_default()).into());
}
Ok(())
}
/// Case 17: a local delete marker is the authoritative answer in a versioned
/// bucket, while an unversioned delete leaves nothing behind and the key is
/// migrated again.
#[tokio::test]
async fn test_odm_delete_marker_shadows_the_source_but_a_plain_delete_does_not() -> TestResult {
let versioned = "odm-interaction-delete-marker";
let unversioned = "odm-interaction-plain-delete";
let env = start_configured_env(versioned, SOURCE_BUCKET, |_| {}).await?;
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(unversioned, &spec).await?;
enable_versioning(&env, versioned).await?;
let key = "deleted/doc.bin";
let source_body = payload(8 * 1024);
let local_body = payload(4 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(key, source_body.clone())]);
for bucket in [versioned, unversioned] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(local_body.clone()))
.send()
.await?;
env.client.delete_object().bucket(bucket).key(key).send().await?;
}
let shadowed = env.raw_get(versioned, key).await?;
assert_eq!(shadowed.status, 404, "{}", String::from_utf8_lossy(&shadowed.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, key),
0,
"a local delete marker answers without the source"
);
let migrated = env.raw_get(unversioned, key).await?;
assert_eq!(migrated.status, 200, "{}", String::from_utf8_lossy(&migrated.body));
assert_eq!(migrated.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(migrated.body, source_body, "an unversioned delete leaves the source authoritative");
assert_eq!(env.source.count_requests(Operation::HeadObject, key), 1);
assert_eq!(env.source.count_requests(Operation::GetObject, key), 1);
Ok(())
}
/// Case 18: deleting the configuration stops all source traffic without
/// touching what was already migrated, and reinstalling it resumes.
#[tokio::test]
async fn test_odm_disable_keeps_pulled_objects_and_stops_source_traffic() -> TestResult {
let bucket = "odm-interaction-disable";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let pulled_key = "disable/pulled.bin";
let untouched_key = "disable/untouched.bin";
let body = payload(32 * 1024);
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new(pulled_key, body.clone()),
SeedObject::new(untouched_key, body.clone()),
],
);
let pulled = env.raw_get(bucket, pulled_key).await?;
assert_eq!(pulled.status, 200, "{}", String::from_utf8_lossy(&pulled.body));
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?);
let disabled = env.disable(bucket).await?;
assert_eq!(disabled.status, 204, "{}", disabled.body);
let still_readable = env.raw_get(bucket, pulled_key).await?;
assert_eq!(still_readable.status, 200, "{}", String::from_utf8_lossy(&still_readable.body));
assert_eq!(still_readable.body, body, "a migrated object survives the disable");
assert_eq!(still_readable.header(ODM_RESPONSE_HEADER), None);
assert_eq!(env.source.count_requests(Operation::GetObject, pulled_key), 1);
let missing = env.raw_get(bucket, untouched_key).await?;
assert_eq!(missing.status, 404, "{}", String::from_utf8_lossy(&missing.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, untouched_key),
0,
"a disabled bucket never reaches the source"
);
let spec = env.fake_source_spec(SOURCE_BUCKET);
env.configure_and_wait(bucket, &spec).await?;
let resumed = env.raw_get(bucket, untouched_key).await?;
assert_eq!(resumed.status, 200, "{}", String::from_utf8_lossy(&resumed.body));
assert_eq!(resumed.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(resumed.body, body);
assert_eq!(env.source.count_requests(Operation::GetObject, untouched_key), 1);
Ok(())
}
/// Case 19: the admin surface an operator sees — the configuration read back
/// without its secret, and a status document whose counters match the source
/// journal exactly.
#[tokio::test]
async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source() -> TestResult {
let bucket = "odm-interaction-admin";
let env = start_configured_env(bucket, SOURCE_BUCKET, |_| {}).await?;
let hit_key = "admin/present.bin";
let miss_key = "admin/absent.bin";
let body = payload(16 * 1024);
env.seed_source(SOURCE_BUCKET, &[SeedObject::new(hit_key, body.clone())]);
let config = env.get_config(bucket).await?;
assert_eq!(config.status, 200, "{}", config.body);
let config = config.json()?;
assert_eq!(
config
.pointer("/config/source/credentials/secret_key")
.and_then(Value::as_str),
Some("REDACTED"),
"{config}"
);
assert_eq!(
config
.pointer("/config/source/credentials/access_key")
.and_then(Value::as_str),
Some(FAKE_ACCESS_KEY),
"the access key stays readable: {config}"
);
assert!(
!config.to_string().contains(FAKE_SECRET_KEY),
"the secret must not appear anywhere in the response"
);
let hit = env.raw_get(bucket, hit_key).await?;
assert_eq!(hit.status, 200, "{}", String::from_utf8_lossy(&hit.body));
for _ in 0..2 {
let miss = env.raw_get(bucket, miss_key).await?;
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
}
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
let status = env.status_json(bucket).await?;
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/enabled").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/module_enabled").and_then(Value::as_bool), Some(true), "{status}");
assert_eq!(status.pointer("/provider").and_then(Value::as_str), Some("s3"), "{status}");
assert_eq!(
status
.pointer("/counters/requests_total/get/source_hit")
.and_then(Value::as_u64),
Some(1),
"one source hit, matching the one source GET: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/source_miss")
.and_then(Value::as_u64),
Some(1),
"only the first miss reached the source: {status}"
);
assert_eq!(
status
.pointer("/counters/requests_total/get/negative_cached")
.and_then(Value::as_u64),
Some(1),
"the second miss stopped at the negative cache: {status}"
);
assert_eq!(
status
.pointer("/counters/pulled_objects_total/inline")
.and_then(Value::as_u64),
Some(1),
"{status}"
);
assert_eq!(
status.pointer("/counters/pulled_bytes_total").and_then(Value::as_u64),
Some(body.len() as u64),
"{status}"
);
assert_eq!(env.source.count_requests(Operation::GetObject, hit_key), 1);
assert_eq!(
env.source.count_requests(Operation::HeadObject, miss_key),
1,
"the status counters and the source journal agree"
);
Ok(())
}
@@ -0,0 +1,243 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Provider interoperability cases (ODM-20, rustfs/backlog#2167).
//!
//! One body per case, run against whichever source the environment names:
//! the in-process fake source locally, a MinIO container or a real cloud
//! provider under `.github/workflows/on-demand-migration-interop.yml`. The
//! source is resolved by [`OdmInteropEnv`], so a provider difference in
//! path-style addressing, region handling, ETag shape or list pagination
//! shows up as one of these assertions failing rather than as a second,
//! drifting copy of the suite.
//!
//! Consequently these cases assert only on what every S3 implementation has
//! to agree on — what the client receives and what RustFS stored — never on
//! the fake source's request journal, which a real provider does not have.
//! The journal-backed expectations stay in `get_basic_test.rs` and
//! `interaction_test.rs`.
//!
//! The first three cases are the minimum a cloud provider is asked for (GET
//! miss, HEAD miss, merged list pagination); the backfill case runs against
//! the MinIO container, whose object count the lane raises well past the fake
//! source's caps.
use super::common::{BackfillRequest, BoxError, OdmInteropEnv, SeedObject, interop_backfill_objects};
use bytes::Bytes;
use std::time::Duration;
type TestResult = Result<(), BoxError>;
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Background pulls land after the response that triggered them; generous for
/// a loaded runner talking to a container.
const SETTLE: Duration = Duration::from_secs(90);
/// Position-dependent payload so a misaligned or truncated copy is caught.
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// A GET miss is answered from the source with the source's own ETag, and the
/// object it stored serves every later read locally.
#[tokio::test]
async fn interop_get_miss_pulls_from_the_source_and_serves_locally() -> TestResult {
let case =
OdmInteropEnv::start("interop_get_miss_pulls_from_the_source_and_serves_locally", "odm-interop-get", |_| {}).await?;
let key = "interop/report.bin";
let body = payload(200 * 1024);
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
let quoted_etag = format!("\"{etag}\"");
let first = case.env.raw_get(&case.bucket, key).await?;
assert_eq!(first.status, 200, "{}", String::from_utf8_lossy(&first.body));
assert_eq!(first.header(ODM_RESPONSE_HEADER), Some("source"), "a source answer is marked");
assert_eq!(first.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(
first.header("etag"),
Some(quoted_etag.as_str()),
"the source ETag is passed through unchanged"
);
assert_eq!(first.body, body, "the client receives the source bytes");
assert!(
case.env.wait_local_listed(&case.bucket, key, SETTLE).await?,
"the inline pull must store the object locally"
);
let second = case.env.raw_get(&case.bucket, key).await?;
assert_eq!(second.status, 200, "{}", String::from_utf8_lossy(&second.body));
assert_eq!(second.header(ODM_RESPONSE_HEADER), None, "a local hit carries no source marker");
assert_eq!(second.body, body, "the local copy is the source bytes");
assert_eq!(
second.header("etag"),
Some(quoted_etag.as_str()),
"preserve_etag keeps the source ETag on the stored object"
);
case.finish().await
}
/// A HEAD miss is proxied with the source's size and ETag and stores nothing.
#[tokio::test]
async fn interop_head_miss_answers_from_the_source_without_persisting() -> TestResult {
let case =
OdmInteropEnv::start("interop_head_miss_answers_from_the_source_without_persisting", "odm-interop-head", |_| {}).await?;
let key = "interop/head-only.bin";
let body = payload(9_000);
let etag = case.seed(&[SeedObject::new(key, body.clone())]).await?.remove(0);
let head = case
.env
.raw_object_request(http::Method::HEAD, &case.bucket, key, &[])
.await?;
assert_eq!(head.status, 200, "HEAD must be answered from the source");
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(head.header("content-length"), Some(body.len().to_string().as_str()));
assert_eq!(head.header("etag"), Some(format!("\"{etag}\"").as_str()));
assert!(head.body.is_empty(), "a HEAD carries no body");
case.env.assert_local_absent(&case.bucket, key).await;
// A key the source does not hold is a plain 404, not a source error.
let missing = case
.env
.raw_object_request(http::Method::HEAD, &case.bucket, "interop/absent.bin", &[])
.await?;
assert_eq!(missing.status, 404, "a source miss is a 404");
case.finish().await
}
/// The merged `ListObjectsV2` pages the source namespace in byte order, keeps
/// every page within `max_keys`, and lets a local object win a shared key.
#[tokio::test]
async fn interop_list_through_pages_the_source_namespace() -> TestResult {
const SOURCE_KEYS: usize = 120;
const PAGE_SIZE: i32 = 50;
const SOURCE_BODY_LEN: usize = 3;
const LOCAL_BODY_LEN: usize = 11;
let case = OdmInteropEnv::start("interop_list_through_pages_the_source_namespace", "odm-interop-list", |spec| {
spec.policy.list_through = true
})
.await?;
let keys: Vec<String> = (0..SOURCE_KEYS).map(|index| format!("page/obj-{index:05}")).collect();
let seeds: Vec<SeedObject> = keys
.iter()
.map(|key| SeedObject::new(key.clone(), payload(SOURCE_BODY_LEN)))
.collect();
case.seed(&seeds).await?;
// Five keys the local bucket also holds, with a body length that tells the
// two sides apart in the listing.
let shared: Vec<String> = keys.iter().step_by(25).cloned().collect();
for key in &shared {
case.env
.client
.put_object()
.bucket(&case.bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(payload(LOCAL_BODY_LEN)))
.send()
.await?;
}
let mut listed: Vec<(String, i64)> = Vec::new();
let mut token: Option<String> = None;
let mut completed = false;
for _ in 0..SOURCE_KEYS {
let page = case
.env
.client
.list_objects_v2()
.bucket(&case.bucket)
.prefix("page/")
.max_keys(PAGE_SIZE)
.set_continuation_token(token.take())
.send()
.await?;
assert!(page.contents().len() <= PAGE_SIZE as usize, "a merged page must not exceed max_keys");
for object in page.contents() {
listed.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
}
if !page.is_truncated().unwrap_or(false) {
completed = true;
break;
}
token = Some(
page.next_continuation_token()
.ok_or("truncated merged page without a continuation token")?
.to_string(),
);
}
assert!(completed, "the merged listing did not terminate");
let listed_keys: Vec<String> = listed.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed_keys, keys, "the merged listing is the source namespace in byte order");
for (key, size) in &listed {
let expected = if shared.contains(key) {
LOCAL_BODY_LEN
} else {
SOURCE_BODY_LEN
};
assert_eq!(*size, expected as i64, "{key} must be reported by the side that wins it");
}
case.finish().await
}
/// A backfill pulls every object under the run's source prefix. The count
/// comes from the environment: the fake source caps out around 4,096 stored
/// versions, while the MinIO lane runs the full production-shaped batch.
#[tokio::test]
async fn interop_backfill_pulls_every_source_object() -> TestResult {
const KEY_PREFIX: &str = "cold/";
let count = interop_backfill_objects()?;
assert!(count > 0, "the backfill case needs at least one source object");
let case = OdmInteropEnv::start("interop_backfill_pulls_every_source_object", "odm-interop-backfill", |_| {}).await?;
let objects: Vec<SeedObject> = (0..count)
.map(|index| SeedObject::new(format!("{KEY_PREFIX}{index:06}"), Bytes::from(format!("object-{index:06}"))))
.collect();
case.seed(&objects).await?;
let started = case.env.start_backfill(&case.bucket, BackfillRequest::default()).await?;
assert_eq!(started.status, 200, "start backfill: {}", started.body);
// One pull is a HEAD plus a GET plus a local write; the ceiling scales
// with the object count so raising it in the workflow does not need a
// second knob here.
let timeout = Duration::from_secs(180 + count as u64 / 5);
let done = case
.env
.wait_for_backfill(&case.bucket, timeout, |job| job["state"] == "completed")
.await?;
for (name, expected) in [
("listed", count as u64),
("enqueued", count as u64),
("pulled", count as u64),
("failed", 0),
] {
assert_eq!(
done[name].as_u64().unwrap_or_else(|| panic!("{name} missing in {done}")),
expected,
"backfill {name}"
);
}
assert_eq!(
case.env.local_key_count(&case.bucket, KEY_PREFIX).await?,
count,
"every source object must be stored locally"
);
case.env
.assert_local_present(&case.bucket, &objects[count - 1].key, &objects[count - 1].body)
.await;
case.finish().await
}
@@ -0,0 +1,362 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Optional merged `ListObjectsV2` (`policy.list_through`, ODM-17,
//! rustfs/backlog#2164): full pagination over a source and a local namespace,
//! common-prefix union under a delimiter, the continuation-token contract, and
//! the two `source_error` behaviours when the source listing fails.
use super::common::{BoxError, OdmSourceSpec, OdmTestEnv, SeedObject, start_configured_env};
use crate::fake_s3_target::{FaultAction, Operation};
use aws_sdk_s3::types::{BucketVersioningStatus, VersioningConfiguration};
use bytes::Bytes;
type TestResult = Result<(), BoxError>;
const SOURCE_BUCKET: &str = "odm-list-source";
const LIST_HEADER: &str = "x-rustfs-on-demand-migration-list";
/// Byte lengths that tell a local object from a source one in a listing.
const SOURCE_BODY_LEN: usize = 3;
const LOCAL_BODY_LEN: usize = 11;
fn body(len: usize) -> Bytes {
vec![b'x'; len].into()
}
/// RustFS migrating `bucket` from `SOURCE_BUCKET` with `list_through` on.
async fn list_through_env(bucket: &str, adjust: impl FnOnce(&mut OdmSourceSpec)) -> Result<OdmTestEnv, BoxError> {
start_configured_env(bucket, SOURCE_BUCKET, |spec| {
spec.policy.list_through = true;
adjust(spec);
})
.await
}
/// Every key the bucket lists, walked through the merged continuation token.
/// Also returns the size each page reported per key and the page sizes, so a
/// caller can assert who won a shared key and that no page exceeded `max_keys`.
async fn walk_listing(
env: &OdmTestEnv,
bucket: &str,
delimiter: Option<&str>,
max_keys: i32,
) -> Result<(Vec<(String, i64)>, Vec<String>, Vec<usize>), BoxError> {
let mut objects = Vec::new();
let mut prefixes = Vec::new();
let mut page_sizes = Vec::new();
let mut token: Option<String> = None;
for _ in 0..1000 {
let page = env
.client
.list_objects_v2()
.bucket(bucket)
.max_keys(max_keys)
.set_delimiter(delimiter.map(str::to_string))
.set_continuation_token(token.take())
.send()
.await?;
let listed = page.contents().len() + page.common_prefixes().len();
page_sizes.push(listed);
for object in page.contents() {
objects.push((object.key().unwrap_or_default().to_string(), object.size().unwrap_or_default()));
}
for prefix in page.common_prefixes() {
prefixes.push(prefix.prefix().unwrap_or_default().to_string());
}
if !page.is_truncated().unwrap_or(false) {
return Ok((objects, prefixes, page_sizes));
}
token = Some(
page.next_continuation_token()
.ok_or("truncated page without a continuation token")?
.to_string(),
);
}
Err("merged listing did not terminate".into())
}
#[tokio::test]
async fn list_through_merges_the_whole_namespace_across_full_pagination() -> TestResult {
let bucket = "odm-list-merge";
let env = list_through_env(bucket, |_| {}).await?;
// 2000 source keys, 80 of them also local, plus 10 local-only keys that
// interleave between source keys ("obj-00010x" sorts after "obj-00010").
let source_keys: Vec<String> = (0..2000).map(|index| format!("obj-{index:05}")).collect();
let seeds: Vec<SeedObject> = source_keys
.iter()
.map(|key| SeedObject::new(key.clone(), body(SOURCE_BODY_LEN)))
.collect();
env.seed_source(SOURCE_BUCKET, &seeds);
let shared: Vec<String> = source_keys.iter().step_by(25).cloned().collect();
let local_only: Vec<String> = (0..10).map(|index| format!("obj-{:05}x", index * 7)).collect();
for key in shared.iter().chain(local_only.iter()) {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
}
let max_keys = 97;
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, None, max_keys).await?;
assert!(prefixes.is_empty(), "no delimiter means no common prefixes");
let mut expected: Vec<String> = source_keys.iter().chain(local_only.iter()).cloned().collect();
expected.sort();
expected.dedup();
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed, expected, "the merged listing is the sorted, deduplicated union");
assert!(
page_sizes.iter().all(|size| *size <= max_keys as usize),
"no page may exceed max_keys: {page_sizes:?}"
);
let shared_sizes: Vec<i64> = objects
.iter()
.filter(|(key, _)| shared.contains(key))
.map(|(_, size)| *size)
.collect();
assert_eq!(shared_sizes.len(), shared.len(), "every shared key is listed exactly once");
assert!(
shared_sizes.iter().all(|size| *size == LOCAL_BODY_LEN as i64),
"the local object wins a key both sides hold"
);
let source_sizes: Vec<i64> = objects
.iter()
.filter(|(key, _)| !shared.contains(key) && !local_only.contains(key))
.map(|(_, size)| *size)
.collect();
assert!(
source_sizes.iter().all(|size| *size == SOURCE_BODY_LEN as i64),
"source-only keys report the source's own size"
);
Ok(())
}
#[tokio::test]
async fn list_through_unions_common_prefixes_under_a_delimiter() -> TestResult {
let bucket = "odm-list-delimiter";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("p1/a", body(SOURCE_BODY_LEN)),
SeedObject::new("p1/b", body(SOURCE_BODY_LEN)),
SeedObject::new("p2/a", body(SOURCE_BODY_LEN)),
SeedObject::new("top-s", body(SOURCE_BODY_LEN)),
],
);
for key in ["p1/c", "p3/a", "top-l"] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
}
// A page size of two forces the prefix union to survive page boundaries.
let (objects, prefixes, page_sizes) = walk_listing(&env, bucket, Some("/"), 2).await?;
assert_eq!(prefixes, vec!["p1/", "p2/", "p3/"], "prefixes are unioned and deduplicated");
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed, vec!["top-l", "top-s"]);
assert!(page_sizes.iter().all(|size| *size <= 2), "{page_sizes:?}");
Ok(())
}
#[tokio::test]
async fn list_through_propagates_a_source_listing_failure() -> TestResult {
let bucket = "odm-list-propagate";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
env.client
.put_object()
.bucket(bucket)
.key("local")
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
env.source
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
let failure = env
.client
.list_objects_v2()
.bucket(bucket)
.send()
.await
.expect_err("propagate must surface the source failure");
let failure = failure.into_service_error();
assert_eq!(failure.meta().code(), Some("SourceUnavailable"), "{failure:?}");
// The next listing sees a healthy source again and merges both sides.
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(listed, vec!["local", "remote"]);
Ok(())
}
#[tokio::test]
async fn list_through_degrades_to_local_only_under_the_not_found_policy() -> TestResult {
let bucket = "odm-list-degrade";
let env = list_through_env(bucket, |spec| spec.policy.source_error = "not_found".to_string()).await?;
env.seed_source(SOURCE_BUCKET, &[SeedObject::new("remote", body(SOURCE_BODY_LEN))]);
env.client
.put_object()
.bucket(bucket)
.key("local")
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
env.source
.inject(Operation::ListObjectsV2, FaultAction::ResponseStatus(503), 1);
let degraded = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
assert_eq!(degraded.status, 200, "{}", String::from_utf8_lossy(&degraded.body));
assert_eq!(
degraded.header(LIST_HEADER),
Some("local_only"),
"a degraded listing must say so in the response header"
);
let xml = String::from_utf8_lossy(&degraded.body).to_string();
assert!(xml.contains("<Key>local</Key>"), "{xml}");
assert!(!xml.contains("<Key>remote</Key>"), "a degraded listing shows local state only: {xml}");
let healthy = env.raw_list_objects_v2(bucket, "max-keys=100").await?;
assert_eq!(healthy.status, 200);
assert_eq!(healthy.header(LIST_HEADER), None, "a healthy merge carries no degradation marker");
assert!(String::from_utf8_lossy(&healthy.body).contains("<Key>remote</Key>"));
Ok(())
}
#[tokio::test]
async fn list_through_rejects_a_tampered_continuation_token() -> TestResult {
let bucket = "odm-list-token";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("a", body(SOURCE_BODY_LEN)),
SeedObject::new("b", body(SOURCE_BODY_LEN)),
SeedObject::new("c", body(SOURCE_BODY_LEN)),
],
);
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
let token = page.next_continuation_token().ok_or("first page must be truncated")?;
let decoded = String::from_utf8(base64_simd::STANDARD.decode_to_vec(token.as_bytes())?)?;
assert!(decoded.contains("\"t\":\"odm-list\""), "the merged token is an envelope: {decoded}");
let tampered = base64_simd::STANDARD.encode_to_string(decoded.replace("\"v\":1", "\"v\":3").as_bytes());
assert_ne!(tampered, token, "the test must change the token version");
let query = serde_urlencoded::to_string([("continuation-token", tampered.as_str())])?;
let rejected = env.raw_list_objects_v2(bucket, &query).await?;
let error_body = String::from_utf8_lossy(&rejected.body);
assert_eq!(rejected.status, 400, "a bumped token version is a client error: {}", error_body);
assert!(error_body.contains("<Code>InvalidArgument</Code>"), "{error_body}");
Ok(())
}
#[tokio::test]
async fn a_merged_token_keeps_paginating_after_list_through_is_turned_off() -> TestResult {
let bucket = "odm-list-token-off";
let env = list_through_env(bucket, |_| {}).await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("s1", body(SOURCE_BODY_LEN)),
SeedObject::new("s2", body(SOURCE_BODY_LEN)),
],
);
for key in ["l1", "l2"] {
env.client
.put_object()
.bucket(bucket)
.key(key)
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
}
let page = env.client.list_objects_v2().bucket(bucket).max_keys(1).send().await?;
assert_eq!(page.contents()[0].key(), Some("l1"));
let token = page
.next_continuation_token()
.ok_or("first page must be truncated")?
.to_string();
let mut spec = env.fake_source_spec(SOURCE_BUCKET);
spec.policy.list_through = false;
env.configure_and_wait(bucket, &spec).await?;
let resumed = env
.client
.list_objects_v2()
.bucket(bucket)
.max_keys(10)
.continuation_token(token)
.send()
.await?;
let listed: Vec<&str> = resumed.contents().iter().filter_map(|object| object.key()).collect();
assert_eq!(listed, vec!["l2"], "a merged token falls back to its local cursor");
Ok(())
}
#[tokio::test]
async fn a_local_delete_marker_hides_the_source_key_from_a_merged_listing() -> TestResult {
let bucket = "odm-list-delete-marker";
let env = list_through_env(bucket, |_| {}).await?;
env.client
.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
env.seed_source(
SOURCE_BUCKET,
&[
SeedObject::new("kept", body(SOURCE_BODY_LEN)),
SeedObject::new("shadowed", body(SOURCE_BODY_LEN)),
],
);
env.client
.put_object()
.bucket(bucket)
.key("shadowed")
.body(body(LOCAL_BODY_LEN).into())
.send()
.await?;
env.client.delete_object().bucket(bucket).key("shadowed").send().await?;
let (objects, _, _) = walk_listing(&env, bucket, None, 100).await?;
let listed: Vec<String> = objects.iter().map(|(key, _)| key.clone()).collect();
assert_eq!(
listed,
vec!["kept"],
"a local delete marker shadows the source key the same way it does on GET"
);
Ok(())
}
@@ -0,0 +1,40 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! On-demand migration (ODM) end-to-end suite (rustfs/backlog#2147).
//!
//! `common` is the shared environment: one RustFS under test, one programmable
//! fake S3 source, admin-API wrappers, seeding and local-state assertions.
//! `harness_self_test` proves the harness itself; `get_basic_test` covers the
//! GET read-through (rustfs/backlog#2156) and `backfill_test` the background
//! backfill job (ODM-12, rustfs/backlog#2159); `list_through_test` covers the
//! optional merged `ListObjectsV2` (ODM-17, rustfs/backlog#2164). The fault, concurrency,
//! interaction and real-source matrix is rustfs/backlog#2158; its lane split
//! lives in `.config/nextest.toml` (fault / concurrency / real source run
//! nightly, the rest in the merge lane). `interop_test` is the provider
//! interoperability lane (ODM-20, rustfs/backlog#2167): the same case bodies
//! against the fake source locally and against a real provider named by the
//! environment in `.github/workflows/on-demand-migration-interop.yml`.
pub mod common;
mod backfill_test;
mod concurrency_test;
mod fault_test;
mod get_basic_test;
mod harness_self_test;
mod interaction_test;
mod interop_test;
mod list_through_test;
mod real_source_test;
@@ -0,0 +1,266 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! On-demand migration against a real RustFS source (rustfs/backlog#2158).
//!
//! These cases start a second (and, for the loop guard, a third) RustFS
//! process, so they carry the `_real_single_node` marker and run in the
//! nightly lane. A real source keeps no request journal, so "the source was
//! not consulted" is proven by removing the object from the source and
//! showing the read still succeeds, or by pointing the *second* server at a
//! fake source whose journal must stay empty.
use super::common::{
AdminResponse, BoxError, ODM_ADMIN_ROUTE, OdmSourceSpec, OdmTestEnv, RawResponse, SeedObject, start_source_rustfs,
start_source_rustfs_with_odm,
};
use crate::common::{RustFSTestEnvironment, signed_request};
use crate::fake_s3_target::{BucketMode, Operation};
use aws_sdk_s3::Client;
use bytes::Bytes;
use std::time::{Duration, Instant};
type TestResult = Result<(), BoxError>;
const ODM_RESPONSE_HEADER: &str = "x-rustfs-on-demand-migration";
/// Reinstalled configurations are applied asynchronously; every phase polls
/// for the new behavior instead of sleeping.
const APPLY_TIMEOUT: Duration = Duration::from_secs(30);
const SETTLE: Duration = Duration::from_secs(60);
fn payload(len: usize) -> Bytes {
(0..len).map(|index| (index % 251) as u8).collect::<Vec<u8>>().into()
}
/// `PUT /rustfs/admin/v3/on-demand-migration/{bucket}` against any server,
/// not just the one under test.
async fn configure_odm(env: &RustFSTestEnvironment, bucket: &str, spec: &OdmSourceSpec) -> Result<AdminResponse, BoxError> {
let url = format!("{}{ODM_ADMIN_ROUTE}/{bucket}", env.url);
let body = serde_json::to_vec(&spec.to_json())?;
let response = signed_request(
http::Method::PUT,
&url,
&env.access_key,
&env.secret_key,
Some(body),
Some("application/json"),
)
.await?;
Ok(AdminResponse {
status: response.status().as_u16(),
body: response.text().await?,
})
}
async fn put_object(client: &Client, bucket: &str, key: &str, body: Bytes) -> TestResult {
client
.put_object()
.bucket(bucket)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(body))
.send()
.await?;
Ok(())
}
/// Polls a read against the server under test until it answers `expected`.
/// This is how a reinstalled configuration is waited for when the source
/// keeps no journal to probe.
async fn wait_for_get_status(env: &OdmTestEnv, bucket: &str, key: &str, expected: u16) -> Result<RawResponse, BoxError> {
let deadline = Instant::now() + APPLY_TIMEOUT;
loop {
let response = env.raw_get(bucket, key).await?;
if response.status == expected {
return Ok(response);
}
if Instant::now() >= deadline {
return Err(format!(
"GET {bucket}/{key} stayed at {} instead of {expected}: {}",
response.status,
String::from_utf8_lossy(&response.body)
)
.into());
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
}
/// Case 20: a second RustFS as the migration source — the pull, a HEAD
/// passthrough, a Range read, and both prefix knobs.
#[tokio::test]
async fn test_odm_rustfs_source_serves_pull_head_range_and_prefixes_real_single_node() -> TestResult {
let bucket = "odm-real-source";
let source_bucket = "odm-real-origin";
let source = start_source_rustfs().await?;
let source_client = source.create_s3_client();
source.create_test_bucket(source_bucket).await?;
let env = OdmTestEnv::start().await?;
env.rustfs.create_test_bucket(bucket).await?;
let spec = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
let configured = configure_odm(&env.rustfs, bucket, &spec).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
// Phase 1: a miss is pulled and stored; removing it from the source
// afterwards proves the second read never goes back to the source.
let pulled_key = "real/pulled.bin";
let pulled_body = payload(256 * 1024);
put_object(&source_client, source_bucket, pulled_key, pulled_body.clone()).await?;
let pulled = wait_for_get_status(&env, bucket, pulled_key, 200).await?;
assert_eq!(pulled.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(pulled.body, pulled_body, "the client receives the source bytes");
assert!(env.wait_local_listed(bucket, pulled_key, SETTLE).await?, "the pull must store the object");
source_client
.delete_object()
.bucket(source_bucket)
.key(pulled_key)
.send()
.await?;
let local = env.raw_get(bucket, pulled_key).await?;
assert_eq!(local.status, 200, "{}", String::from_utf8_lossy(&local.body));
assert_eq!(local.header(ODM_RESPONSE_HEADER), None, "a local hit is not marked");
assert_eq!(local.body, pulled_body, "the object is served from the local copy");
// Phase 2: HEAD proxies metadata without storing anything.
let head_key = "real/head-only.bin";
let head_body = payload(9_000);
put_object(&source_client, source_bucket, head_key, head_body.clone()).await?;
let head = env.raw_object_request(http::Method::HEAD, bucket, head_key, &[]).await?;
assert_eq!(head.status, 200, "HEAD must be answered from the source");
assert_eq!(head.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(head.header("content-length"), Some(head_body.len().to_string().as_str()));
env.assert_local_absent(bucket, head_key).await;
// Phase 3: a Range read is passed through as a 206.
let range_key = "real/range.bin";
let range_body = payload(100_000);
put_object(&source_client, source_bucket, range_key, range_body.clone()).await?;
let ranged = env
.raw_object_request(http::Method::GET, bucket, range_key, &[("range", "bytes=100-199")])
.await?;
assert_eq!(ranged.status, 206, "{}", String::from_utf8_lossy(&ranged.body));
assert_eq!(ranged.header("content-range"), Some("bytes 100-199/100000"));
assert_eq!(ranged.body, range_body.slice(100..200));
// Phase 4: `filter.prefix` decides which local keys may consult the
// source at all.
let allowed_key = "allowed/doc.bin";
let denied_key = "denied/doc.bin";
let filtered_body = payload(4_096);
put_object(&source_client, source_bucket, allowed_key, filtered_body.clone()).await?;
put_object(&source_client, source_bucket, denied_key, filtered_body.clone()).await?;
let mut filtered = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
filtered.filter.prefix = Some("allowed/".to_string());
let response = configure_odm(&env.rustfs, bucket, &filtered).await?;
assert_eq!(response.status, 200, "{}", response.body);
let denied = wait_for_get_status(&env, bucket, denied_key, 404).await?;
assert_eq!(denied.header(ODM_RESPONSE_HEADER), None);
env.assert_local_absent(bucket, denied_key).await;
let allowed = wait_for_get_status(&env, bucket, allowed_key, 200).await?;
assert_eq!(allowed.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(allowed.body, filtered_body);
// Phase 5: `filter.source_prefix` rewrites the key on the way out, so a
// local key resolves to a different key in the source bucket.
let rewritten_key = "rewritten/doc.bin";
let rewritten_body = payload(2_048);
put_object(&source_client, source_bucket, &format!("archive/{rewritten_key}"), rewritten_body.clone()).await?;
let mut rewriting = OdmSourceSpec::for_rustfs_source(&source, source_bucket);
rewriting.filter.source_prefix = Some("archive/".to_string());
let response = configure_odm(&env.rustfs, bucket, &rewriting).await?;
assert_eq!(response.status, 200, "{}", response.body);
let rewritten = wait_for_get_status(&env, bucket, rewritten_key, 200).await?;
assert_eq!(rewritten.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(rewritten.body, rewritten_body, "the source prefix is prepended to the local key");
Ok(())
}
/// Case 21: two migrating servers pointed at each other must not build a
/// request loop. The middle server also has a fake source of its own, whose
/// journal is the evidence: a key it would happily fetch for a direct client
/// is never fetched for a request that arrived with the anti-loop marker.
#[tokio::test]
async fn test_odm_chained_sources_stop_at_the_loop_guard_real_single_node() -> TestResult {
let bucket = "odm-loop-guard";
let fake_bucket = "odm-loop-fake";
let env = OdmTestEnv::start().await?;
env.source.create_bucket_with_mode(fake_bucket, BucketMode::Unversioned);
env.rustfs.create_test_bucket(bucket).await?;
let middle = start_source_rustfs_with_odm().await?;
let middle_client = middle.create_s3_client();
middle.create_test_bucket(bucket).await?;
let middle_spec = OdmSourceSpec::for_fake_source(&env.source, fake_bucket);
let configured = configure_odm(&middle, bucket, &middle_spec).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
let chained = OdmSourceSpec::for_rustfs_source(&middle, bucket);
let configured = configure_odm(&env.rustfs, bucket, &chained).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
// The first hop works: an object that only the middle server holds is
// migrated to the server under test.
let present_key = "loop/present.bin";
let present_body = payload(16 * 1024);
put_object(&middle_client, bucket, present_key, present_body.clone()).await?;
let served = wait_for_get_status(&env, bucket, present_key, 200).await?;
assert_eq!(served.header(ODM_RESPONSE_HEADER), Some("source"));
assert_eq!(served.body, present_body, "the first hop serves the middle server's object");
// The second hop does not: this key exists only on the middle server's
// own source, and the anti-loop marker stops the chain there.
let guarded_key = "loop/chain-guard.bin";
let guarded_body = payload(8 * 1024);
env.seed_source(fake_bucket, &[SeedObject::new(guarded_key, guarded_body.clone())]);
let guarded = env.raw_get(bucket, guarded_key).await?;
assert_eq!(guarded.status, 404, "{}", String::from_utf8_lossy(&guarded.body));
assert_eq!(
env.source.count_requests(Operation::HeadObject, guarded_key),
0,
"a chained request must not reach a third source"
);
assert_eq!(env.source.count_requests(Operation::GetObject, guarded_key), 0);
// Proof that the guard, and not a broken configuration, is what stopped
// it: the same key served directly by the middle server does reach the
// fake source.
let direct = middle_client.get_object().bucket(bucket).key(guarded_key).send().await?;
assert_eq!(direct.body.collect().await?.into_bytes(), guarded_body);
assert_eq!(
env.source.count_requests(Operation::GetObject, guarded_key),
1,
"an unmarked request does consult the middle server's source"
);
// Now make the pair mutual and prove the read still terminates.
let mutual = OdmSourceSpec::for_rustfs_source(&env.rustfs, bucket);
let configured = configure_odm(&middle, bucket, &mutual).await?;
assert_eq!(configured.status, 200, "{}", configured.body);
let mutual_key = "loop/mutual.bin";
let started = Instant::now();
let response = wait_for_get_status(&env, bucket, mutual_key, 404).await?;
assert_eq!(response.header(ODM_RESPONSE_HEADER), None);
assert!(
started.elapsed() < Duration::from_secs(10),
"a mutual configuration must not loop, took {:?}",
started.elapsed()
);
assert_eq!(
env.source.count_requests(Operation::HeadObject, mutual_key),
0,
"the fake source is out of the chain once the pair is mutual"
);
Ok(())
}
@@ -856,6 +856,13 @@ impl NodeService for MinimalLockNodeService {
Err(Status::unimplemented("lock-only test server"))
}
async fn scanner_dirty_usage_snapshot(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::ScannerDirtyUsageSnapshotResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn background_heal_status(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::BackgroundHealStatusRequest>,
@@ -904,6 +911,13 @@ impl NodeService for MinimalLockNodeService {
) -> Result<Response<rustfs_protos::proto_gen::node_service::LoadTransitionTierConfigResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
async fn tier_daily_stats(
&self,
_request: Request<rustfs_protos::proto_gen::node_service::TierDailyStatsRequest>,
) -> Result<Response<rustfs_protos::proto_gen::node_service::TierDailyStatsResponse>, Status> {
Err(Status::unimplemented("lock-only test server"))
}
}
/// Spawn a gRPC lock server on a random port
@@ -35,19 +35,22 @@ mod tests {
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tokio::time::{Duration, Instant, interval};
use tokio::net::TcpStream;
use tokio::time::{Duration, Instant, interval, sleep, timeout};
use tracing::info;
const ENABLE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E";
const NAMESPACE_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_E2E_IN_NAMESPACE";
const LOG_DIR_ENV: &str = "RUSTFS_PRIVILEGED_REPLACEMENT_LOG_DIR";
const TARGET_NODE: usize = 1;
const TARGET_DRIVE: usize = 0;
const MOUNT_SIZE: &str = "size=128m,mode=0700";
const ABSENT_SCANNER_OBSERVATION_TIMEOUT_SECS: u64 = 180;
const REPLACEMENT_RECOVERY_DIR: &str = ".rustfs.sys/buckets/ahm-replacement";
const REPLACEMENT_INTENT_SUFFIX: &str = "_ahm_replacement_intent.json";
const REPLACEMENT_COMPLETION_PROOF_SUFFIX: &str = "_ahm_replacement_completion_proof.json";
const RESUME_CHECKPOINT_SUFFIX: &str = "_ahm_checkpoint.json";
const FAULT_WINDOW_OBJECT_COUNT: usize = 24;
const FAULT_WINDOW_OBJECT_BYTES: usize = 32 * 1024 * 1024;
#[derive(Debug)]
struct BaselineVersion {
@@ -65,6 +68,12 @@ mod tests {
CompletedWithIncomplete(BTreeSet<String>),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ReplacementScenario {
Baseline,
MidRebuildIoFault,
}
struct MountNamespaceGuard {
mounts: Vec<PathBuf>,
}
@@ -102,6 +111,19 @@ mod tests {
impl FaultableBlockMount {
fn mount(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
Self::mount_with_live_recovery(target, image_root, label, false)
}
fn mount_live_recovery(target: &Path, image_root: &Path, label: &str) -> Result<Self, Box<dyn Error + Send + Sync>> {
Self::mount_with_live_recovery(target, image_root, label, true)
}
fn mount_with_live_recovery(
target: &Path,
image_root: &Path,
label: &str,
live_recovery: bool,
) -> Result<Self, Box<dyn Error + Send + Sync>> {
fs::create_dir_all(image_root)?;
let image = image_root.join(format!("{label}.img"));
let file = fs::File::create(&image)?;
@@ -114,7 +136,15 @@ mod tests {
return Err("losetup --find --show returned an empty loop device".into());
}
run_command("mkfs.ext4", &["-F", &loop_device])?;
if live_recovery {
// Keep the filesystem and RustFS' persistent root descriptor attached
// across the transient all-block EIO. A journaling ext4 abort requires
// an unmount to recover, which would test process/disk reattachment
// instead of live I/O recovery.
run_command("mkfs.ext4", &["-F", "-O", "^has_journal", &loop_device])?;
} else {
run_command("mkfs.ext4", &["-F", &loop_device])?;
}
let sectors = run_command_stdout("blockdev", &["--getsz", &loop_device])?;
let dm_name = format!("rustfs_e2e_{label}_{}", std::process::id());
let table = format!("0 {sectors} linear {loop_device} 0");
@@ -122,7 +152,11 @@ mod tests {
run_command("dmsetup", &["create", &dm_name, "--table", &table])?;
let target_arg = path_to_string(target, "faultable mount target")?;
run_command("mount", &[&mapper, &target_arg])?;
if live_recovery {
run_command("mount", &["-o", "errors=continue", &mapper, &target_arg])?;
} else {
run_command("mount", &[&mapper, &target_arg])?;
}
Ok(Self {
target: target.to_path_buf(),
@@ -142,18 +176,42 @@ mod tests {
run_command("dmsetup", &["resume", &self.dm_name])
}
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
fn verify_raw_io_is_unavailable(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let mapper = format!("/dev/mapper/{}", self.dm_name);
let output = Command::new("dd")
.env("LC_ALL", "C")
.arg(format!("if={mapper}"))
.args(["of=/dev/null", "bs=4096", "count=1", "iflag=direct", "status=none"])
.output()?;
if output.status.success() {
return Err(format!("dm-error target unexpectedly allowed a raw read from {mapper}").into());
}
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("Input/output error") {
return Err(format!("raw read from dm-error target failed unexpectedly: {stderr}").into());
}
Ok(())
}
fn restore_linear_table(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
let sectors = run_command_stdout("blockdev", &["--getsz", &self.loop_device])?;
let linear_table = format!("0 {sectors} linear {} 0", self.loop_device);
run_command("dmsetup", &["suspend", &self.dm_name])?;
// An ext4 journal abort can leave the mounted filesystem internally
// read-only. Avoid dmsetup's filesystem freeze/flush in that state;
// all I/O sent to the error target has already completed with EIO.
run_command("dmsetup", &["suspend", "--noflush", &self.dm_name])?;
run_command("dmsetup", &["load", &self.dm_name, "--table", &linear_table])?;
run_command("dmsetup", &["resume", &self.dm_name])
}
fn restore_available(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
self.restore_linear_table()
}
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut first_error: Option<Box<dyn Error + Send + Sync>> = None;
if self.dm_created {
let _ = self.restore_available();
let _ = self.restore_linear_table();
}
if self.mounted {
if let Err(error) = detach_mount(&self.target) {
@@ -194,6 +252,72 @@ mod tests {
}
}
struct ZramBlockMount {
target: PathBuf,
device: String,
mounted: bool,
}
impl ZramBlockMount {
fn reserve(target: &Path) -> Result<Self, Box<dyn Error + Send + Sync>> {
if !Path::new("/dev/zram-control").exists() {
run_command("modprobe", &["zram"])?;
}
let device = run_command_stdout("zramctl", &["--find", "--size", "256M"])?;
if device.is_empty() {
return Err("zramctl --find --size returned an empty device".into());
}
Ok(Self {
target: target.to_path_buf(),
device,
mounted: false,
})
}
fn mount_target(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let result = (|| {
run_command("mkfs.ext4", &["-F", &self.device])?;
let target_arg = path_to_string(&self.target, "zram replacement mount target")?;
run_command("mount", &[&self.device, &target_arg])
})();
if let Err(error) = result {
let _ = self.cleanup();
return Err(error);
}
self.mounted = true;
Ok(())
}
fn cleanup(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let mut first_error: Option<Box<dyn Error + Send + Sync>> = None;
if self.mounted {
if let Err(error) = detach_mount(&self.target) {
first_error.get_or_insert(error);
} else {
self.mounted = false;
}
}
if !self.device.is_empty() {
if let Err(error) = run_command("zramctl", &["--reset", &self.device]) {
first_error.get_or_insert(error);
} else {
self.device.clear();
}
}
if let Some(error) = first_error {
return Err(error);
}
Ok(())
}
}
impl Drop for ZramBlockMount {
fn drop(&mut self) {
let _ = self.cleanup();
}
}
fn checked_command_output(program: &str, args: &[&str]) -> Result<std::process::Output, Box<dyn Error + Send + Sync>> {
let output = Command::new(program).args(args).output()?;
if output.status.success() {
@@ -298,6 +422,18 @@ mod tests {
Err(format!("{ENABLE_ENV}=1 requires root or CAP_SYS_ADMIN; unshare exited with status {status}").into())
}
fn replacement_node_log_path(
cluster_temp_dir: &str,
parity: usize,
node_index: usize,
) -> Result<PathBuf, Box<dyn Error + Send + Sync>> {
let log_dir = std::env::var_os(LOG_DIR_ENV)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(cluster_temp_dir));
fs::create_dir_all(&log_dir)?;
Ok(log_dir.join(format!("replacement-ec{parity}-node{node_index}-{}.log", std::process::id())))
}
fn payload(len: usize, seed: u8) -> Vec<u8> {
let mut next = seed;
(0..len)
@@ -374,7 +510,11 @@ mod tests {
Ok((completed.version_id().map(str::to_owned), digest))
}
async fn seed_baseline(client: &Client, target_disk: &Path) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
async fn seed_baseline(
client: &Client,
target_disk: &Path,
extra_object_count: usize,
) -> Result<Vec<BaselineVersion>, Box<dyn Error + Send + Sync>> {
let plain_bucket = "priv-replacement-plain";
let versioned_bucket = "priv-replacement-versions";
let null_bucket = "priv-replacement-null";
@@ -437,7 +577,7 @@ mod tests {
put_object_version(client, null_bucket, "null/current.bin", payload(512 * 1024, 8)).await?;
versions.push((null_bucket, "null/current.bin", version_id, Some(body_sha256)));
let versions = versions
let mut versions = versions
.into_iter()
.map(|(bucket, key, version_id, body_sha256)| {
let expected = census_object_version_on_disk(target_disk, bucket, key, version_id.as_deref())?;
@@ -453,6 +593,23 @@ mod tests {
})
})
.collect::<Result<Vec<_>, Box<dyn Error + Send + Sync>>>()?;
for index in 0..extra_object_count {
let key = format!("fault-window/object-{index:04}.bin");
let seed = u8::try_from(index + 32)?;
let (version_id, body_sha256) =
put_object_version(client, plain_bucket, &key, payload(FAULT_WINDOW_OBJECT_BYTES, seed)).await?;
let expected = census_object_version_on_disk(target_disk, plain_bucket, &key, version_id.as_deref())?;
if !expected.is_complete() {
return Err(format!("fault-window baseline census is incomplete for {plain_bucket}/{key}: {expected:?}").into());
}
versions.push(BaselineVersion {
bucket: plain_bucket.to_string(),
key,
version_id,
body_sha256: Some(body_sha256),
expected,
});
}
let inline = versions
.iter()
.find(|version| version.key == "history/inline.bin")
@@ -472,8 +629,20 @@ mod tests {
if let Some(version_id) = &version.version_id {
request = request.version_id(version_id);
}
let response = request.send().await?;
let body = response.body.collect().await?.into_bytes();
let response = request.send().await.map_err(|error| {
format!("body GET failed for {}/{}@{:?}: {error}", version.bucket, version.key, version.version_id)
})?;
let body = response
.body
.collect()
.await
.map_err(|error| {
format!(
"body stream failed for {}/{}@{:?}: {error}",
version.bucket, version.key, version.version_id
)
})?
.into_bytes();
assert_eq!(
sha256_hex(&body),
*expected_sha256,
@@ -582,81 +751,6 @@ mod tests {
Ok(())
}
fn log_tail(log: &str) -> String {
let mut lines = log.lines().rev().take(80).collect::<Vec<_>>();
lines.reverse();
lines.join("\n")
}
fn log_len(path: &Path) -> Result<u64, Box<dyn Error + Send + Sync>> {
match fs::metadata(path) {
Ok(metadata) => Ok(metadata.len()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(0),
Err(error) => Err(format!("failed to stat target node log {path:?}: {error}").into()),
}
}
fn log_from_offset(path: &Path, offset: u64) -> Result<String, Box<dyn Error + Send + Sync>> {
let log = match fs::read(path) {
Ok(log) => log,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(error) => return Err(format!("failed to read target node log {path:?}: {error}").into()),
};
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(log.len());
Ok(String::from_utf8_lossy(&log[start..]).into_owned())
}
fn live_disk_loss_scan_completed(log: &str, target_disk: &Path) -> bool {
let target = target_disk.to_string_lossy();
let mut saw_live_loss = false;
for line in log.lines() {
if line.contains("Heal auto-scan disk inspection failed")
&& line.contains("check_failed")
&& line.contains(target.as_ref())
{
saw_live_loss = true;
continue;
}
if saw_live_loss && (line.contains("Heal auto disk scanner idle") || line.contains("Heal auto-scan cycle completed"))
{
return true;
}
}
false
}
fn live_disk_loss_scan_completed_from_path(
log_path: &Path,
start_offset: u64,
target_disk: &Path,
) -> Result<bool, Box<dyn Error + Send + Sync>> {
Ok(live_disk_loss_scan_completed(&log_from_offset(log_path, start_offset)?, target_disk))
}
async fn wait_for_live_disk_loss_observation(
log_path: &Path,
target_disk: &Path,
start_offset: u64,
timeout_secs: u64,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
let mut tick = interval(Duration::from_secs(1));
loop {
if live_disk_loss_scan_completed_from_path(log_path, start_offset, target_disk)? {
return Ok(());
}
if Instant::now() >= deadline {
let log = log_from_offset(log_path, start_offset)?;
return Err(format!(
"scanner did not finish a live target-loss scan for {target_disk:?} within {timeout_secs}s; log tail:\n{}",
log_tail(&log)
)
.into());
}
tick.tick().await;
}
}
fn cluster_status_is_definitive(status: &serde_json::Value) -> Result<bool, Box<dyn Error + Send + Sync>> {
status["cluster"]["definitive"]
.as_bool()
@@ -707,6 +801,105 @@ mod tests {
.collect()
}
fn target_record_details(
status: &serde_json::Value,
target_disk: &Path,
) -> Result<Vec<(String, String)>, Box<dyn Error + Send + Sync>> {
let target = target_disk.to_string_lossy();
let records = status["cluster"]["records"]
.as_array()
.ok_or_else(|| format!("replacement recovery status omitted cluster.records: {status}"))?;
records
.iter()
.filter(|record| {
record["targetSlots"]
.as_array()
.into_iter()
.flatten()
.filter_map(serde_json::Value::as_str)
.any(|slot| slot.contains(target.as_ref()))
})
.map(|record| {
let task_id = record["taskId"]
.as_str()
.filter(|task_id| !task_id.is_empty())
.ok_or_else(|| format!("replacement recovery record omitted taskId: {record}"))?;
let state = record["state"]
.as_str()
.filter(|state| !state.is_empty())
.ok_or_else(|| format!("replacement recovery record omitted state: {record}"))?;
Ok((task_id.to_string(), state.to_string()))
})
.collect()
}
fn running_target_generation(
status: &serde_json::Value,
target_disk: &Path,
) -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
if !cluster_status_is_definitive(status)? {
return Ok(None);
}
let records = target_record_details(status, target_disk)?;
if records.len() == 1 && records[0].1 == "running" {
return Ok(Some(records[0].0.clone()));
}
Ok(None)
}
fn assert_target_generation_nonterminal(
status: &serde_json::Value,
target_disk: &Path,
expected_task_id: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
if !cluster_status_is_definitive(status)? {
return Err(format!("replacement recovery became non-definitive during target EIO: {status}").into());
}
let records = target_record_details(status, target_disk)?;
let matching = records
.iter()
.filter(|(task_id, _)| task_id == expected_task_id)
.collect::<Vec<_>>();
if matching.len() != 1 {
return Err(format!(
"replacement generation {expected_task_id} must remain uniquely observable during target EIO: {records:?}"
)
.into());
}
match matching[0].1.as_str() {
"waiting_for_replacement" | "running" | "incomplete" => Ok(()),
state => Err(format!(
"replacement generation {expected_task_id} reached invalid state {state:?} during target EIO: {status}"
)
.into()),
}
}
fn assert_target_generation_completed(
status: &serde_json::Value,
target_disk: &Path,
expected_task_id: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
if !cluster_status_is_definitive(status)? {
return Err(format!("completed replacement recovery status is non-definitive: {status}").into());
}
let records = target_record_details(status, target_disk)?;
if records == [(expected_task_id.to_string(), "completed".to_string())] {
return Ok(());
}
Err(
format!("replacement generation {expected_task_id} did not retain its identity through EIO recovery: {records:?}")
.into(),
)
}
fn is_transient_recovery_version_absence(error: &(dyn Error + 'static)) -> bool {
matches!(
error.downcast_ref::<rustfs_filemeta::Error>(),
Some(rustfs_filemeta::Error::FileVersionNotFound)
)
}
fn incomplete_versions(
target_disk: &Path,
versions: &[BaselineVersion],
@@ -714,7 +907,21 @@ mod tests {
let mut missing = BTreeSet::new();
for version in versions {
let actual =
census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref())?;
match census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref()) {
Ok(actual) => actual,
// During replacement recovery, xl.meta may arrive before this
// particular historical version. The generic census helper
// correctly reports that as an error; this progress poll must
// instead wait for the version to be restored.
Err(error) if is_transient_recovery_version_absence(error.as_ref()) => {
missing.insert(format!(
"{}/{}@{:?}: version metadata not yet present on replacement",
version.bucket, version.key, version.version_id
));
continue;
}
Err(error) => return Err(error),
};
if !actual.matches_manifest(&version.expected) {
missing.insert(format!("{}/{}@{:?}: {actual:?}", version.bucket, version.key, version.version_id));
}
@@ -722,6 +929,165 @@ mod tests {
Ok(missing)
}
async fn wait_for_partial_replacement<'a>(
cluster: &RustFSTestClusterEnvironment,
target_disk: &Path,
versions: &'a [BaselineVersion],
timeout_secs: u64,
) -> Result<(usize, &'a BaselineVersion, String), Box<dyn Error + Send + Sync>> {
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
loop {
let missing = incomplete_versions(target_disk, versions)?;
let completed = versions.len().saturating_sub(missing.len());
if completed > 0 && completed < versions.len() {
let witness = versions.iter().find(|version| {
census_object_version_on_disk(target_disk, &version.bucket, &version.key, version.version_id.as_deref())
.is_ok_and(|actual| actual.matches_manifest(&version.expected))
});
if let Some(witness) = witness {
let status = replacement_status(cluster).await?;
if let Some(task_id) = running_target_generation(&status, target_disk)? {
return Ok((completed, witness, task_id));
}
}
}
if completed == versions.len() {
return Err(format!(
"replacement rebuilt all {} baseline versions before target EIO could be injected",
versions.len()
)
.into());
}
if Instant::now() >= deadline {
let status = replacement_status(cluster).await?;
return Err(format!(
"replacement made no observable running partial progress within {timeout_secs}s: completed={completed}/{} status={status}",
versions.len()
)
.into());
}
sleep(Duration::from_millis(10)).await;
}
}
fn cluster_process_ids(cluster: &RustFSTestClusterEnvironment) -> Result<Vec<u32>, Box<dyn Error + Send + Sync>> {
cluster
.nodes
.iter()
.enumerate()
.map(|(index, node)| {
node.process
.as_ref()
.map(std::process::Child::id)
.ok_or_else(|| format!("cluster node {index} process is not running").into())
})
.collect()
}
async fn assert_cluster_processes_and_listeners_unchanged(
cluster: &mut RustFSTestClusterEnvironment,
expected_pids: &[u32],
) -> Result<(), Box<dyn Error + Send + Sync>> {
if cluster.nodes.len() != expected_pids.len() {
return Err("cluster node count changed during target EIO".into());
}
for (index, (node, expected_pid)) in cluster.nodes.iter_mut().zip(expected_pids).enumerate() {
let process = node
.process
.as_mut()
.ok_or_else(|| format!("cluster node {index} process disappeared during target EIO"))?;
if process.id() != *expected_pid {
return Err(format!(
"cluster node {index} PID changed during target EIO: expected {expected_pid}, got {}",
process.id()
)
.into());
}
if let Some(status) = process.try_wait()? {
return Err(format!("cluster node {index} exited during target EIO with {status}").into());
}
match timeout(Duration::from_secs(2), TcpStream::connect(&node.address)).await {
Ok(Ok(stream)) => drop(stream),
Ok(Err(error)) => {
return Err(format!("cluster node {index} TCP listener failed during target EIO: {error}").into());
}
Err(_) => return Err(format!("cluster node {index} TCP listener timed out during target EIO").into()),
}
}
Ok(())
}
async fn exercise_mid_rebuild_io_fault(
cluster: &mut RustFSTestClusterEnvironment,
replacement_mount: &FaultableBlockMount,
target_disk: &Path,
versions: &[BaselineVersion],
) -> Result<String, Box<dyn Error + Send + Sync>> {
let partial_timeout_secs = std::env::var("RUSTFS_HEAL_DISK_IO_PARTIAL_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(120);
let (partial_count, witness, task_id) =
wait_for_partial_replacement(cluster, target_disk, versions, partial_timeout_secs).await?;
let expected_pids = cluster_process_ids(cluster)?;
replacement_mount
.make_unavailable()
.map_err(|error| format!("failed to install dm-error on the active replacement: {error}"))?;
let fault_result = async {
replacement_mount
.verify_raw_io_is_unavailable()
.map_err(|error| format!("active replacement dm-error was not proven by direct I/O: {error}"))?;
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
let observation_deadline = Instant::now() + Duration::from_secs(2);
loop {
let status = timeout(Duration::from_secs(5), replacement_status(cluster))
.await
.map_err(|_| "replacement recovery status timed out during target EIO")??;
assert_target_generation_nonterminal(&status, target_disk, &task_id)?;
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
if Instant::now() >= observation_deadline {
break;
}
sleep(Duration::from_millis(100)).await;
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
}
.await;
let restore_result = replacement_mount
.restore_available()
.map_err(|error| format!("failed to restore the active replacement after dm-error: {error}"));
if let Err(error) = fault_result {
if let Err(restore_error) = restore_result {
info!(%restore_error, "replacement restore also failed while preserving target EIO failure");
}
return Err(error);
}
restore_result?;
assert_cluster_processes_and_listeners_unchanged(cluster, &expected_pids).await?;
let actual = census_object_version_on_disk(target_disk, &witness.bucket, &witness.key, witness.version_id.as_deref())?;
if !actual.matches_manifest(&witness.expected) {
return Err(format!(
"witnessed replacement shard did not survive target EIO for {}/{}@{:?}: {actual:?}",
witness.bucket, witness.key, witness.version_id
)
.into());
}
let completed_after_restore = versions
.len()
.saturating_sub(incomplete_versions(target_disk, versions)?.len());
if completed_after_restore < partial_count {
return Err(format!(
"replacement progress regressed across target EIO: before={partial_count}, after={completed_after_restore}"
)
.into());
}
Ok(task_id)
}
fn replacement_completion_state(
status: &serde_json::Value,
target_disk: &Path,
@@ -810,7 +1176,11 @@ mod tests {
}
}
async fn run_replacement_e2e(parity: usize, test_name: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
async fn run_replacement_e2e(
parity: usize,
test_name: &str,
scenario: ReplacementScenario,
) -> Result<(), Box<dyn Error + Send + Sync>> {
init_logging();
if !privileged_run_enabled()? {
return Ok(());
@@ -822,13 +1192,15 @@ mod tests {
let mut mount_ns = MountNamespaceGuard::new()?;
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(3, 4)).await?;
let target_log_path = PathBuf::from(&cluster.temp_dir).join(format!("replacement-node{TARGET_NODE}.log"));
cluster.set_node_capture_log_path(TARGET_NODE, target_log_path.to_string_lossy())?;
for node_index in 0..cluster.nodes.len() {
let node_log_path = replacement_node_log_path(&cluster.temp_dir, parity, node_index)?;
cluster.set_node_capture_log_path(node_index, node_log_path.to_string_lossy())?;
}
let target_disk = PathBuf::from(&cluster.nodes[TARGET_NODE].data_dirs[TARGET_DRIVE]);
// Each drive below is an independent tmpfs mount, so this privileged
// path must exercise the production distinct-device/readiness fences.
// The blank target uses a temporary zram block device, so the
// replacement readiness fence sees no root or sibling alias.
cluster.extra_env.retain(|(key, _)| key != "RUSTFS_UNSAFE_BYPASS_DISK_CHECK");
let image_root = PathBuf::from(&cluster.temp_dir).join("replacement-faultable-images");
let image_root = PathBuf::from(&cluster.temp_dir).join("replacement-block-images");
let mut target_mount = None;
for (node_index, node) in cluster.nodes.iter().enumerate() {
for (drive_index, drive) in node.data_dirs.iter().enumerate() {
@@ -845,6 +1217,10 @@ mod tests {
}
}
let mut target_mount = target_mount.ok_or("target drive was not mounted with the faultable block fixture")?;
let mut zram_replacement = match scenario {
ReplacementScenario::Baseline => Some(ZramBlockMount::reserve(&target_disk)?),
ReplacementScenario::MidRebuildIoFault => None,
};
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
cluster.set_env("RUSTFS_SCANNER_ENABLED", "true");
@@ -852,28 +1228,55 @@ mod tests {
cluster.set_env("RUSTFS_SCANNER_CYCLE", "1");
cluster.set_env("RUSTFS_SCANNER_START_DELAY_SECS", "0");
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
cluster.set_node_env(TARGET_NODE, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
if scenario == ReplacementScenario::MidRebuildIoFault {
cluster.set_env("RUSTFS_HEAL_PAGE_OBJECT_CONCURRENCY", "1");
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
}
for node_index in 0..cluster.nodes.len() {
cluster.set_node_env(node_index, "RUST_LOG", "rustfs=info,rustfs::heal::manager=debug,rustfs_notify=debug")?;
}
cluster.start().await?;
let clients = cluster.create_all_clients()?;
let versions = seed_baseline(&clients[0], &target_disk).await?;
verify_bodies(&clients[0], &versions).await?;
let extra_object_count = match scenario {
ReplacementScenario::Baseline => 0,
ReplacementScenario::MidRebuildIoFault => FAULT_WINDOW_OBJECT_COUNT,
};
let versions = seed_baseline(&clients[0], &target_disk, extra_object_count)
.await
.map_err(|error| format!("pre-fault baseline seeding failed: {error}"))?;
verify_bodies(&clients[0], &versions)
.await
.map_err(|error| format!("pre-fault body verification failed: {error}"))?;
let live_loss_log_offset = log_len(&target_log_path)?;
target_mount.make_unavailable()?;
wait_for_live_disk_loss_observation(
&target_log_path,
&target_disk,
live_loss_log_offset,
ABSENT_SCANNER_OBSERVATION_TIMEOUT_SECS,
)
.await?;
assert_no_replacement_status_records(&cluster, &target_disk).await?;
assert_no_replacement_admission_artifacts(&cluster, &target_disk)?;
target_mount
.make_unavailable()
.map_err(|error| format!("failed to install the dm-error target: {error}"))?;
target_mount
.verify_raw_io_is_unavailable()
.map_err(|error| format!("dm-error target was not proven by a direct raw read: {error}"))?;
assert_no_replacement_status_records(&cluster, &target_disk)
.await
.map_err(|error| format!("live-fault replacement status check failed: {error}"))?;
assert_no_replacement_admission_artifacts(&cluster, &target_disk)
.map_err(|error| format!("live-fault replacement artifact check failed: {error}"))?;
cluster.stop_node(TARGET_NODE)?;
cluster.stop_node_gracefully(TARGET_NODE).await?;
target_mount.cleanup()?;
mount_ns.mount_tmpfs(&target_disk, &format!("rustfs-e2e-p{parity}-replacement"))?;
let mut faultable_replacement = match scenario {
ReplacementScenario::Baseline => {
zram_replacement
.as_mut()
.ok_or("baseline replacement zram was not reserved")?
.mount_target()?;
None
}
ReplacementScenario::MidRebuildIoFault => Some(FaultableBlockMount::mount_live_recovery(
&target_disk,
&image_root,
&format!("p{parity}_replacement_node{TARGET_NODE}_drive{TARGET_DRIVE}"),
)?),
};
let missing_before_restart = incomplete_versions(&target_disk, &versions)?;
assert_eq!(
missing_before_restart.len(),
@@ -882,46 +1285,42 @@ mod tests {
);
cluster.start_node(TARGET_NODE).await?;
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
verify_bodies(&clients[0], &versions).await?;
let recovery_result = async {
let faulted_task_id = match faultable_replacement.as_ref() {
Some(replacement) => {
Some(exercise_mid_rebuild_io_fault(&mut cluster, replacement, &target_disk, &versions).await?)
}
None => None,
};
wait_for_completed_replacement_with_census(&cluster, &target_disk, &versions, 420).await?;
if let Some(task_id) = faulted_task_id {
let status = replacement_status(&cluster).await?;
assert_target_generation_completed(&status, &target_disk, &task_id)?;
}
verify_bodies(&clients[0], &versions).await
}
.await;
let stop_result = cluster.stop_node_gracefully(TARGET_NODE).await;
let replacement_cleanup_result = match faultable_replacement.as_mut() {
Some(replacement) => replacement.cleanup(),
None => zram_replacement
.as_mut()
.ok_or("baseline replacement zram disappeared before cleanup")?
.cleanup(),
};
Ok(())
}
if let Err(error) = recovery_result {
if let Err(stop_error) = stop_result {
info!(%stop_error, "replacement target stop failed while preserving recovery failure");
}
if let Err(cleanup_error) = replacement_cleanup_result {
info!(%cleanup_error, "replacement zram cleanup failed while preserving recovery failure");
}
return Err(error);
}
stop_result?;
replacement_cleanup_result?;
#[test]
fn live_loss_barrier_requires_scanner_failure_after_log_offset() -> Result<(), Box<dyn Error + Send + Sync>> {
let target = Path::new("/mnt/target");
assert!(live_disk_loss_scan_completed(
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto-scan cycle completed",
target
));
assert!(live_disk_loss_scan_completed(
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
target
));
assert!(!live_disk_loss_scan_completed(
"Heal auto disk scanner idle\nHeal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed",
target
));
assert!(!live_disk_loss_scan_completed(
"event=disk_health_check_failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle",
target
));
assert!(!live_disk_loss_scan_completed(
"Heal auto-scan disk inspection failed endpoint=/mnt/other disk_state=check_failed\nHeal auto disk scanner idle",
target
));
let path = std::env::temp_dir().join(format!("rustfs-replacement-scan-{}.log", std::process::id()));
let stale =
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
fs::write(&path, stale)?;
let offset = log_len(&path)?;
assert!(!live_disk_loss_scan_completed_from_path(&path, offset, target)?);
let fresh =
"Heal auto-scan disk inspection failed endpoint=/mnt/target disk_state=check_failed\nHeal auto disk scanner idle\n";
fs::write(&path, format!("{stale}{fresh}"))?;
assert!(live_disk_loss_scan_completed_from_path(&path, offset, target)?);
fs::remove_file(path)?;
Ok(())
}
@@ -954,6 +1353,15 @@ mod tests {
);
}
#[test]
fn recovery_census_only_treats_missing_version_as_transient() {
let missing_version: Box<dyn Error + Send + Sync> = Box::new(rustfs_filemeta::Error::FileVersionNotFound);
let missing_file: Box<dyn Error + Send + Sync> = Box::new(rustfs_filemeta::Error::FileNotFound);
assert!(is_transient_recovery_version_absence(missing_version.as_ref()));
assert!(!is_transient_recovery_version_absence(missing_file.as_ref()));
}
#[tokio::test]
async fn completion_poll_samples_census_before_status() {
let order = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
@@ -1046,6 +1454,65 @@ mod tests {
assert_eq!(status_samples.borrow().len(), 1);
}
#[test]
fn target_eio_status_preserves_one_nonterminal_generation() {
let target = Path::new("/mnt/target");
for state in ["waiting_for_replacement", "running", "incomplete"] {
let status = serde_json::json!({
"cluster": {
"definitive": true,
"records": [{
"taskId": "generation-a",
"state": state,
"targetSlots": ["http://127.0.0.1:9000/mnt/target"]
}]
}
});
assert!(assert_target_generation_nonterminal(&status, target, "generation-a").is_ok());
}
let running = serde_json::json!({
"cluster": {
"definitive": true,
"records": [{
"taskId": "generation-a",
"state": "running",
"targetSlots": ["/mnt/target"]
}]
}
});
assert_eq!(running_target_generation(&running, target).unwrap().as_deref(), Some("generation-a"));
}
#[test]
fn target_eio_status_rejects_false_or_replaced_completion() {
let target = Path::new("/mnt/target");
let completed = serde_json::json!({
"cluster": {
"definitive": true,
"records": [{
"taskId": "generation-a",
"state": "completed",
"targetSlots": ["/mnt/target"]
}]
}
});
assert!(assert_target_generation_nonterminal(&completed, target, "generation-a").is_err());
assert!(assert_target_generation_completed(&completed, target, "generation-a").is_ok());
assert!(assert_target_generation_completed(&completed, target, "generation-b").is_err());
let duplicate = serde_json::json!({
"cluster": {
"definitive": true,
"records": [
{"taskId": "generation-a", "state": "running", "targetSlots": ["/mnt/target"]},
{"taskId": "generation-a", "state": "incomplete", "targetSlots": ["/mnt/target"]}
]
}
});
assert!(assert_target_generation_nonterminal(&duplicate, target, "generation-a").is_err());
}
#[test]
fn absent_status_requires_definitive_empty_records() {
let target = Path::new("/mnt/target");
@@ -1066,6 +1533,7 @@ mod tests {
run_replacement_e2e(
4,
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec8_plus_4_without_admin_heal",
ReplacementScenario::Baseline,
)
.await
}
@@ -1079,6 +1547,20 @@ mod tests {
run_replacement_e2e(
6,
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_rebuilds_ec6_plus_6_without_admin_heal",
ReplacementScenario::Baseline,
)
.await
}
/// Linux mount namespaces are per-thread; keep mount setup and process
/// spawning on one OS thread so child RustFS nodes inherit the test mounts.
#[tokio::test(flavor = "current_thread")]
#[ignore = "requires Linux root/CAP_SYS_ADMIN and RUSTFS_PRIVILEGED_REPLACEMENT_E2E=1"]
async fn test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio() -> Result<(), Box<dyn Error + Send + Sync>> {
run_replacement_e2e(
4,
"replacement_privileged_e2e_test::tests::test_privileged_3x4_auto_replacement_recovers_from_mid_rebuild_eio",
ReplacementScenario::MidRebuildIoFault,
)
.await
}
+535 -18
View File
@@ -69,7 +69,7 @@ use std::net::IpAddr;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use time::{Duration as TimeDuration, OffsetDateTime};
use tokio::fs;
use tokio::net::TcpListener;
@@ -86,7 +86,7 @@ type BacklogMetricPoints = Arc<Mutex<BTreeMap<String, BTreeMap<String, (u64, f64
/// default. This suite opts its source servers into the loopback allowance explicitly
/// so the shared harness (`RustFSTestEnvironment` / the cluster harness) stays
/// fail-closed and every other e2e scenario keeps exercising the production SSRF policy.
const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
pub(crate) const LOOPBACK_REPLICATION_TARGET_ENV: &[(&str, &str)] = &[("RUSTFS_REPLICATION_ALLOW_LOOPBACK_TARGET", "true")];
/// Short data-scanner cycle for the failure-recovery tests (backlog#1147 repl-5).
///
@@ -402,14 +402,14 @@ fn parse_assume_role_credentials(xml: &str) -> Result<(String, String, String),
Ok((access_key, secret_key, session_token))
}
struct ReplicationTargetOptions<'a> {
endpoint: &'a str,
access_key: &'a str,
secret_key: &'a str,
target_bucket: &'a str,
secure: bool,
skip_tls_verify: bool,
ca_cert_pem: Option<&'a str>,
pub(crate) struct ReplicationTargetOptions<'a> {
pub(crate) endpoint: &'a str,
pub(crate) access_key: &'a str,
pub(crate) secret_key: &'a str,
pub(crate) target_bucket: &'a str,
pub(crate) secure: bool,
pub(crate) skip_tls_verify: bool,
pub(crate) ca_cert_pem: Option<&'a str>,
}
async fn set_replication_target(
@@ -434,7 +434,7 @@ async fn set_replication_target(
.await
}
async fn set_replication_target_with_options(
pub(crate) async fn set_replication_target_with_options(
source_env: &RustFSTestEnvironment,
source_bucket: &str,
options: ReplicationTargetOptions<'_>,
@@ -504,7 +504,7 @@ async fn send_set_replication_target_request(
.await
}
async fn put_bucket_replication(
pub(crate) async fn put_bucket_replication(
env: &RustFSTestEnvironment,
bucket: &str,
target_arn: &str,
@@ -643,7 +643,10 @@ async fn get_bucket_replication(
signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await
}
async fn enable_bucket_versioning(env: &RustFSTestEnvironment, bucket: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
pub(crate) async fn enable_bucket_versioning(
env: &RustFSTestEnvironment,
bucket: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
set_bucket_versioning(env, bucket, BucketVersioningStatus::Enabled).await
}
@@ -2005,6 +2008,87 @@ fn proxy_error_response(error: impl std::fmt::Display) -> Response<Full<bytes::B
.expect("static proxy response must be valid")
}
#[derive(Clone)]
struct ReplicationResponseHoldRuntime {
armed: Arc<AtomicBool>,
backend_committed: watch::Sender<bool>,
release: watch::Receiver<bool>,
}
impl ReplicationResponseHoldRuntime {
fn try_claim(&self) -> bool {
self.armed
.compare_exchange(true, false, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
}
}
struct ReplicationResponseHold {
armed: Arc<AtomicBool>,
backend_committed_signal: watch::Sender<bool>,
backend_committed: watch::Receiver<bool>,
release: watch::Sender<bool>,
}
impl ReplicationResponseHold {
fn arm(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
if self.armed.load(Ordering::Acquire) {
return Err("replication response hold was already armed".into());
}
self.backend_committed_signal
.send(false)
.map_err(|_| "replication response hold closed before rearming")?;
self.release
.send(false)
.map_err(|_| "replication response hold closed before rearming")?;
self.armed
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.map_err(|_| "replication response hold was already armed")?;
Ok(())
}
async fn wait_for_backend_commit(&mut self) -> Result<(), Box<dyn Error + Send + Sync>> {
let wait = async {
while !*self.backend_committed.borrow() {
self.backend_committed
.changed()
.await
.map_err(|_| "replication response hold closed before the backend committed")?;
}
Ok::<(), Box<dyn Error + Send + Sync>>(())
};
timeout(Duration::from_secs(60), wait)
.await
.map_err(|_| "timed out waiting for the replication backend to commit")?
}
fn release(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
self.release
.send(true)
.map_err(|_| "replication response hold closed before release")?;
Ok(())
}
}
fn replication_response_hold() -> (ReplicationResponseHoldRuntime, ReplicationResponseHold) {
let armed = Arc::new(AtomicBool::new(false));
let (backend_committed, backend_committed_rx) = watch::channel(false);
let (release, release_rx) = watch::channel(false);
(
ReplicationResponseHoldRuntime {
armed: armed.clone(),
backend_committed: backend_committed.clone(),
release: release_rx,
},
ReplicationResponseHold {
armed,
backend_committed_signal: backend_committed,
backend_committed: backend_committed_rx,
release,
},
)
}
async fn forward_replication_proxy_request(
request: Request<Incoming>,
backend_url: &str,
@@ -2012,6 +2096,7 @@ async fn forward_replication_proxy_request(
request_count: &AtomicU64,
mut replication_enabled: watch::Receiver<bool>,
mut held_tagging: watch::Receiver<Option<String>>,
mut response_hold: ReplicationResponseHoldRuntime,
) -> Response<Full<bytes::Bytes>> {
let (parts, body) = request.into_parts();
let is_replication = parts
@@ -2037,6 +2122,7 @@ async fn forward_replication_proxy_request(
}
}
}
let hold_response = is_replication && parts.method == http::Method::PUT && response_hold.try_claim();
let Some(path_and_query) = parts.uri.path_and_query() else {
return proxy_error_response("request URI omitted path");
@@ -2059,6 +2145,16 @@ async fn forward_replication_proxy_request(
Ok(body) => body,
Err(error) => return proxy_error_response(error),
};
if hold_response && status.is_success() {
if response_hold.backend_committed.send(true).is_err() {
return proxy_error_response("replication response hold closed after the backend committed");
}
while !*response_hold.release.borrow() {
if response_hold.release.changed().await.is_err() {
return proxy_error_response("replication response hold closed before release");
}
}
}
let mut proxied = Response::builder().status(status);
for (name, value) in &headers {
proxied = proxied.header(name, value);
@@ -2070,7 +2166,7 @@ async fn start_replication_counting_proxy(
backend_url: &str,
tasks: &mut JoinSet<()>,
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>), Box<dyn Error + Send + Sync>> {
let (proxy_url, request_count, replication_enabled, _held_tagging) =
let (proxy_url, request_count, replication_enabled, _held_tagging, _response_hold) =
start_replication_counting_proxy_with_tag_hold(backend_url, tasks).await?;
Ok((proxy_url, request_count, replication_enabled))
}
@@ -2082,7 +2178,16 @@ async fn start_replication_counting_proxy(
async fn start_replication_counting_proxy_with_tag_hold(
backend_url: &str,
tasks: &mut JoinSet<()>,
) -> Result<(String, Arc<AtomicU64>, watch::Sender<bool>, watch::Sender<Option<String>>), Box<dyn Error + Send + Sync>> {
) -> Result<
(
String,
Arc<AtomicU64>,
watch::Sender<bool>,
watch::Sender<Option<String>>,
ReplicationResponseHold,
),
Box<dyn Error + Send + Sync>,
> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let proxy_url = format!("http://{}", listener.local_addr()?);
let backend_url = backend_url.to_string();
@@ -2090,6 +2195,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
let task_request_count = request_count.clone();
let (replication_enabled, task_replication_enabled) = watch::channel(true);
let (held_tagging, task_held_tagging) = watch::channel(None);
let (response_hold_runtime, response_hold) = replication_response_hold();
tasks.spawn(async move {
let client = local_http_client();
let mut connections = JoinSet::new();
@@ -2102,6 +2208,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
let request_count = task_request_count.clone();
let replication_enabled = task_replication_enabled.clone();
let held_tagging = task_held_tagging.clone();
let response_hold = response_hold_runtime.clone();
connections.spawn(async move {
let service = service_fn(move |request| {
let backend_url = backend_url.clone();
@@ -2109,6 +2216,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
let request_count = request_count.clone();
let replication_enabled = replication_enabled.clone();
let held_tagging = held_tagging.clone();
let response_hold = response_hold.clone();
async move {
Ok::<_, Infallible>(
forward_replication_proxy_request(
@@ -2118,6 +2226,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
&request_count,
replication_enabled,
held_tagging,
response_hold,
)
.await,
)
@@ -2130,7 +2239,7 @@ async fn start_replication_counting_proxy_with_tag_hold(
}
}
});
Ok((proxy_url, request_count, replication_enabled, held_tagging))
Ok((proxy_url, request_count, replication_enabled, held_tagging, response_hold))
}
async fn site_replication_remove(
@@ -3821,6 +3930,12 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
let mut source_env = RustFSTestEnvironment::new().await?;
let mut source_env_vars = replication_fast_env();
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
// This matrix verifies request-time rule/admission behavior. Keep the
// background existing-object scanner outside the observation window: with
// ExistingObjectReplication enabled it may legitimately discover an
// object after its tags change, which is a separate data-replication path
// that PR #5696 intentionally did not alter.
source_env_vars.push(("RUSTFS_SCANNER_START_DELAY_SECS", "300"));
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
let mut target_env_a = RustFSTestEnvironment::new().await?;
@@ -4049,6 +4164,13 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
.await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
// A metadata edit must not retroactively admit data that failed the tag
// filter at PUT time. This is the PR #5696 safety boundary: the target ARN
// has no persisted data-admission state for this version, so adding the
// matching tag later remains metadata-only and fails closed.
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
source_client
.put_object()
.bucket(source_bucket)
@@ -6621,6 +6743,99 @@ async fn test_site_replication_replicates_object_with_bucket_versioning_real_dua
Ok(())
}
#[tokio::test]
async fn test_site_replication_replays_bucket_created_during_peer_outage_real_dual_node() -> TestResult {
init_logging();
// Keep compilation outside the scenario timeout. Recovery itself waits
// for the production 30-second lightweight retry tick.
let _rustfs_binary = rustfs_binary_path();
match timeout(Duration::from_secs(150), async {
let mut site_env = replication_fast_env();
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
let mut site_a_env = RustFSTestEnvironment::new().await?;
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut site_b_env = RustFSTestEnvironment::new().await?;
site_b_env.start_rustfs_server_without_cleanup_with_env(&site_env).await?;
let site_a_client = site_a_env.create_s3_client();
let site_b_client = site_b_env.create_s3_client();
let bucket = "site-repl-peer-outage";
let key = "after-recovery.txt";
let payload = b"site replication recovered the missed bucket".to_vec();
let add_status = site_replication_add(
&site_a_env,
&[
PeerSite {
name: "outage-site-a".to_string(),
endpoint: site_a_env.url.clone(),
access_key: site_a_env.access_key.clone(),
secret_key: site_a_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "outage-site-b".to_string(),
endpoint: site_b_env.url.clone(),
access_key: site_b_env.access_key.clone(),
secret_key: site_b_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
wait_for_site_replication_enabled(&site_a_env, 2).await?;
wait_for_site_replication_enabled(&site_b_env, 2).await?;
site_b_env.stop_server();
site_a_client.create_bucket().bucket(bucket).send().await?;
site_a_client.head_bucket().bucket(bucket).send().await?;
let queued = site_replication_info(&site_a_env)
.await?
.retry_stats
.ok_or("peer outage did not persist a site replication retry event")?;
assert!(queued.pending + queued.failed > 0, "peer outage retry queue was unexpectedly empty");
site_b_env.restart_server_preserving_data(vec![], &site_env).await?;
let recovery_deadline = tokio::time::Instant::now() + Duration::from_secs(75);
loop {
let bucket_recovered = site_b_client.head_bucket().bucket(bucket).send().await.is_ok();
let queue_empty = site_replication_info(&site_a_env).await?.retry_stats.is_none();
if bucket_recovered && queue_empty {
break;
}
if tokio::time::Instant::now() >= recovery_deadline {
return Err(format!(
"site replication retry did not settle after peer recovery; bucket_recovered={bucket_recovered}, queue_empty={queue_empty}"
)
.into());
}
sleep(Duration::from_millis(250)).await;
}
site_a_client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(payload.clone()))
.send()
.await?;
assert_eq!(wait_for_object_on_target(&site_b_client, bucket, key).await?, payload);
Ok(())
})
.await
{
Ok(result) => result,
Err(_) => Err("site replication peer-outage recovery timed out after 150 seconds".into()),
}
}
/// Re-applying a site's own replication config must not disable the peer's reverse direction.
///
/// `PutBucketReplication` broadcasts the config to every peer — the console's replication
@@ -7152,6 +7367,27 @@ async fn put_single_tag(
Ok(())
}
async fn put_single_tag_current(
client: &Client,
bucket: &str,
key: &str,
tag_key: &str,
tag_value: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
client
.put_object_tagging()
.bucket(bucket)
.key(key)
.tagging(
aws_sdk_s3::types::Tagging::builder()
.tag_set(aws_sdk_s3::types::Tag::builder().key(tag_key).value(tag_value).build()?)
.build()?,
)
.send()
.await?;
Ok(())
}
async fn get_single_tag(
client: &Client,
bucket: &str,
@@ -7199,6 +7435,28 @@ async fn wait_for_single_tag(
}
}
/// Poll one site until `tag_key` is absent from the selected version.
async fn wait_for_tag_absent(
client: &Client,
bucket: &str,
key: &str,
version_id: &str,
tag_key: &str,
site: &str,
) -> Result<(), Box<dyn Error + Send + Sync>> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(60);
loop {
let observed = get_single_tag(client, bucket, key, version_id, tag_key).await?;
if observed.is_none() {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!("{site}: {bucket}/{key}?versionId={version_id} tag {tag_key} remained {observed:?}").into());
}
sleep(Duration::from_millis(200)).await;
}
}
/// Tag key the dual-node LWW scenario edits on both sites.
const LWW_TAG_KEY: &str = "owner";
@@ -7246,6 +7504,265 @@ async fn wait_for_proxy_replication_requests(
}
}
/// rustfs/backlog#2099: metadata admission must not drop a tag edit made after
/// the target has committed the initial object but before the source persists
/// that replication as COMPLETED.
#[tokio::test]
async fn test_site_replication_tagging_during_initial_pending_window_converges() -> TestResult {
init_logging();
// `RustFSTestEnvironment::start_rustfs_server_with_env` resolves (and on a
// cold checkout builds) this binary synchronously. Keep that setup outside
// the scenario timeout so 180 seconds measures the runtime race rather
// than compilation latency.
let _rustfs_binary = rustfs_binary_path();
match timeout(Duration::from_secs(180), async {
const PAYLOAD: &str = "tagging during pending replication";
const TAG_KEY: &str = "window";
const TAG_VALUE: &str = "pending";
const DELETE_PAYLOAD: &str = "tag deletion during pending replication";
const DELETE_TAG_KEY: &str = "remove";
const DELETE_TAG_VALUE: &str = "while-pending";
let mut site_env = replication_fast_env();
site_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
let mut site_a_env = RustFSTestEnvironment::new().await?;
site_a_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut site_b_env = RustFSTestEnvironment::new().await?;
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let site_a_client = site_a_env.create_s3_client();
let site_b_client = site_b_env.create_s3_client();
let mut proxy_tasks = JoinSet::new();
let (
site_b_proxy,
_site_b_replication_requests,
_site_b_replication_enabled,
_site_b_held_tagging,
mut site_b_response_hold,
) = start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
let add_status = site_replication_add(
&site_a_env,
&[
PeerSite {
name: "pending-site-a".to_string(),
endpoint: site_a_env.url.clone(),
access_key: site_a_env.access_key.clone(),
secret_key: site_a_env.secret_key.clone(),
..Default::default()
},
PeerSite {
name: "pending-site-b".to_string(),
endpoint: site_b_env.url.clone(),
access_key: site_b_env.access_key.clone(),
secret_key: site_b_env.secret_key.clone(),
..Default::default()
},
],
)
.await?;
assert!(add_status.success, "unexpected site add result: {add_status:?}");
let site_info = wait_for_site_replication_enabled(&site_a_env, 2).await?;
wait_for_site_replication_enabled(&site_b_env, 2).await?;
let mut site_b_peer = site_info
.sites
.iter()
.find(|peer| peer.endpoint == site_b_env.url.as_str())
.ok_or("site B peer missing from replication info")?
.clone();
site_b_peer.endpoint = site_b_proxy.clone();
site_b_peer.sync_state = SyncStatus::Enable;
let edit = site_replication_edit(&site_a_env, "", &site_b_peer).await?;
assert!(edit.success, "unexpected site B endpoint edit: {edit:?}");
for env in [&site_a_env, &site_b_env] {
wait_for_site_replication_info(env, |info| info.sites.iter().any(|peer| peer.endpoint == site_b_proxy)).await?;
}
let bucket = "site-repl-tag-pending";
let key = "pending-window.txt";
site_a_client.create_bucket().bucket(bucket).send().await?;
wait_for_bucket_on_target(&site_b_client, bucket).await?;
site_b_response_hold.arm()?;
let put_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
tokio::spawn(async move {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(PAYLOAD.as_bytes()))
.send()
.await
})
};
// The proxy reads B's complete successful response before parking it,
// so B's GET proves the object is committed while A's worker is still
// unable to persist COMPLETED.
site_b_response_hold.wait_for_backend_commit().await?;
wait_for_replicated_object(&site_b_client, bucket, key, PAYLOAD).await?;
let source_head = site_a_client.head_object().bucket(bucket).key(key).send().await?;
let version_id = source_head
.version_id()
.ok_or("source HEAD omitted the pending version ID")?
.to_string();
assert_eq!(
source_head.replication_status().map(|status| status.as_str()),
Some("PENDING"),
"source must still report PENDING while the initial replication response is held"
);
let tag_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = key.to_string();
// The original real-machine failure used the current-version S3
// API (no versionId). The delete phase below deliberately keeps an
// explicit versionId so both request shapes stay covered.
tokio::spawn(async move { put_single_tag_current(&client, &bucket, &key, TAG_KEY, TAG_VALUE).await })
};
wait_for_single_tag(&site_a_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site A").await?;
assert_eq!(
head_replication_status(&site_a_client, bucket, key, &version_id)
.await?
.as_deref(),
Some("PENDING"),
"tag update must be authored before the initial replication reaches COMPLETED"
);
site_b_response_hold.release()?;
let put_output = timeout(Duration::from_secs(60), put_task)
.await
.map_err(|_| "source PutObject remained blocked after releasing the replication response")???;
timeout(Duration::from_secs(60), tag_task)
.await
.map_err(|_| "PutObjectTagging remained blocked after releasing the replication response")???;
assert_eq!(put_output.version_id(), Some(version_id.as_str()));
wait_for_single_tag(&site_b_client, bucket, key, &version_id, TAG_KEY, TAG_VALUE, "site B").await?;
wait_for_version_replication_status(&site_a_client, bucket, key, &version_id, &["COMPLETED"], "site A").await?;
wait_for_version_replication_status(&site_b_client, bucket, key, &version_id, &["REPLICA"], "site B").await?;
let source_state = list_replication_state(&site_a_client, bucket).await?;
let target_state = list_replication_state(&site_b_client, bucket).await?;
assert_eq!(source_state, target_state, "tag replication must not fork the object version");
assert_eq!(source_state.len(), 1, "tag replication must leave exactly one object version");
assert_eq!(source_state[0].key, key);
assert_eq!(source_state[0].version_id, version_id);
// Re-arm the same response barrier for an initially tagged object.
// Deleting its tag while A is still PENDING proves the same admission
// rule covers DeleteObjectTagging rather than only the original PUT
// symptom.
let delete_key = "pending-delete-window.txt";
site_b_response_hold.arm()?;
let delete_put_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = delete_key.to_string();
tokio::spawn(async move {
client
.put_object()
.bucket(bucket)
.key(key)
.tagging(format!("{DELETE_TAG_KEY}={DELETE_TAG_VALUE}"))
.body(ByteStream::from_static(DELETE_PAYLOAD.as_bytes()))
.send()
.await
})
};
site_b_response_hold.wait_for_backend_commit().await?;
wait_for_replicated_object(&site_b_client, bucket, delete_key, DELETE_PAYLOAD).await?;
let delete_source_head = site_a_client.head_object().bucket(bucket).key(delete_key).send().await?;
let delete_version_id = delete_source_head
.version_id()
.ok_or("source HEAD omitted the pending tagged version ID")?
.to_string();
assert_eq!(
delete_source_head.replication_status().map(|status| status.as_str()),
Some("PENDING"),
"source tagged object must remain PENDING while its initial response is held"
);
wait_for_single_tag(
&site_b_client,
bucket,
delete_key,
&delete_version_id,
DELETE_TAG_KEY,
DELETE_TAG_VALUE,
"site B",
)
.await?;
let delete_tag_task = {
let client = site_a_client.clone();
let bucket = bucket.to_string();
let key = delete_key.to_string();
let version_id = delete_version_id.clone();
tokio::spawn(async move {
client
.delete_object_tagging()
.bucket(bucket)
.key(key)
.version_id(version_id)
.send()
.await
})
};
wait_for_tag_absent(&site_a_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site A").await?;
assert_eq!(
head_replication_status(&site_a_client, bucket, delete_key, &delete_version_id)
.await?
.as_deref(),
Some("PENDING"),
"tag deletion must be authored before the initial replication reaches COMPLETED"
);
site_b_response_hold.release()?;
let delete_put_output = timeout(Duration::from_secs(60), delete_put_task)
.await
.map_err(|_| "source tagged PutObject remained blocked after releasing the replication response")???;
timeout(Duration::from_secs(60), delete_tag_task)
.await
.map_err(|_| "DeleteObjectTagging remained blocked after releasing the replication response")???;
assert_eq!(delete_put_output.version_id(), Some(delete_version_id.as_str()));
wait_for_tag_absent(&site_b_client, bucket, delete_key, &delete_version_id, DELETE_TAG_KEY, "site B").await?;
wait_for_version_replication_status(&site_a_client, bucket, delete_key, &delete_version_id, &["COMPLETED"], "site A")
.await?;
wait_for_version_replication_status(&site_b_client, bucket, delete_key, &delete_version_id, &["REPLICA"], "site B")
.await?;
let source_state = list_replication_state(&site_a_client, bucket).await?;
let target_state = list_replication_state(&site_b_client, bucket).await?;
assert_eq!(source_state, target_state, "tag deletion must not fork the object version");
assert_eq!(source_state.len(), 2, "pending-window scenarios must leave exactly two object versions");
assert!(
source_state
.iter()
.any(|entry| entry.key == delete_key && entry.version_id == delete_version_id),
"tag deletion must preserve the original version identity"
);
proxy_tasks.abort_all();
Ok(())
})
.await
{
Ok(result) => result,
Err(_) => Err("pending-window site-replication tagging test timed out".into()),
}
}
/// rustfs/backlog#1953 (audit A4/P1-6): receiver-side LWW for replicated
/// metadata categories, exercised end to end over the real dual-node
/// active-active site-replication control plane — sender, worker, status
@@ -7282,9 +7799,9 @@ async fn test_site_replication_tagging_lww_converges_active_active_real_dual_nod
site_b_env.start_rustfs_server_with_env(vec![], &site_env).await?;
let mut proxy_tasks = JoinSet::new();
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging) =
let (site_a_proxy, site_a_replication_requests, _site_a_replication_enabled, site_a_held_tagging, _site_a_response_hold) =
start_replication_counting_proxy_with_tag_hold(&site_a_env.url, &mut proxy_tasks).await?;
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging) =
let (site_b_proxy, site_b_replication_requests, _site_b_replication_enabled, site_b_held_tagging, _site_b_response_hold) =
start_replication_counting_proxy_with_tag_hold(&site_b_env.url, &mut proxy_tasks).await?;
let site_a_client = site_a_env.create_s3_client();
@@ -0,0 +1,519 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Outbound target matrix: every object shape RustFS replicates, against
//! every remote-target failure mode the fake target models.
//!
//! The matrix exists because a fix for one target class shipped a regression
//! for another (rustfs#6895 fixed rustfs#6853 and caused rustfs#7082; see
//! `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`).
//! Each row is one target mode with its own RustFS source and fake target;
//! each cell is one object shape. [`expectation`] is the single place that
//! says what a cell must do today:
//!
//! - `Completed` cells must replicate and the target must hold the source
//! bytes; the journal must also show the wire shape the cell relies on.
//! - `KnownFailing` cells pin an open issue. They must fail for the recorded
//! reason, and the moment they start passing the test fails with an XPASS
//! message so the expectation is flipped in the same PR as the fix.
//!
//! Adding a target behavior the fleet has shown: add the mode to the fake
//! target, add a row here, and record any cell that is red before the fix.
use crate::common::{RustFSTestEnvironment, init_logging, replication_fast_env};
use crate::fake_s3_target::{FAKE_ACCESS_KEY, FAKE_SECRET_KEY};
use crate::fake_s3_target::{FakeS3Target, Operation as FakeTargetOperation, RequestRecord};
use crate::on_demand_migration::common::fake_source_client;
use crate::replication_extension_test::{
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, enable_bucket_versioning, put_bucket_replication,
set_replication_target_with_options,
};
use aws_sdk_s3::Client;
use aws_sdk_s3::primitives::{ByteStream, DateTime};
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart, ObjectLockLegalHoldStatus, ObjectLockMode};
use bytes::Bytes;
use std::error::Error;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::time::{Duration, sleep, timeout};
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
/// A remote-target behavior the fleet has shown, as the fake target models it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TargetMode {
/// RustFS / MinIO-like target: adopts source version ids, decodes any
/// framing, enforces no checksum rule.
Baseline,
/// SeaweedFS 3.97 (rustfs#6853): refuses `aws-chunked` bodies. A sender
/// that frames its uploads gets a hard failure here instead of a
/// silently corrupted replica.
RejectAwsChunked,
/// AWS S3 / MinIO / Impossible Cloud (rustfs#7082): a PutObject with
/// Object Lock parameters must carry `Content-MD5` or `x-amz-checksum-*`.
RequireChecksumWithObjectLock,
/// AWS S3 / Wasabi / Impossible Cloud: mints its own version ids
/// (rustfs/backlog#2085). Data must still land.
MintOwnVersionIds,
}
impl TargetMode {
const ALL: [TargetMode; 4] = [
TargetMode::Baseline,
TargetMode::RejectAwsChunked,
TargetMode::RequireChecksumWithObjectLock,
TargetMode::MintOwnVersionIds,
];
fn apply(self, target: &FakeS3Target) {
match self {
TargetMode::Baseline => {}
TargetMode::RejectAwsChunked => target.reject_aws_chunked_uploads(true),
TargetMode::RequireChecksumWithObjectLock => target.require_checksum_for_object_lock(true),
TargetMode::MintOwnVersionIds => target.assign_own_version_ids(true),
}
}
fn slug(self) -> &'static str {
match self {
TargetMode::Baseline => "baseline",
TargetMode::RejectAwsChunked => "reject-aws-chunked",
TargetMode::RequireChecksumWithObjectLock => "require-checksum-object-lock",
TargetMode::MintOwnVersionIds => "mint-own-version-ids",
}
}
}
/// An object shape the replication transport treats differently.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ObjectShape {
/// The exact rustfs#7082 reproduction: a zero-byte object.
Empty,
/// Small single-part object with no Object Lock parameters.
Plain,
/// Single-part object with a GOVERNANCE retention period.
Retention,
/// Single-part object with legal hold ON.
LegalHold,
/// Two-part multipart upload, no Object Lock parameters.
Multipart,
/// Two-part multipart upload with a GOVERNANCE retention period; the
/// lock headers travel on CreateMultipartUpload, which has no body.
LockedMultipart,
}
impl ObjectShape {
const ALL: [ObjectShape; 6] = [
ObjectShape::Empty,
ObjectShape::Plain,
ObjectShape::Retention,
ObjectShape::LegalHold,
ObjectShape::Multipart,
ObjectShape::LockedMultipart,
];
fn key(self) -> &'static str {
match self {
ObjectShape::Empty => "matrix/empty.bin",
ObjectShape::Plain => "matrix/plain.bin",
ObjectShape::Retention => "matrix/retention.bin",
ObjectShape::LegalHold => "matrix/legal-hold.bin",
ObjectShape::Multipart => "matrix/multipart.bin",
ObjectShape::LockedMultipart => "matrix/locked-multipart.bin",
}
}
fn carries_object_lock_params(self) -> bool {
matches!(self, ObjectShape::Retention | ObjectShape::LegalHold | ObjectShape::LockedMultipart)
}
/// Upload the shape to the source and return the bytes the target must
/// end up holding.
async fn put(self, client: &Client, bucket: &str) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
let key = self.key();
match self {
ObjectShape::Empty => {
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(b""))
.send()
.await?;
Ok(Bytes::new())
}
ObjectShape::Plain => {
let body = payload(64 * 1024, 0x11);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.send()
.await?;
Ok(body)
}
ObjectShape::Retention => {
let body = payload(48 * 1024, 0x22);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.object_lock_mode(ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until())
.send()
.await?;
Ok(body)
}
ObjectShape::LegalHold => {
let body = payload(32 * 1024, 0x33);
client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.clone()))
.object_lock_legal_hold_status(ObjectLockLegalHoldStatus::On)
.send()
.await?;
Ok(body)
}
ObjectShape::Multipart => multipart_put(client, bucket, key, 0x44, false).await,
ObjectShape::LockedMultipart => multipart_put(client, bucket, key, 0x55, true).await,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Expectation {
/// Replicates COMPLETED and the target holds the source bytes.
Completed,
/// Replicates FAILED today for a recorded reason; pinned to an open issue.
KnownFailing(&'static str),
}
/// The cells that are red today, each pinned to the open issue that owns it.
/// This is the single source of truth: a fix that turns a cell green must
/// remove its entry in the same PR, and [`check_known_failing_cell`] refuses
/// an unexpected pass so the table cannot go stale silently. rustfs#7082
/// (Retention and LegalHold against the checksum-requiring target) lived
/// here until the replication PUT started carrying a Content-MD5 derived
/// from the source ETag.
const KNOWN_FAILING_CELLS: &[(TargetMode, ObjectShape, &str)] = &[];
fn expectation(mode: TargetMode, shape: ObjectShape) -> Expectation {
KNOWN_FAILING_CELLS
.iter()
.find(|(known_mode, known_shape, _)| *known_mode == mode && *known_shape == shape)
.map(|(_, _, issue)| Expectation::KnownFailing(issue))
.unwrap_or(Expectation::Completed)
}
#[tokio::test]
async fn matrix_baseline_target() -> TestResult {
run_row(TargetMode::Baseline).await
}
#[tokio::test]
async fn matrix_reject_aws_chunked_target() -> TestResult {
run_row(TargetMode::RejectAwsChunked).await
}
#[tokio::test]
async fn matrix_require_checksum_with_object_lock_target() -> TestResult {
run_row(TargetMode::RequireChecksumWithObjectLock).await
}
#[tokio::test]
async fn matrix_mint_own_version_ids_target() -> TestResult {
run_row(TargetMode::MintOwnVersionIds).await
}
/// Every known-red entry must name a real cell and an issue, and the lookup
/// must round-trip, so a stale or mistyped entry cannot silently pin nothing.
#[test]
fn known_failing_table_names_real_cells() {
for (mode, shape, issue) in KNOWN_FAILING_CELLS {
assert!(
TargetMode::ALL.contains(mode) && ObjectShape::ALL.contains(shape),
"{mode:?}/{shape:?} is not a matrix cell"
);
assert!(
issue.starts_with("rustfs#") || issue.starts_with("rustfs/backlog#"),
"{issue} must name an open issue"
);
assert_eq!(expectation(*mode, *shape), Expectation::KnownFailing(issue));
}
let red_cells = TargetMode::ALL
.iter()
.flat_map(|mode| ObjectShape::ALL.iter().map(move |shape| (*mode, *shape)))
.filter(|(mode, shape)| matches!(expectation(*mode, *shape), Expectation::KnownFailing(_)))
.count();
assert_eq!(red_cells, KNOWN_FAILING_CELLS.len());
}
async fn run_row(mode: TargetMode) -> TestResult {
init_logging();
let target = FakeS3Target::start().await?;
let target_bucket = format!("matrix-{}-dst", mode.slug());
target.create_bucket_with_object_lock(target_bucket.clone());
mode.apply(&target);
let mut source_env = RustFSTestEnvironment::new().await?;
let mut env_vars = replication_fast_env();
env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
env_vars.extend_from_slice(&[("NO_PROXY", "127.0.0.1,localhost"), ("HTTP_PROXY", ""), ("HTTPS_PROXY", "")]);
source_env.start_rustfs_server_with_env(vec![], &env_vars).await?;
let source_bucket = format!("matrix-{}-src", mode.slug());
let source_client = source_env.create_s3_client();
source_client
.create_bucket()
.bucket(&source_bucket)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
enable_bucket_versioning(&source_env, &source_bucket).await?;
let target_arn = set_replication_target_with_options(
&source_env,
&source_bucket,
ReplicationTargetOptions {
endpoint: &target.address(),
access_key: FAKE_ACCESS_KEY,
secret_key: FAKE_SECRET_KEY,
target_bucket: &target_bucket,
secure: false,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await?;
put_bucket_replication(&source_env, &source_bucket, &target_arn).await?;
let target_client = fake_source_client(&target);
let mut failures = Vec::new();
for shape in ObjectShape::ALL {
let cell = format!("{}/{:?}", mode.slug(), shape);
let expected_body = shape.put(&source_client, &source_bucket).await?;
let status = wait_for_terminal_replication_status(&source_client, &source_bucket, shape.key()).await?;
let journal = target.requests();
let outcome = match expectation(mode, shape) {
Expectation::Completed => {
check_completed_cell(&cell, &status, &target_client, &target_bucket, shape, &expected_body, &journal).await
}
Expectation::KnownFailing(issue) => check_known_failing_cell(&cell, issue, &status, shape, &journal),
};
if let Err(err) = outcome {
failures.push(format!("{cell}: {err}"));
}
}
target.shutdown().await;
if failures.is_empty() {
Ok(())
} else {
Err(format!(
"{} matrix cell(s) violated their expectation:\n {}",
failures.len(),
failures.join("\n ")
)
.into())
}
}
async fn check_completed_cell(
cell: &str,
status: &str,
target_client: &Client,
target_bucket: &str,
shape: ObjectShape,
expected_body: &Bytes,
journal: &[RequestRecord],
) -> TestResult {
if status != "COMPLETED" {
return Err(format!("expected COMPLETED, source reports {status}").into());
}
let stored = target_client
.get_object()
.bucket(target_bucket)
.key(shape.key())
.send()
.await
.map_err(|err| format!("target GET failed after COMPLETED: {err}"))?
.body
.collect()
.await?
.into_bytes();
if stored != *expected_body {
return Err(format!(
"target holds {} bytes that differ from the {} source bytes (COMPLETED over a corrupted replica)",
stored.len(),
expected_body.len()
)
.into());
}
// The wire shape the cell relies on: plain signed payloads (rustfs#6853)
// for every upload of this key, and the lock headers present exactly when
// the shape carries them.
let uploads: Vec<&RequestRecord> = journal
.iter()
.filter(|record| {
record.key.as_deref() == Some(shape.key())
&& matches!(
record.operation,
FakeTargetOperation::PutObject | FakeTargetOperation::UploadPart | FakeTargetOperation::CreateMultipartUpload
)
})
.collect();
if uploads.is_empty() {
return Err("no upload reached the target although the source reports COMPLETED".into());
}
if let Some(framed) = uploads.iter().find(|record| record.transport.aws_chunked) {
return Err(format!("{cell}: an upload went out aws-chunked (rustfs#6853 framing): {framed:?}").into());
}
let lock_headers_seen = uploads.iter().any(|record| record.transport.object_lock_params);
if lock_headers_seen != shape.carries_object_lock_params() {
return Err(format!(
"object lock headers on the wire: {lock_headers_seen}, shape carries them: {}",
shape.carries_object_lock_params()
)
.into());
}
// rustfs#7082 contract: every PutObject that carries Object Lock
// parameters also carries Content-MD5 or an x-amz-checksum-* header,
// whatever the target's own policy is.
if let Some(bare) = uploads.iter().find(|record| {
record.operation == FakeTargetOperation::PutObject
&& record.transport.object_lock_params
&& record.transport.content_md5.is_none()
&& record.transport.checksum_headers.is_empty()
}) {
return Err(format!("a locked PutObject went out without any integrity header (rustfs#7082): {bare:?}").into());
}
Ok(())
}
fn check_known_failing_cell(cell: &str, issue: &str, status: &str, shape: ObjectShape, journal: &[RequestRecord]) -> TestResult {
if status == "COMPLETED" {
return Err(format!(
"XPASS: {cell} reached COMPLETED but the expectation table pins it to {issue}; \
the fix landed, so flip this cell to Expectation::Completed in the same PR"
)
.into());
}
if status != "FAILED" {
return Err(format!("expected FAILED ({issue}), source reports {status}").into());
}
// Fail for the recorded reason, not by accident: the PUT carried the lock
// headers and no integrity header at all.
let rejected = journal.iter().any(|record| {
record.operation == FakeTargetOperation::PutObject
&& record.key.as_deref() == Some(shape.key())
&& record.transport.object_lock_params
&& record.transport.content_md5.is_none()
&& record.transport.checksum_headers.is_empty()
});
if !rejected {
return Err(format!(
"FAILED, but not for the {issue} reason (a locked PUT without Content-MD5 / x-amz-checksum-*); journal: {journal:?}"
)
.into());
}
Ok(())
}
/// First terminal replication status (`COMPLETED` or `FAILED`) the source
/// reports for the key.
async fn wait_for_terminal_replication_status(
client: &Client,
bucket: &str,
key: &str,
) -> Result<String, Box<dyn Error + Send + Sync>> {
let wait = async {
loop {
let head = client.head_object().bucket(bucket).key(key).send().await?;
match head.replication_status().map(|status| status.as_str().to_string()) {
Some(status) if status == "COMPLETED" || status == "FAILED" => return Ok(status),
_ => sleep(Duration::from_millis(200)).await,
}
}
};
match timeout(Duration::from_secs(90), wait).await {
Ok(result) => result,
Err(_) => Err(format!("{key} reached no terminal replication status within 90 seconds").into()),
}
}
async fn multipart_put(
client: &Client,
bucket: &str,
key: &str,
fill: u8,
locked: bool,
) -> Result<Bytes, Box<dyn Error + Send + Sync>> {
let part_one = payload(5 * 1024 * 1024, fill);
let part_two = payload(256 * 1024, fill.wrapping_add(1));
let mut create = client.create_multipart_upload().bucket(bucket).key(key);
if locked {
create = create
.object_lock_mode(ObjectLockMode::Governance)
.object_lock_retain_until_date(retain_until());
}
let upload_id = create
.send()
.await?
.upload_id()
.ok_or("CreateMultipartUpload returned no upload id")?
.to_string();
let mut completed = Vec::new();
for (number, part) in [(1, &part_one), (2, &part_two)] {
let etag = client
.upload_part()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.part_number(number)
.body(ByteStream::from(part.clone()))
.send()
.await?
.e_tag()
.ok_or("UploadPart returned no ETag")?
.to_string();
completed.push(CompletedPart::builder().part_number(number).e_tag(etag).build());
}
client
.complete_multipart_upload()
.bucket(bucket)
.key(key)
.upload_id(&upload_id)
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
.send()
.await?;
let mut body = Vec::with_capacity(part_one.len() + part_two.len());
body.extend_from_slice(&part_one);
body.extend_from_slice(&part_two);
Ok(Bytes::from(body))
}
fn payload(len: usize, fill: u8) -> Bytes {
Bytes::from((0..len).map(|i| fill.wrapping_add((i % 251) as u8)).collect::<Vec<u8>>())
}
fn retain_until() -> DateTime {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_secs();
DateTime::from_secs(now as i64 + 86_400)
}
+1 -1
View File
@@ -15,7 +15,7 @@
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
pub(crate) use rustfs_ecstore::api::disk::{RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::rpc::{
@@ -0,0 +1,144 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Two-node gate for the tier stats wire contract (rustfs/backlog#2207).
//!
//! Before this contract, `GET /v3/tier-stats` answered from the process that
//! happened to receive the request, so the same query returned different
//! numbers depending on which node a client reached, with nothing in the body
//! saying so. These tests pin the two properties that fix costs: the answer is
//! node-independent, and it states how much of the cluster it covers.
use crate::common::{RustFSTestClusterEnvironment, admin_request, init_logging};
use http::Method;
use http::StatusCode;
use serde_json::Value;
use std::time::Duration;
use tokio::time::{Instant, sleep};
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
const TIER_STATS_PATH: &str = "/rustfs/admin/v3/tier-stats";
const PEER_CONVERGENCE_TIMEOUT: Duration = Duration::from_secs(30);
async fn tier_stats(cluster: &RustFSTestClusterEnvironment, node: usize, query: &str) -> TestResult<(StatusCode, String)> {
admin_request(
&cluster.nodes[node].url,
Method::GET,
&format!("{TIER_STATS_PATH}{query}"),
None,
&cluster.access_key,
&cluster.secret_key,
)
.await
}
async fn tier_stats_json(cluster: &RustFSTestClusterEnvironment, node: usize) -> TestResult<Value> {
let (status, body) = tier_stats(cluster, node, "").await?;
assert_eq!(status, StatusCode::OK, "node {node} must answer tier-stats: {body}");
Ok(serde_json::from_str(&body)?)
}
/// Wait until every node reports the whole cluster.
///
/// Peer clients are established during startup, so a query issued in the first
/// moments can legitimately see a peer as unavailable. Polling separates that
/// startup window from the failure this test is about: an answer that stays
/// partial because the peer never reports at all.
async fn wait_for_complete_activity(cluster: &RustFSTestClusterEnvironment) -> TestResult<Vec<Value>> {
let deadline = Instant::now() + PEER_CONVERGENCE_TIMEOUT;
loop {
let mut bodies = Vec::with_capacity(cluster.nodes.len());
for node in 0..cluster.nodes.len() {
bodies.push(tier_stats_json(cluster, node).await?);
}
if bodies.iter().all(|body| body["activity"]["status"] == "complete") {
return Ok(bodies);
}
if Instant::now() >= deadline {
return Err(format!("tier-stats activity never became complete on every node: {bodies:?}").into());
}
sleep(Duration::from_millis(500)).await;
}
}
#[tokio::test]
async fn tier_stats_answers_the_same_cluster_result_from_either_node() -> TestResult {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
cluster.start().await?;
let bodies = wait_for_complete_activity(&cluster).await?;
let (first, second) = (&bodies[0], &bodies[1]);
for (node, body) in bodies.iter().enumerate() {
assert_eq!(body["contractVersion"], 2, "node {node} must name the current contract version");
assert_eq!(
body["activity"]["nodesExpected"], 2,
"node {node} must expect both cluster members, not only itself"
);
assert_eq!(
body["activity"]["nodesReporting"], 2,
"node {node} must include its peer's rolling window in the sum"
);
assert_eq!(
body["activity"]["unavailableNodes"],
serde_json::json!([]),
"node {node} reported a complete result while naming an unavailable member"
);
}
// A fixture cluster has no remote tier, so this pair is equal over an
// empty list; `nodesReporting` above is what proves the peer answered.
// The per-tier summing itself is pinned by the aggregator unit tests.
assert_eq!(
first["tiers"], second["tiers"],
"the same query must not depend on which node received it"
);
assert_eq!(
first["inventory"]["status"], second["inventory"]["status"],
"both nodes read the same persisted usage snapshot"
);
cluster.stop();
Ok(())
}
#[tokio::test]
async fn tier_stats_keeps_the_legacy_body_reachable_and_rejects_unknown_formats() -> TestResult {
init_logging();
let mut cluster = RustFSTestClusterEnvironment::new(2).await?;
cluster.start().await?;
let (status, body) = tier_stats(&cluster, 0, "?format=legacy").await?;
assert_eq!(status, StatusCode::OK, "the pinned legacy body must stay reachable: {body}");
let legacy: Value = serde_json::from_str(&body)?;
assert!(
legacy.is_object() && legacy.get("contractVersion").is_none(),
"the legacy body is the bare tier map, not the current envelope: {legacy}"
);
let (status, body) = tier_stats(&cluster, 0, "?format=v3").await?;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"an unknown format must be rejected rather than answered in another shape: {body}"
);
cluster.stop();
Ok(())
}
@@ -12,22 +12,72 @@
// 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, admin_request, init_logging, replication_fast_env, rustfs_binary_path,
};
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target};
use crate::on_demand_migration::common::{ODM_SERVER_ENV, OdmTestEnv, SeedObject};
use crate::replication_extension_test::{
LOOPBACK_REPLICATION_TARGET_ENV, ReplicationTargetOptions, put_bucket_replication, set_replication_target_with_options,
};
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,
BucketLifecycleConfiguration, BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, DefaultRetention,
ExpirationStatus, LifecycleExpiration, LifecycleRule, LifecycleRuleFilter, ObjectLockConfiguration, ObjectLockEnabled,
ObjectLockRetentionMode, ObjectLockRule, PublicAccessBlockConfiguration, ServerSideEncryption, ServerSideEncryptionByDefault,
ServerSideEncryptionConfiguration, ServerSideEncryptionRule, Tag, Tagging, VersioningConfiguration,
};
use std::path::PathBuf;
use http::{Method, StatusCode};
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>>;
type BoxError = Box<dyn std::error::Error + Send + Sync>;
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
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);
// Bucket-configuration upgrade/rollback scenarios (rustfs#7172, #7183, #7089).
const CONFIG_PLAIN_BUCKET: &str = "upgrade-config-plain";
const CONFIG_ENCRYPTED_BUCKET: &str = "upgrade-config-encrypted";
const CONFIG_REPLICATED_BUCKET: &str = "upgrade-config-replicated";
const CONFIG_LOCKED_BUCKET: &str = "upgrade-config-locked";
const CONFIG_REPLICA_BUCKET: &str = "upgrade-config-replica";
const ROLLBACK_BUCKET: &str = "rollback-config-data";
const ROLLBACK_REPLICA_BUCKET: &str = "rollback-config-replica";
const BUCKET_QUOTA_BYTES: u64 = 64 * 1024 * 1024;
const LIFECYCLE_RULE_ID: &str = "upgrade-expire-logs";
const LIFECYCLE_PREFIX: &str = "logs/";
const LIFECYCLE_DAYS: i32 = 30;
const BUCKET_TAG_KEY: &str = "owner";
const BUCKET_TAG_VALUE: &str = "upgrade-compatibility";
const OBJECT_LOCK_DAYS: i32 = 1;
// `set-bucket-quota` answers 503 until the scanner has made the bucket's usage
// authoritative; the quota test uses the same 30s budget.
const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
// Quota admission fails closed while a freshly started server has neither
// authoritative usage nor a persisted degraded baseline for the bucket
// (rustfs#5716), so a write to a quota-enabled bucket is retryable-503 for that
// window. It is a restart property, not an upgrade property — the same window
// opens on the very first start — so the write assertions ride it out instead
// of treating it as an upgrade failure.
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let path = std::env::var_os(SOURCE_BINARY_ENV)
@@ -103,6 +153,219 @@ 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(())
}
/// Pins the published old writer's limitation and the supported recovery
/// procedure. This is not a promise that mixed-version ODM is supported.
/// Replace the loss assertion when ODM gains independent persistence;
/// preserving configuration across rc.5 writes is then an improvement.
#[tokio::test]
#[ignore = "requires the pinned 1.0.0-rc.5 release binary"]
async fn rc5_rollback_requires_restoring_odm_configuration() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let version = tokio::process::Command::new(&previous_binary)
.arg("--version")
.output()
.await?;
assert!(version.status.success(), "previous binary must report its version");
assert!(
String::from_utf8(version.stdout)?.contains(RC5_COMMIT),
"this compatibility scenario requires the published rc.5 writer"
);
let mut env = OdmTestEnv::start().await?;
let bucket = "odm-rc5-rollback";
let source_bucket = "odm-rc5-source";
env.source.create_bucket_with_mode(source_bucket, BucketMode::Unversioned);
env.seed_source(
source_bucket,
&[SeedObject::new(
"source-only",
bytes::Bytes::from_static(b"source read after recovery"),
)],
);
env.rustfs.create_test_bucket(bucket).await?;
let saved_config = env.fake_source_spec(source_bucket);
assert_eq!(env.configure_source(bucket, &saved_config).await?.status, 200);
let before = env.get_config(bucket).await?;
assert_eq!(before.status, 200);
let expected_config = before
.json()?
.get("config")
.cloned()
.ok_or("configuration response omitted config")?;
env.client
.put_object()
.bucket(bucket)
.key("local")
.body(ByteStream::from_static(b"local data survives rollback"))
.send()
.await?;
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
let restarted = env.get_config(bucket).await?;
assert_eq!(restarted.status, 200, "a current writer preserves ODM across restart");
assert_eq!(restarted.json()?.get("config"), Some(&expected_config));
restart_from_binary(&mut env.rustfs, &previous_binary, &[]).await?;
env.client
.put_bucket_tagging()
.bucket(bucket)
.tagging(
Tagging::builder()
.tag_set(Tag::builder().key("writer").value("rc5").build()?)
.build()?,
)
.send()
.await?;
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
let missing = env.get_config(bucket).await?;
assert_eq!(missing.status, 404, "rc.5 rewrites metadata without ODM keys");
assert!(missing.body.contains("NoSuchConfiguration"));
assert_eq!(read_object(&env.client, bucket, "local", None).await?.1, b"local data survives rollback");
let tags = env.client.get_bucket_tagging().bucket(bucket).send().await?;
assert!(tags.tag_set().iter().any(|tag| tag.key() == "writer" && tag.value() == "rc5"));
assert_eq!(
env.configure_source(bucket, &saved_config).await?.status,
200,
"restore from saved full configuration"
);
env.rustfs.restart_server_preserving_data(vec![], ODM_SERVER_ENV).await?;
let restored = env.get_config(bucket).await?;
assert_eq!(restored.status, 200, "restored ODM configuration persists");
assert_eq!(restored.json()?.get("config"), Some(&expected_config));
env.wait_until_source_consulted(bucket).await?;
assert_eq!(
read_object(&env.client, bucket, "source-only", None).await?.1,
b"source read after recovery"
);
Ok(())
}
#[tokio::test]
#[ignore = "requires a pinned previous RustFS release binary"]
async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
@@ -252,3 +515,693 @@ 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(())
}
/// Child-process environment shared by both bucket-configuration scenarios.
///
/// The replication target is an in-process fake bound to `127.0.0.1`, which
/// `set-remote-target` rejects as an SSRF risk without the loopback opt-in, and
/// the proxy bypass keeps a developer's `HTTP_PROXY` from intercepting the
/// server's outbound health check.
fn bucket_config_server_env() -> Vec<(&'static str, &'static str)> {
let mut env = vec![
(SSE_MASTER_KEY_ENV, SSE_MASTER_KEY),
("NO_PROXY", "127.0.0.1,localhost"),
("HTTP_PROXY", ""),
("HTTPS_PROXY", ""),
// Shorten the scanner cycle so the bucket's usage becomes authoritative
// in seconds; both `set-bucket-quota` and quota admission block on it.
("RUSTFS_SCANNER_CYCLE", "1"),
("RUSTFS_SCANNER_START_DELAY_SECS", "0"),
];
env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
env.extend(replication_fast_env());
env
}
/// Restart `env` in place on the same data directory using an explicit binary.
///
/// [`RustFSTestEnvironment::restart_server_preserving_data`] always relaunches
/// the workspace build, which is the upgrade direction only. The rollback
/// scenario needs the reverse: stop the current build and bring the pinned
/// previous release up on the metadata that build just wrote.
async fn restart_from_binary(env: &mut RustFSTestEnvironment, binary: &Path, server_env: &[(&str, &str)]) -> TestResult {
env.stop_server();
env.start_rustfs_server_from_binary(binary, vec![], server_env).await
}
async fn set_bucket_quota(env: &RustFSTestEnvironment, bucket: &str, quota_bytes: u64) -> TestResult {
let path = format!("/rustfs/admin/v3/quota/{bucket}");
let body = serde_json::json!({ "quota": quota_bytes, "quota_type": "HARD" }).to_string();
let deadline = Instant::now() + QUOTA_READINESS_TIMEOUT;
loop {
let (status, response) =
admin_request(&env.url, Method::PUT, &path, Some(body.clone()), &env.access_key, &env.secret_key).await?;
if status.is_success() {
return Ok(());
}
if status != StatusCode::SERVICE_UNAVAILABLE || Instant::now() >= deadline {
return Err(format!("setting the quota of {bucket} failed: {status} {response}").into());
}
sleep(Duration::from_millis(500)).await;
}
}
/// PUT into a quota-enabled bucket, riding out the post-start quota-admission
/// warm-up described on [`QUOTA_ADMISSION_WARMUP_TIMEOUT`].
///
/// Only `ServiceUnavailable` is retried: any other failure, and a warm-up that
/// never ends, is a genuine regression and surfaces as an error.
async fn put_object_through_quota_warmup(client: &Client, bucket: &str, key: &str, body: &'static [u8]) -> TestResult {
let deadline = Instant::now() + QUOTA_ADMISSION_WARMUP_TIMEOUT;
loop {
let result = client
.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from_static(body))
.send()
.await;
let error = match result {
Ok(_) => return Ok(()),
Err(error) => error,
};
let retryable = error.as_service_error().and_then(ProvideErrorMetadata::code) == Some("ServiceUnavailable");
if !retryable || Instant::now() >= deadline {
return Err(format!("PUT {bucket}/{key} failed after the quota warm-up window: {error}").into());
}
sleep(Duration::from_millis(500)).await;
}
}
async fn get_bucket_quota(env: &RustFSTestEnvironment, bucket: &str) -> Result<Option<u64>, BoxError> {
let path = format!("/rustfs/admin/v3/quota/{bucket}");
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
if status != StatusCode::OK {
return Err(format!("reading the quota of {bucket} failed: {status} {response}").into());
}
let quota: serde_json::Value = serde_json::from_str(&response)?;
Ok(quota.get("quota").and_then(serde_json::Value::as_u64))
}
/// `GET /rustfs/admin/v3/list-remote-targets?bucket=...`.
///
/// Returns an error for any non-200, because rustfs#7172 made this endpoint
/// fail closed on a `bucket-targets.json` blob the running build cannot parse.
/// An upgrade that misreads a blob written by the previous release therefore
/// shows up here as an error, and a silently dropped target shows up as an
/// empty list — the caller must distinguish the two.
async fn list_remote_targets(env: &RustFSTestEnvironment, bucket: &str) -> Result<Vec<serde_json::Value>, BoxError> {
let path = format!("/rustfs/admin/v3/list-remote-targets?bucket={}", urlencoding::encode(bucket));
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
if status != StatusCode::OK {
return Err(format!("list-remote-targets for {bucket} failed: {status} {response}").into());
}
Ok(serde_json::from_str(&response)?)
}
/// Assert that `bucket` still carries exactly the replication target `arn`.
async fn assert_remote_target_preserved(env: &RustFSTestEnvironment, bucket: &str, arn: &str, context: &str) -> TestResult {
let targets = list_remote_targets(env, bucket).await?;
assert_eq!(
targets.len(),
1,
"{context}: list-remote-targets must still report the single configured target, got {targets:?}"
);
assert_eq!(
targets[0].get("arn").and_then(serde_json::Value::as_str),
Some(arn),
"{context}: the target ARN changed across the restart: {targets:?}"
);
Ok(())
}
/// Configure a replication target on `bucket` pointing at the in-process fake,
/// then attach an enabled replication rule for it. Returns the target ARN.
async fn configure_replication(
env: &RustFSTestEnvironment,
bucket: &str,
target: &FakeS3Target,
target_bucket: &str,
) -> Result<String, BoxError> {
let arn = set_replication_target_with_options(
env,
bucket,
ReplicationTargetOptions {
endpoint: &target.address(),
access_key: FAKE_ACCESS_KEY,
secret_key: FAKE_SECRET_KEY,
target_bucket,
secure: false,
skip_tls_verify: false,
ca_cert_pem: None,
},
)
.await?;
put_bucket_replication(env, bucket, &arn).await?;
Ok(arn)
}
async fn put_default_sse_s3_encryption(client: &Client, bucket: &str) -> TestResult {
let configuration = ServerSideEncryptionConfiguration::builder()
.rules(
ServerSideEncryptionRule::builder()
.apply_server_side_encryption_by_default(
ServerSideEncryptionByDefault::builder()
.sse_algorithm(ServerSideEncryption::Aes256)
.build()?,
)
.build(),
)
.build()?;
client
.put_bucket_encryption()
.bucket(bucket)
.server_side_encryption_configuration(configuration)
.send()
.await?;
Ok(())
}
async fn assert_default_sse_s3_encryption(client: &Client, bucket: &str, context: &str) -> TestResult {
let response = client.get_bucket_encryption().bucket(bucket).send().await?;
let rules = response
.server_side_encryption_configuration()
.ok_or("GetBucketEncryption omitted the configuration")?
.rules();
assert_eq!(rules.len(), 1, "{context}: expected exactly one encryption rule, got {rules:?}");
assert_eq!(
rules[0]
.apply_server_side_encryption_by_default()
.map(ServerSideEncryptionByDefault::sse_algorithm),
Some(&ServerSideEncryption::Aes256),
"{context}: the default encryption algorithm changed"
);
Ok(())
}
async fn put_bucket_tag(client: &Client, bucket: &str) -> TestResult {
let tagging = Tagging::builder()
.tag_set(Tag::builder().key(BUCKET_TAG_KEY).value(BUCKET_TAG_VALUE).build()?)
.build()?;
client.put_bucket_tagging().bucket(bucket).tagging(tagging).send().await?;
Ok(())
}
async fn assert_bucket_tag(client: &Client, bucket: &str, context: &str) -> TestResult {
let tags = client.get_bucket_tagging().bucket(bucket).send().await?;
let tag_set = tags.tag_set();
assert_eq!(tag_set.len(), 1, "{context}: expected exactly one bucket tag, got {tag_set:?}");
assert_eq!(tag_set[0].key(), BUCKET_TAG_KEY, "{context}: bucket tag key changed");
assert_eq!(tag_set[0].value(), BUCKET_TAG_VALUE, "{context}: bucket tag value changed");
Ok(())
}
async fn assert_versioning_enabled(client: &Client, bucket: &str, context: &str) -> TestResult {
let versioning = client.get_bucket_versioning().bucket(bucket).send().await?;
assert_eq!(
versioning.status(),
Some(&BucketVersioningStatus::Enabled),
"{context}: versioning is no longer Enabled on {bucket}"
);
Ok(())
}
fn bucket_policy_document(bucket: &str) -> serde_json::Value {
serde_json::json!({
"Version": "2012-10-17",
"Statement": [{
"Sid": "UpgradePublicRead",
"Effect": "Allow",
"Principal": { "AWS": ["*"] },
"Action": ["s3:GetObject"],
"Resource": [format!("arn:aws:s3:::{bucket}/public/*")]
}]
})
}
/// `GET .../on-demand-migration/{bucket}/status`.
///
/// The migration module defaults on from rustfs#7089, so a bucket that never
/// configured a source must still answer `configured: false` rather than
/// engaging the migration path.
async fn assert_migration_not_configured(env: &RustFSTestEnvironment, bucket: &str) -> TestResult {
let path = format!("/rustfs/admin/v3/on-demand-migration/{bucket}/status");
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
assert_eq!(
status,
StatusCode::OK,
"the migration status endpoint must answer for an unconfigured bucket: {status} {response}"
);
let body: serde_json::Value = serde_json::from_str(&response)?;
assert_eq!(
body.get("configured"),
Some(&serde_json::Value::Bool(false)),
"a bucket upgraded from the previous release must not look migration-configured: {body}"
);
Ok(())
}
/// A GET for a key that was never written must be a plain `NoSuchKey`.
///
/// With the migration module on by default this is the cheap proof that an
/// unconfigured bucket never consults a source: any migration engagement would
/// surface as a different status or error code here.
async fn assert_missing_key_is_no_such_key(client: &Client, bucket: &str, key: &str) -> TestResult {
let error = client
.get_object()
.bucket(bucket)
.key(key)
.send()
.await
.expect_err("a key that was never written must not be readable");
assert_eq!(
error.raw_response().map(|response| response.status().as_u16()),
Some(404),
"a missing key must stay a 404 on a bucket with no migration configuration"
);
assert_eq!(
error.as_service_error().and_then(ProvideErrorMetadata::code),
Some("NoSuchKey"),
"a missing key must stay NoSuchKey on a bucket with no migration configuration"
);
Ok(())
}
/// Bucket configuration written by the pinned previous release must survive an
/// upgrade to the current build unchanged, and must keep working.
///
/// This pins the three on-disk surfaces the on-demand-migration series moved:
///
/// * `BucketMetadata` grew two msgpack keys (encoded map length 44 -> 46), so
/// every configuration read below decodes a 44-key blob on 46-key code.
/// * rustfs#7172 made an unreadable `bucket-targets.json` / encryption /
/// public-access-block / quota blob "present but unreadable" instead of
/// silently defaulting, and made `list-remote-targets` fail closed on it. A
/// replication target configured by the old release must therefore still be
/// *listed*, not dropped and not an error.
/// * rustfs#7183 made the object write path refuse a PUT when the bucket's
/// encryption configuration cannot be read, so a misparsed SSE config would
/// turn every PUT to that bucket into a 500.
///
/// Not covered on purpose: on-demand-migration configuration itself, which the
/// previous release has no public API for — the reverse direction is asserted
/// instead (an upgraded bucket reports `configured: false`).
#[tokio::test]
#[ignore = "requires a pinned previous RustFS release binary"]
async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
// In-process: the fake target outlives both server processes, so the
// replication target stays reachable across the upgrade.
let replication_target = FakeS3Target::start().await?;
replication_target.create_bucket(CONFIG_REPLICA_BUCKET);
let mut env = RustFSTestEnvironment::new().await?;
let server_env = bucket_config_server_env();
env.start_rustfs_server_from_binary(&previous_binary, vec![], &server_env)
.await?;
let old_client = env.create_s3_client();
env.create_test_bucket(CONFIG_PLAIN_BUCKET).await?;
env.create_test_bucket(CONFIG_ENCRYPTED_BUCKET).await?;
env.create_test_bucket(CONFIG_REPLICATED_BUCKET).await?;
old_client
.create_bucket()
.bucket(CONFIG_LOCKED_BUCKET)
.object_lock_enabled_for_bucket(true)
.send()
.await?;
// Plain bucket: policy, tags, lifecycle, quota.
let policy = bucket_policy_document(CONFIG_PLAIN_BUCKET);
old_client
.put_bucket_policy()
.bucket(CONFIG_PLAIN_BUCKET)
.policy(policy.to_string())
.send()
.await?;
put_bucket_tag(&old_client, CONFIG_PLAIN_BUCKET).await?;
old_client
.put_bucket_lifecycle_configuration()
.bucket(CONFIG_PLAIN_BUCKET)
.lifecycle_configuration(
BucketLifecycleConfiguration::builder()
.rules(
LifecycleRule::builder()
.id(LIFECYCLE_RULE_ID)
.status(ExpirationStatus::Enabled)
.filter(LifecycleRuleFilter::builder().prefix(LIFECYCLE_PREFIX).build())
.expiration(LifecycleExpiration::builder().days(LIFECYCLE_DAYS).build())
.build()?,
)
.build()?,
)
.send()
.await?;
set_bucket_quota(&env, CONFIG_PLAIN_BUCKET, BUCKET_QUOTA_BYTES).await?;
// Encrypted bucket: SSE-S3 default encryption plus a fully restrictive
// public access block, both of which rustfs#7172 now fails closed on.
put_default_sse_s3_encryption(&old_client, CONFIG_ENCRYPTED_BUCKET).await?;
old_client
.put_public_access_block()
.bucket(CONFIG_ENCRYPTED_BUCKET)
.public_access_block_configuration(
PublicAccessBlockConfiguration::builder()
.block_public_acls(true)
.ignore_public_acls(true)
.block_public_policy(true)
.restrict_public_buckets(true)
.build(),
)
.send()
.await?;
// Replicated bucket: versioning, a validated remote target, a rule.
enable_versioning(&old_client, CONFIG_REPLICATED_BUCKET).await?;
let target_arn = configure_replication(&env, CONFIG_REPLICATED_BUCKET, &replication_target, CONFIG_REPLICA_BUCKET).await?;
assert_remote_target_preserved(&env, CONFIG_REPLICATED_BUCKET, &target_arn, "before the upgrade").await?;
// Object-lock bucket: a default GOVERNANCE retention on a fresh bucket.
old_client
.put_object_lock_configuration()
.bucket(CONFIG_LOCKED_BUCKET)
.object_lock_configuration(
ObjectLockConfiguration::builder()
.object_lock_enabled(ObjectLockEnabled::Enabled)
.rule(
ObjectLockRule::builder()
.default_retention(
DefaultRetention::builder()
.mode(ObjectLockRetentionMode::Governance)
.days(OBJECT_LOCK_DAYS)
.build(),
)
.build(),
)
.build(),
)
.send()
.await?;
let plain_key = "plain/written-by-previous";
let plain_bytes = b"plain object written by the previous RustFS release";
put_object_through_quota_warmup(&old_client, CONFIG_PLAIN_BUCKET, plain_key, plain_bytes).await?;
let encrypted_key = "encrypted/written-by-previous";
let encrypted_bytes = b"default-encrypted object written by the previous RustFS release";
old_client
.put_object()
.bucket(CONFIG_ENCRYPTED_BUCKET)
.key(encrypted_key)
.body(ByteStream::from_static(encrypted_bytes))
.send()
.await?;
assert_eq!(
read_object(&old_client, CONFIG_ENCRYPTED_BUCKET, encrypted_key, None)
.await?
.0,
Some(ServerSideEncryption::Aes256),
"the previous release must apply the bucket default encryption it just accepted"
);
// The multipart object lives in the default-encrypted bucket so the
// upgraded build has to reassemble parts *and* re-derive the object key.
let multipart_key = "encrypted/multipart-written-by-previous";
let multipart_parts = vec![vec![b'm'; 5 * 1024 * 1024], b"final multipart bytes".to_vec()];
let multipart_bytes = multipart_parts.concat();
write_multipart(&old_client, CONFIG_ENCRYPTED_BUCKET, multipart_key, &multipart_parts).await?;
let versioned_key = "versioned/written-by-previous";
let versioned_bytes = b"versioned object written by the previous RustFS release";
let versioned_id = old_client
.put_object()
.bucket(CONFIG_REPLICATED_BUCKET)
.key(versioned_key)
.body(ByteStream::from_static(versioned_bytes))
.send()
.await?
.version_id()
.ok_or("versioned PUT omitted version ID")?
.to_string();
env.restart_server_preserving_data(vec![], &server_env).await?;
let new_client = env.create_s3_client();
// Every configuration must read back unchanged on the upgraded build.
let upgraded_policy = new_client.get_bucket_policy().bucket(CONFIG_PLAIN_BUCKET).send().await?;
let upgraded_policy: serde_json::Value =
serde_json::from_str(upgraded_policy.policy().ok_or("GetBucketPolicy omitted the document")?)?;
assert_eq!(upgraded_policy, policy, "the bucket policy changed across the upgrade");
assert_bucket_tag(&new_client, CONFIG_PLAIN_BUCKET, "after the upgrade").await?;
let lifecycle = new_client
.get_bucket_lifecycle_configuration()
.bucket(CONFIG_PLAIN_BUCKET)
.send()
.await?;
let rules = lifecycle.rules();
assert_eq!(rules.len(), 1, "the lifecycle rule count changed across the upgrade: {rules:?}");
assert_eq!(rules[0].id(), Some(LIFECYCLE_RULE_ID));
assert_eq!(rules[0].status(), &ExpirationStatus::Enabled);
assert_eq!(
rules[0].expiration().and_then(LifecycleExpiration::days),
Some(LIFECYCLE_DAYS),
"the lifecycle expiration changed across the upgrade"
);
assert_eq!(
get_bucket_quota(&env, CONFIG_PLAIN_BUCKET).await?,
Some(BUCKET_QUOTA_BYTES),
"the bucket quota changed across the upgrade"
);
assert_default_sse_s3_encryption(&new_client, CONFIG_ENCRYPTED_BUCKET, "after the upgrade").await?;
let public_access_block = new_client
.get_public_access_block()
.bucket(CONFIG_ENCRYPTED_BUCKET)
.send()
.await?;
let public_access_block = public_access_block
.public_access_block_configuration()
.ok_or("GetPublicAccessBlock omitted the configuration")?;
assert_eq!(public_access_block.block_public_acls(), Some(true));
assert_eq!(public_access_block.ignore_public_acls(), Some(true));
assert_eq!(public_access_block.block_public_policy(), Some(true));
assert_eq!(public_access_block.restrict_public_buckets(), Some(true));
assert_versioning_enabled(&new_client, CONFIG_REPLICATED_BUCKET, "after the upgrade").await?;
// rustfs#7172: neither an empty list nor an error is acceptable here.
assert_remote_target_preserved(&env, CONFIG_REPLICATED_BUCKET, &target_arn, "after the upgrade").await?;
let replication = new_client
.get_bucket_replication()
.bucket(CONFIG_REPLICATED_BUCKET)
.send()
.await?;
let replication_rules = replication
.replication_configuration()
.ok_or("GetBucketReplication omitted the configuration")?
.rules();
assert_eq!(
replication_rules.len(),
1,
"the replication rule count changed across the upgrade: {replication_rules:?}"
);
assert_eq!(
replication_rules[0].destination().map(|destination| destination.bucket()),
Some(target_arn.as_str()),
"the replication rule no longer points at the configured target"
);
let object_lock = new_client
.get_object_lock_configuration()
.bucket(CONFIG_LOCKED_BUCKET)
.send()
.await?;
let object_lock = object_lock
.object_lock_configuration()
.ok_or("GetObjectLockConfiguration omitted the configuration")?;
assert_eq!(object_lock.object_lock_enabled(), Some(&ObjectLockEnabled::Enabled));
let retention = object_lock
.rule()
.and_then(ObjectLockRule::default_retention)
.ok_or("the object lock configuration lost its default retention")?;
assert_eq!(retention.mode(), Some(&ObjectLockRetentionMode::Governance));
assert_eq!(retention.days(), Some(OBJECT_LOCK_DAYS));
// rustfs#7183: a PUT into the default-encrypted bucket must still succeed
// and still come back encrypted.
let post_upgrade_encrypted_key = "encrypted/written-after-upgrade";
let post_upgrade_encrypted_bytes = b"default-encrypted object written by the current RustFS build";
new_client
.put_object()
.bucket(CONFIG_ENCRYPTED_BUCKET)
.key(post_upgrade_encrypted_key)
.body(ByteStream::from_static(post_upgrade_encrypted_bytes))
.send()
.await?;
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, post_upgrade_encrypted_key, None).await?;
assert_eq!(
encryption,
Some(ServerSideEncryption::Aes256),
"a PUT after the upgrade lost the bucket default encryption"
);
assert_eq!(body, post_upgrade_encrypted_bytes);
let post_upgrade_plain_key = "plain/written-after-upgrade";
let post_upgrade_plain_bytes = b"plain object written by the current RustFS build";
put_object_through_quota_warmup(&new_client, CONFIG_PLAIN_BUCKET, post_upgrade_plain_key, post_upgrade_plain_bytes).await?;
let (encryption, body) = read_object(&new_client, CONFIG_PLAIN_BUCKET, post_upgrade_plain_key, None).await?;
assert_eq!(encryption, None, "a bucket without default encryption must not encrypt a PUT");
assert_eq!(body, post_upgrade_plain_bytes);
// Every object written by the previous release reads back byte-identical.
assert_eq!(read_object(&new_client, CONFIG_PLAIN_BUCKET, plain_key, None).await?.1, plain_bytes);
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, encrypted_key, None).await?;
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
assert_eq!(body, encrypted_bytes);
let (encryption, body) = read_object(&new_client, CONFIG_ENCRYPTED_BUCKET, multipart_key, None).await?;
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
assert_eq!(body, multipart_bytes, "the multipart object did not survive the upgrade");
assert_eq!(
read_object(&new_client, CONFIG_REPLICATED_BUCKET, versioned_key, Some(&versioned_id))
.await?
.1,
versioned_bytes
);
// rustfs#7089: the migration module is on by default, but a bucket that
// never configured a source behaves exactly as before.
assert_migration_not_configured(&env, CONFIG_PLAIN_BUCKET).await?;
assert_missing_key_is_no_such_key(&new_client, CONFIG_PLAIN_BUCKET, "plain/never-written").await?;
replication_target.shutdown().await;
Ok(())
}
/// Rolling back to the pinned previous release must still read the bucket
/// metadata the current build wrote.
///
/// This is the other half of the `BucketMetadata` 44 -> 46 key change: the
/// current build writes a 46-key msgpack map with `OnDemandMigrationConfigJSON`
/// and `OnDemandMigrationConfigUpdatedAt`, and the previous release's decoder
/// has to skip those two unknown keys instead of failing the whole blob. If it
/// did not, every configuration read below would come back empty or error and
/// the rollback would silently discard the bucket's configuration.
#[tokio::test]
#[ignore = "requires a pinned previous RustFS release binary"]
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
init_logging();
let previous_binary = source_binary()?;
let replication_target = FakeS3Target::start().await?;
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
let mut env = RustFSTestEnvironment::new().await?;
let server_env = bucket_config_server_env();
env.start_rustfs_server_with_env(vec![], &server_env).await?;
let new_client = env.create_s3_client();
env.create_test_bucket(ROLLBACK_BUCKET).await?;
enable_versioning(&new_client, ROLLBACK_BUCKET).await?;
put_default_sse_s3_encryption(&new_client, ROLLBACK_BUCKET).await?;
put_bucket_tag(&new_client, ROLLBACK_BUCKET).await?;
let target_arn = configure_replication(&env, ROLLBACK_BUCKET, &replication_target, ROLLBACK_REPLICA_BUCKET).await?;
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "before the rollback").await?;
let single_key = "rollback/single";
let single_bytes = b"single-part object written by the current RustFS build";
let single_version = new_client
.put_object()
.bucket(ROLLBACK_BUCKET)
.key(single_key)
.body(ByteStream::from_static(single_bytes))
.send()
.await?
.version_id()
.ok_or("versioned PUT omitted version ID")?
.to_string();
let multipart_key = "rollback/multipart";
let multipart_parts = vec![vec![b'r'; 5 * 1024 * 1024], b"final rollback bytes".to_vec()];
let multipart_bytes = multipart_parts.concat();
write_multipart(&new_client, ROLLBACK_BUCKET, multipart_key, &multipart_parts).await?;
restart_from_binary(&mut env, &previous_binary, &server_env).await?;
let old_client = env.create_s3_client();
assert_versioning_enabled(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
assert_default_sse_s3_encryption(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
assert_bucket_tag(&old_client, ROLLBACK_BUCKET, "after the rollback").await?;
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after the rollback").await?;
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, single_key, Some(&single_version)).await?;
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
assert_eq!(body, single_bytes);
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, multipart_key, None).await?;
assert_eq!(encryption, Some(ServerSideEncryption::Aes256));
assert_eq!(body, multipart_bytes, "the multipart object did not survive the rollback");
// A PUT on the rolled-back release must still honour the encryption
// configuration it decoded out of the current build's metadata blob.
let post_rollback_key = "rollback/written-after-rollback";
let post_rollback_bytes = b"object written by the previous RustFS release after the rollback";
old_client
.put_object()
.bucket(ROLLBACK_BUCKET)
.key(post_rollback_key)
.body(ByteStream::from_static(post_rollback_bytes))
.send()
.await?;
let (encryption, body) = read_object(&old_client, ROLLBACK_BUCKET, post_rollback_key, None).await?;
assert_eq!(
encryption,
Some(ServerSideEncryption::Aes256),
"the rolled-back release lost the bucket default encryption"
);
assert_eq!(body, post_rollback_bytes);
replication_target.shutdown().await;
Ok(())
}

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