mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 12:09:12 +00:00
Compare commits
421 Commits
1.0.0-rc.4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c130d00d4b | |||
| e49d9cdea2 | |||
| f5778d8b97 | |||
| 9da42a899a | |||
| 3d46ed312a | |||
| 3496277e7c | |||
| 0b72f39023 | |||
| 30a0937a7d | |||
| 5b962b6c58 | |||
| 0ee5408b94 | |||
| cb3100a252 | |||
| 3a4afe9b38 | |||
| 71859ff83c | |||
| cb72df269a | |||
| d7b7d1835f | |||
| b92392a04a | |||
| a40ec8a6f3 | |||
| c99efd9477 | |||
| 27d593fa35 | |||
| 0137d14064 | |||
| 1dddf357cd | |||
| 395ba797fc | |||
| 51893abfbf | |||
| 92c17af8e3 | |||
| f17f31a3df | |||
| 1a88870809 | |||
| 760c9d65be | |||
| 08283d1fdc | |||
| 6a323c3e91 | |||
| 1650f3c2a6 | |||
| 38611d2510 | |||
| 1a459d650f | |||
| 5aef1796cc | |||
| a8aaadb886 | |||
| 71f1dcf859 | |||
| d44244f60f | |||
| d3884ed3ea | |||
| 282d6d5efe | |||
| f0d865728c | |||
| 3df39ef4d7 | |||
| b0c73c1224 | |||
| 54c11ef28b | |||
| e99c41a9bf | |||
| fddecf0afe | |||
| 55fd73ed48 | |||
| 1ae80c41ec | |||
| 9fc9b5e69c | |||
| 61e0edce16 | |||
| 2b5c739343 | |||
| 5dca076efe | |||
| 07833379b4 | |||
| 6655272c90 | |||
| 0a5d4cef0e | |||
| 941fae61a0 | |||
| 03fa62cc7d | |||
| b59dea826f | |||
| 081a8b61d8 | |||
| 159dc13548 | |||
| bde4b78a9f | |||
| c861fe3a57 | |||
| e1608fbd9c | |||
| eb1b17802c | |||
| 112f70914d | |||
| ea9aa53fd8 | |||
| dd368f0f5b | |||
| 6d8606412e | |||
| 037354cec0 | |||
| 30ab919bb3 | |||
| 8fc1c9281e | |||
| 14cef91423 | |||
| c9acc33720 | |||
| 955d491174 | |||
| 1c4e9f1b65 | |||
| a9f01dbbdb | |||
| 35aefbb2a5 | |||
| 8fb335cf19 | |||
| 8f763fb1a2 | |||
| a6b5da64f2 | |||
| 1210428b6d | |||
| d5426f59ec | |||
| f54323b062 | |||
| cc5060ac20 | |||
| 188f380b3b | |||
| e2a921bc16 | |||
| 447f3c704b | |||
| d915f9565e | |||
| 55ad7508b9 | |||
| 33fd056000 | |||
| af2e9df821 | |||
| 0a92a7d98c | |||
| c589fd2439 | |||
| 3e5d4ebb09 | |||
| 3677871468 | |||
| cc1ec6b992 | |||
| 9e2545244c | |||
| f053862aad | |||
| e8a7f4bc4a | |||
| 7ba5cd6888 | |||
| acfeef55ab | |||
| 0d1b312673 | |||
| 42c32381b6 | |||
| e6bf2a4646 | |||
| cf9688898d | |||
| 2d159635ed | |||
| 2f02d1d2d8 | |||
| 8ae8fb7eea | |||
| bbd7b9ef17 | |||
| 15e9bc5ed0 | |||
| 882d9ca8a4 | |||
| 19a29a7027 | |||
| 2e4ab045b6 | |||
| cbfd5b92f4 | |||
| 971f9acdf4 | |||
| a6589c19e3 | |||
| 0885c721fe | |||
| eaf5159d0f | |||
| 2477e31059 | |||
| d8c3b1bb26 | |||
| a3b8183be9 | |||
| 4dbc58887a | |||
| 123967e729 | |||
| 4b0d597d4d | |||
| 13e6424e99 | |||
| b33693fc19 | |||
| 9ed1d46090 | |||
| 6eb60f8e72 | |||
| 8dd3cabd41 | |||
| e648f683bf | |||
| 193b1b7d3f | |||
| 3b920c7999 | |||
| 5f8b097172 | |||
| 445114577f | |||
| 923bde6904 | |||
| 10ccf7c31a | |||
| 146003a426 | |||
| 65ed86f76e | |||
| 3da3bcd9ee | |||
| 20e4fd7de6 | |||
| da28f8c843 | |||
| 507447da12 | |||
| 81014fd233 | |||
| ff3c5a4989 | |||
| 4d226998e2 | |||
| f6bed1a73a | |||
| 7ff578ff20 | |||
| f16a30b231 | |||
| 6f717b2752 | |||
| 3654c147e2 | |||
| a9373288f2 | |||
| fdb28e611f | |||
| 5f739944a3 | |||
| 2bc88418f5 | |||
| 9d10d69a6d | |||
| 9863f4848d | |||
| 7dfc2ee5f0 | |||
| 80c88a9031 | |||
| 3005efe845 | |||
| 3a914b429d | |||
| 8a8616b8f7 | |||
| 62e66baf89 | |||
| 53cabe9274 | |||
| 0f272ddb14 | |||
| 0181a583a6 | |||
| 0713a723cd | |||
| a6cb34c7a4 | |||
| 703766677b | |||
| f7c51271ee | |||
| 0e6ee3bf62 | |||
| 3ab7a1921f | |||
| 86ebcb325c | |||
| 8023cf3e26 | |||
| 54a7e9f307 | |||
| 5e58b1d3a2 | |||
| df30dff1a7 | |||
| d011ec9952 | |||
| 1747ed0292 | |||
| 74be040c62 | |||
| df4fdef1d8 | |||
| 759a395fec | |||
| d04611ba38 | |||
| 37344a84da | |||
| c1070f1447 | |||
| 8bf569899a | |||
| 98f7e63396 | |||
| 07dce1cab0 | |||
| b727e669db | |||
| 0fe6cc3641 | |||
| 5be9d255e6 | |||
| 817adb127c | |||
| e3349f5f30 | |||
| a5bde8b0af | |||
| a23d4b05a3 | |||
| 183b5c9ede | |||
| 1ab6405ac9 | |||
| 9e0663cbba | |||
| ba20af77bb | |||
| 2231633ae1 | |||
| 2e2bc814b1 | |||
| 1dd81cf276 | |||
| 01db1f6644 | |||
| 7e1f261e38 | |||
| 99f85ca2b1 | |||
| afc66b7182 | |||
| 922552083f | |||
| 32eb116cbc | |||
| 68f47b9219 | |||
| de7422b508 | |||
| 1bbfa71b11 | |||
| c066faf07a | |||
| ca8bbbf8f3 | |||
| bc789332b6 | |||
| b422d1fea9 | |||
| 87bc9d14ea | |||
| fddaeba247 | |||
| b46a8164f8 | |||
| b1faaafb1f | |||
| 0a975f2fe2 | |||
| ceeff52229 | |||
| e04e15aed1 | |||
| afa84fa988 | |||
| 7aaed4d67b | |||
| 36e07e104a | |||
| b03804566c | |||
| 40a2470feb | |||
| 7dcfdb3320 | |||
| 397dbcf102 | |||
| 99073938ae | |||
| d22991f33b | |||
| 194c8643c0 | |||
| 5720c5c748 | |||
| 1941189499 | |||
| bba934723a | |||
| cebe57a2f0 | |||
| 6a8a8a1eaf | |||
| 833cc51534 | |||
| 43450df589 | |||
| 394394cdfc | |||
| af896dc427 | |||
| 297ff4688c | |||
| b9b2aa0b76 | |||
| 1dcdfe4817 | |||
| 6e26769265 | |||
| bd66fa9dca | |||
| 1aea7541c8 | |||
| 9e6d34785b | |||
| 03aecc5c3e | |||
| 45fe54e389 | |||
| 2ed5c297ac | |||
| 23ab078c56 | |||
| 80c629bfe0 | |||
| 47304cc68d | |||
| cee84561e7 | |||
| b09ce8e6b5 | |||
| a45951260a | |||
| a41134eb8a | |||
| 35ce8cdb80 | |||
| 0b1a588da5 | |||
| f6c6736a01 | |||
| ab44ae7e83 | |||
| b0256e3453 | |||
| 14a77f9d79 | |||
| 436a1be899 | |||
| 1ea1dfa0a1 | |||
| c45a8c35c4 | |||
| 4932d1dedf | |||
| 041af14143 | |||
| e44007012b | |||
| e3ca1ca54c | |||
| ec0a65703a | |||
| 0d1e40ee73 | |||
| 281e40f1cc | |||
| 7541bb2c5d | |||
| 25dd879cf4 | |||
| af1ebbfb8e | |||
| 3e3eb4d8d5 | |||
| 48b6548988 | |||
| 655f6ae452 | |||
| 61821a6f3e | |||
| 896781a52b | |||
| 612dd38fea | |||
| ff28b79088 | |||
| 35456bcede | |||
| 9d4ccb7884 | |||
| 9a22cb85f3 | |||
| 6c67086d0b | |||
| ea01cd339c | |||
| bb37841362 | |||
| 59a7194d7f | |||
| f647ada320 | |||
| 589a954478 | |||
| 1d606e1cf6 | |||
| dc2e25b48c | |||
| d690f5d60d | |||
| 3eca80e37d | |||
| 45a2ccb734 | |||
| c876df53f5 | |||
| 769da6d81f | |||
| ca46ae9e56 | |||
| 7df0920c80 | |||
| c4ac11d22e | |||
| 602ed2cbcd | |||
| b6c3108e53 | |||
| 8ecd8f2520 | |||
| e6234d3714 | |||
| 042a0c3014 | |||
| 87333f7b24 | |||
| 9945c67f7e | |||
| fca1514aac | |||
| 47ad69b691 | |||
| 489408c0b0 | |||
| 1b3744a1da | |||
| 9244eb36ed | |||
| 442298d5f7 | |||
| be7d35d441 | |||
| ec1cd606d3 | |||
| 16af688a7a | |||
| 37b23a16da | |||
| 006e9b7d28 | |||
| d214c27583 | |||
| 8fd364a99c | |||
| c2d8488728 | |||
| 1370434f3a | |||
| 5dde2c188c | |||
| 2f9c75d04f | |||
| 9ee7b1221d | |||
| fcc3c7fb6b | |||
| 01dc55ee5b | |||
| 3d24526704 | |||
| 51532e19fb | |||
| 931ff60182 | |||
| 07212c4e26 | |||
| 4932af080b | |||
| d6f9a7c462 | |||
| 7345b49cf6 | |||
| 4753e35035 | |||
| 96239fc034 | |||
| b428875bed | |||
| cf362282f0 | |||
| b2a2e637a5 | |||
| 0c18012442 | |||
| ee39e4fccb | |||
| 90ab2e24c3 | |||
| 21e5b3dc64 | |||
| 1e8c8d4cd5 | |||
| ff3ad30f0c | |||
| 47a3f5ef01 | |||
| a22fa7461d | |||
| 814ab5bbf3 | |||
| 498205b7ec | |||
| c235f7c05d | |||
| 64cca79fbb | |||
| 759e1041bd | |||
| 8055aeb1d4 | |||
| 79bd6fa862 | |||
| fa0be5d271 | |||
| 78cb142c91 | |||
| 5fa3d2a682 | |||
| fd8ddf0a02 | |||
| e1ea99ff06 | |||
| 11c6ee42ea | |||
| 9307d2c8a8 | |||
| 84c5f2170f | |||
| e009eab4f1 | |||
| ab84c3f5cf | |||
| b5f9cbcee4 | |||
| af6c229914 | |||
| c0155f0dfa | |||
| 346388b63c | |||
| a56439219f | |||
| 0fe41da688 | |||
| 0953f7e912 | |||
| 75cd3885f3 | |||
| 73c9dd4c9d | |||
| 2040f5aff9 | |||
| 5104be1d23 | |||
| 5ef8b1ce5c | |||
| ce4eca40a6 | |||
| 86b6fecbb4 | |||
| 88b43f546f | |||
| 2092fbf465 | |||
| ed66b0a04d | |||
| 847fbd2a8b | |||
| 7eddd1cf83 | |||
| 2437069114 | |||
| 3b87d61cbf | |||
| eb6b617ca2 | |||
| 64705d7589 | |||
| 206ef7d086 | |||
| b301834c6d | |||
| 028be4f604 | |||
| 6f9adb3ad0 | |||
| 19c7529d88 | |||
| 7951601ae8 | |||
| b87ce6b183 | |||
| f135583fee | |||
| 75e605fe87 | |||
| d115f1cbd7 | |||
| cfaf87360f | |||
| 876f60c1f4 | |||
| 488af5984c | |||
| 7136062c75 | |||
| 1585308f0f | |||
| e388a3ff53 | |||
| 22741603f5 | |||
| 3c89c71f66 | |||
| db57fabcbd | |||
| 03888bd266 | |||
| a6a04b5faa | |||
| 18068eb7e5 | |||
| 921a48bd14 | |||
| 2e6511566e | |||
| ec8abb19ab | |||
| 2a8be5566d | |||
| bcbc58b6a0 | |||
| 28fa412a06 | |||
| 6f7a4ff060 | |||
| 8a57632bfd | |||
| 2eb4ddf4af | |||
| e281ed2f6d | |||
| 2e6c820f53 | |||
| d48dda5bdc |
@@ -22,3 +22,17 @@
|
|||||||
fixtures and encrypted migration data.
|
fixtures and encrypted migration data.
|
||||||
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
|
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
|
||||||
condition, and default toward reading old data safely.
|
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.
|
||||||
|
|||||||
@@ -50,10 +50,11 @@ consider adding it to the script's `checked_files` list.
|
|||||||
|
|
||||||
## `check_doc_paths.sh`
|
## `check_doc_paths.sh`
|
||||||
|
|
||||||
Instruction/architecture docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`,
|
Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
|
||||||
`docs/architecture/*.md`) must not reference repo file paths that no longer
|
Markdown file under `docs/` (architecture, operations, testing, index) must not
|
||||||
exist. If your refactor moved code, update the docs that point at it — the
|
reference repo file paths that no longer exist. If your refactor moved code,
|
||||||
error message lists `doc -> stale-path` pairs.
|
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`
|
## `check_no_planning_docs.sh`
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
|||||||
|
|
||||||
### S3 object actions, copy, multipart, and upload policy validation
|
### S3 object actions, copy, multipart, and upload policy validation
|
||||||
|
|
||||||
|
- `GHSA-g8w9-qw9q-fghr`: a valid presigned `PutObject` accepted extra `x-amz-tagging`, website redirect, and storage-class headers omitted from `SignedHeaders`. Lesson: a presigned URL is a bounded capability; reject `x-amz-*` headers that are not cryptographically bound by the signature so unsigned metadata cannot change authorization, lifecycle, redirect, cost, or durability semantics.
|
||||||
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
|
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
|
||||||
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
|
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
|
||||||
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
||||||
@@ -119,7 +120,7 @@ Use these targeted searches when a diff touches security-sensitive code:
|
|||||||
```bash
|
```bash
|
||||||
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
|
rg -n "validate_admin_request|check_permissions|AdminAction::|deny_only|is_allowed" rustfs crates
|
||||||
rg -n "authorize_operation|FtpsDriver|SftpDriver|RETR|MKD|SIZE|MDTM|CreateBucket|GetObject|HeadObject" crates/protocols rustfs
|
rg -n "authorize_operation|FtpsDriver|SftpDriver|RETR|MKD|SIZE|MDTM|CreateBucket|GetObject|HeadObject" crates/protocols rustfs
|
||||||
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|content-length-range|starts-with" rustfs crates
|
rg -n "UploadPartCopy|upload_part_copy|CompleteMultipart|PostObject|presign|SignedHeaders|content-length-range|starts-with" rustfs crates
|
||||||
rg -n "ListBucketVersions|GetObjectVersion|versionId|VersionId|ExistingObjectTag|ForAllValues|ForAnyValue|POLICY_PLUGIN|opa" rustfs crates
|
rg -n "ListBucketVersions|GetObjectVersion|versionId|VersionId|ExistingObjectTag|ForAllValues|ForAnyValue|POLICY_PLUGIN|opa" rustfs crates
|
||||||
rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
|
rg -n "normalize_extract_entry_key|Snowball|auto-extract|PathBuf::join|canonicalize|\\.\\.|x-forwarded-for|x-real-ip|SourceIp" rustfs crates
|
||||||
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
|
rg -n "DEFAULT_SECRET|DEFAULT_ACCESS|TEST_PRIVATE_KEY|rustfs rpc|RUSTFS_RPC_SECRET" rustfs crates
|
||||||
@@ -136,6 +137,7 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
|
|||||||
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases, and assert denied paths do not reach the storage backend.
|
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases, and assert denied paths do not reach the storage backend.
|
||||||
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
||||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
||||||
|
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, and storage-class headers; require rejection before storage access, and verify explicitly signed equivalents still work.
|
||||||
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
|
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
|
||||||
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
|
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
|
||||||
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks.
|
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
|
||||||
|
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=d6aa36cfaae2c4d8590482c7e47138c5965b335b34a75f50d11ffc3366e9021e
|
sha256-darwin=53b05ac745905809d3828c6994bdd8ecf9d20b2b61a8a9d80fe15eb62f932193
|
||||||
sha256-linux=c8315465f50c194faee36141cdbb1e15e59271e524d948564a69e2d5eb408f2a
|
sha256-linux=7c892afa4b9d1591b46bd79c976b647109a277284fddb3b98edced4b0297eda2
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
sha256=9b9bc336b43b70d0e06e0adb5455bf035bb18945d85d60936eb6fe4d48e0e680
|
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
|
||||||
|
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
sha256=87c05c46d611ea7ed3feb5f7276bda8e5a0f70d72165d305d73a457907e7ba79
|
||||||
@@ -1 +1 @@
|
|||||||
sha256=655a3f3c1d042e694339d15caba7580518320322d1bac0f09450b37e6c09e2e7
|
sha256=95c8adc016bbc0df9fb2afa24a108bcdf6567ec4d0518725a6cae301593ab556
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=294350518743cac8d7c41880a2835216e4b697908d7b0b1bc92b62816d94c59d
|
sha256=5db88c6fec94d4f269c7d9cfc128bd2adc27b3d7021127e2fa0b1daccc5f900f
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"lane": "ci/test-and-lint",
|
||||||
|
"tests": [
|
||||||
|
{
|
||||||
|
"invariant": "write-quorum",
|
||||||
|
"suite": "rustfs-ecstore",
|
||||||
|
"name": "set_disk::ops::object::inline_put_commit_path_tests::inline_put_direct_commit_accepts_exact_quorum_and_rejects_quorum_minus_one"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "metadata-rollback",
|
||||||
|
"suite": "rustfs-ecstore",
|
||||||
|
"name": "set_disk::core::io_primitives::tests::write_unique_file_info_reverts_metadata_when_write_quorum_fails"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "stale-writer",
|
||||||
|
"suite": "rustfs-ecstore",
|
||||||
|
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::put_object_no_lock_aborts_after_outer_namespace_lock_loss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "range-body",
|
||||||
|
"suite": "rustfs-ecstore",
|
||||||
|
"name": "set_disk::ops::object::transition_upload_integrity_tests::transitioned_compressed_object_range_get_returns_plaintext_slice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "multipart-cancellation",
|
||||||
|
"suite": "rustfs-ecstore",
|
||||||
|
"name": "set_disk::ops::multipart::tests::cancelled_complete_keeps_upload_lock_through_tail_cleanup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "list-uncommitted-version",
|
||||||
|
"suite": "rustfs-filemeta",
|
||||||
|
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "minio-object-fixture",
|
||||||
|
"suite": "rustfs-filemeta",
|
||||||
|
"name": "filemeta::test::parses_real_minio_object_xlmeta"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "corrupt-part-arrays",
|
||||||
|
"suite": "rustfs-filemeta",
|
||||||
|
"name": "filemeta::test::crc_valid_but_part_arrays_corrupt_into_fileinfo_errors_not_panics"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "odm-source-contract-s3",
|
||||||
|
"suite": "rustfs",
|
||||||
|
"name": "on_demand_migration::source_client::tests::s3_backend_satisfies_the_shared_backend_contract"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "odm-source-contract-azure",
|
||||||
|
"suite": "rustfs",
|
||||||
|
"name": "on_demand_migration::azure::tests::azure_backend_satisfies_the_shared_backend_contract"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"invariant": "odm-source-contract-gcs",
|
||||||
|
"suite": "rustfs",
|
||||||
|
"name": "on_demand_migration::gcs::tests::gcs_native_backend_satisfies_the_shared_backend_contract"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fixtures": [
|
||||||
|
{
|
||||||
|
"path": "crates/filemeta/tests/fixtures/minio/object_large_bin.xlmeta.hex",
|
||||||
|
"sha256": "e8093767806d701e639b48d023190e858fbc4cde69bcfd83c22af8cba8452ce5",
|
||||||
|
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "crates/filemeta/tests/fixtures/minio/object_small_txt.xlmeta.hex",
|
||||||
|
"sha256": "2a415ad3a3be5a9440035d4026ff880e0e8c1ec1701be9f4e077734e8dce03da",
|
||||||
|
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "crates/filemeta/tests/fixtures/minio/object_versioned_txt.xlmeta.hex",
|
||||||
|
"sha256": "7f21f50c326dd8b0228deb6dbdb7052b3d0a3f8ee6c85d43486f0e6bb7a97261",
|
||||||
|
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata.blob.hex",
|
||||||
|
"sha256": "f2b6e260aff106adf6039feb1c645686e84e75404ff725491fb18668be5db203",
|
||||||
|
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata_full.xlmeta.hex",
|
||||||
|
"sha256": "3b6de589519c08a1614c8bd409bb8199c17d42043861b07bce513075e6fbfc12",
|
||||||
|
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -23,4 +23,4 @@ coverage: core-deps ## Workspace line coverage (cargo-llvm-cov + nextest; slow,
|
|||||||
@mkdir -p target/llvm-cov
|
@mkdir -p target/llvm-cov
|
||||||
cargo llvm-cov report --lcov --output-path target/llvm-cov/lcov.info
|
cargo llvm-cov report --lcov --output-path target/llvm-cov/lcov.info
|
||||||
cargo llvm-cov report --json --output-path target/llvm-cov/coverage.json
|
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
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
|
|||||||
.PHONY: test-wiring-check
|
.PHONY: test-wiring-check
|
||||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||||
@echo "🧪 Checking test wiring..."
|
@echo "🧪 Checking test wiring..."
|
||||||
python3 ./scripts/check_test_wiring.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
||||||
|
|
||||||
.PHONY: log-analyzer-rules-check
|
.PHONY: log-analyzer-rules-check
|
||||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
.NOTPARALLEL: pre-commit pre-pr dev-check
|
.NOTPARALLEL: pre-commit pre-pr dev-check
|
||||||
|
|
||||||
.PHONY: setup-hooks
|
.PHONY: setup-hooks
|
||||||
setup-hooks: ## Set up git hooks
|
setup-hooks: ## Install the configured pre-commit hooks
|
||||||
@echo "🔧 Setting up git hooks..."
|
@echo "🔧 Setting up git hooks..."
|
||||||
chmod +x .git/hooks/pre-commit
|
pre-commit validate-config
|
||||||
|
pre-commit install
|
||||||
@echo "✅ Git hooks setup complete!"
|
@echo "✅ Git hooks setup complete!"
|
||||||
|
|
||||||
.PHONY: doc-paths-check
|
.PHONY: doc-paths-check
|
||||||
|
|||||||
@@ -31,17 +31,21 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_object_batch_bench_enhanced.sh
|
./scripts/test_object_batch_bench_enhanced.sh
|
||||||
./scripts/test_hotpath_warp_ab_gate.sh
|
./scripts/test_hotpath_warp_ab_gate.sh
|
||||||
./scripts/test_hotpath_warp_abba.sh
|
./scripts/test_hotpath_warp_abba.sh
|
||||||
|
./scripts/test_scanner_validation_harness.sh
|
||||||
./scripts/test_exact_1mib_handoff_abba.sh
|
./scripts/test_exact_1mib_handoff_abba.sh
|
||||||
./scripts/test_pinned_paired_abba_bench.sh
|
./scripts/test_pinned_paired_abba_bench.sh
|
||||||
./scripts/test_manual_transition_runbooks.sh
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
./scripts/test_fuzz_runner.sh
|
./scripts/test_fuzz_runner.sh
|
||||||
|
./scripts/test_python_bin.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
python3 ./scripts/check_security_coverage.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||||
python3 ./scripts/s3-tests/test_report_compat.py
|
$(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
|
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
|
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# Committed floor for the number of tests selected by the migration-critical
|
# Committed floor for the number of tests selected by the migration-critical
|
||||||
# CI gate (see scripts/check_migration_gate_count.sh, backlog#1153 infra-12).
|
# CI gate (see scripts/check_migration_gate_count.sh, backlog#1153 infra-12).
|
||||||
#
|
#
|
||||||
# The floor equals the exact count of rustfs-ecstore --lib tests matching the
|
# The floor equals the exact count of rustfs-ecstore --lib tests, with the
|
||||||
# gate filter (name substrings: data_movement, rebalance, decommission,
|
# test-util feature enabled, matching the gate filter (name substrings:
|
||||||
# source_cleanup, delete_marker) at the time this file was last updated.
|
# data_movement, rebalance, decommission, source_cleanup, delete_marker) at
|
||||||
|
# the time this file was last updated.
|
||||||
# CI fails if the selected count drops below this number, so renames or
|
# CI fails if the selected count drops below this number, so renames or
|
||||||
# removals that thin the gate must update this file in the same PR.
|
# removals that thin the gate must update this file in the same PR.
|
||||||
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
||||||
571
|
946
|
||||||
|
|||||||
+198
-24
@@ -46,6 +46,11 @@ e2e-reliability = { max-threads = 1 }
|
|||||||
e2e-inline-boundaries = { max-threads = 1 }
|
e2e-inline-boundaries = { max-threads = 1 }
|
||||||
e2e-cluster-nightly = { max-threads = 1 }
|
e2e-cluster-nightly = { max-threads = 1 }
|
||||||
|
|
||||||
|
# Deep async storage futures are composed into tests across several crates.
|
||||||
|
# Keep the test stack bounded but above libtest's 2 MiB default.
|
||||||
|
[scripts.setup.ecstore-base-stack]
|
||||||
|
command = ['sh', '-c', 'echo RUST_MIN_STACK=4194304 >> "$NEXTEST_ENV"']
|
||||||
|
|
||||||
# These exact regression scenarios build deep async storage futures that exceed
|
# These exact regression scenarios build deep async storage futures that exceed
|
||||||
# libtest's 2 MiB spawned-thread stack on Linux. Give only their test processes
|
# libtest's 2 MiB spawned-thread stack on Linux. Give only their test processes
|
||||||
# the same 32 MiB stack already used by the crate's dedicated large-stack tests.
|
# the same 32 MiB stack already used by the crate's dedicated large-stack tests.
|
||||||
@@ -60,9 +65,13 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
|||||||
|
|
||||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||||
[[profile.default.scripts]]
|
[[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'
|
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|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]]
|
[[profile.default.scripts]]
|
||||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||||
setup = 'lifecycle-large-stack'
|
setup = 'lifecycle-large-stack'
|
||||||
@@ -80,6 +89,29 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||||
test-group = 'ecstore-serial-flaky'
|
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,
|
# 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
|
# 2-pool store and commits a 72 MiB multipart object. Keep that cross-disk IO
|
||||||
# from overlapping the ecstore commit fixtures above.
|
# from overlapping the ecstore commit fixtures above.
|
||||||
@@ -100,12 +132,29 @@ test-group = 'embedded-test-ports'
|
|||||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
|
# Serialize the transition matrix tests. They build a 4-disk hermetic erasure
|
||||||
|
# set, populate the get_object_metadata_cache, and assert generation lifecycle
|
||||||
|
# semantics. serial_test's #[serial] has no effect across nextest's process
|
||||||
|
# boundary, so concurrent execution races the shared metadata-cache generation
|
||||||
|
# counter and causes spurious "metadata read should publish the generation"
|
||||||
|
# panics. Preventive serialization, no retries.
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(set_disk::transition_matrix_tests::)'
|
||||||
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# The durable ILM decommission regressions build isolated multi-pool stores and
|
# The durable ILM decommission regressions build isolated multi-pool stores and
|
||||||
# deliberately take source or target disks offline while checking fencing.
|
# deliberately take source or target disks offline while checking fencing.
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
|
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
|
# Decommission entry and marker/barrier tests share process-wide fault hooks and
|
||||||
|
# deterministic commit barriers. Keep the whole init decommission family in one
|
||||||
|
# nextest group; serial_test alone cannot isolate separate test processes.
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(/^store::init::tests::(decommission_|suspended_.*decommission)$/)'
|
||||||
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# Serialize the bucket-incarnation / lifecycle-fence tests. They drive
|
# Serialize the bucket-incarnation / lifecycle-fence tests. They drive
|
||||||
# init_bucket_metadata_sys and bucket_metadata_sys_of, i.e. process-global
|
# init_bucket_metadata_sys and bucket_metadata_sys_of, i.e. process-global
|
||||||
# OnceLock state that serial_test's #[serial] cannot protect across nextest's
|
# OnceLock state that serial_test's #[serial] cannot protect across nextest's
|
||||||
@@ -134,6 +183,13 @@ test-group = 'e2e-reliability'
|
|||||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||||
test-group = 'e2e-inline-boundaries'
|
test-group = 'e2e-inline-boundaries'
|
||||||
|
|
||||||
|
# 4-node 4-drive distributed Actions suite: each case starts four rustfs
|
||||||
|
# processes and up to sixteen data directories. Serialize across nextest's
|
||||||
|
# process boundary so several 4x4 clusters never overlap.
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||||
|
test-group = 'e2e-cluster-nightly'
|
||||||
|
|
||||||
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
||||||
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
||||||
# one group.
|
# one group.
|
||||||
@@ -157,9 +213,13 @@ fail-fast = false
|
|||||||
path = "junit.xml"
|
path = "junit.xml"
|
||||||
|
|
||||||
[[profile.ci.scripts]]
|
[[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'
|
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|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]]
|
[[profile.ci.scripts]]
|
||||||
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
filter = 'binary(lifecycle_integration_test) | (package(rustfs) & test(/^app::lifecycle_transition_api_test::/))'
|
||||||
setup = 'lifecycle-large-stack'
|
setup = 'lifecycle-large-stack'
|
||||||
@@ -220,6 +280,20 @@ test-group = 'e2e-reliability'
|
|||||||
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
filter = 'package(rustfs-ecstore) & test(/^set_disk::ops::multipart::tests::crash_consistency::/)'
|
||||||
test-group = 'ecstore-serial-flaky'
|
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
|
# Match the default-profile embedded test isolation without quarantining or
|
||||||
# retrying failures in CI.
|
# retrying failures in CI.
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
@@ -232,10 +306,20 @@ test-group = 'embedded-test-ports'
|
|||||||
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
|
# Serialize the transition matrix tests under the ci profile too (see the
|
||||||
|
# matching default-profile override near the top). No retries.
|
||||||
|
[[profile.ci.overrides]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(set_disk::transition_matrix_tests::)'
|
||||||
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
|
filter = 'package(rustfs-ecstore) & (test(decommission_migrates_and_verifies_registered_durable_ilm_records) | test(decommission_durable_ilm_target_read_error_is_not_masked_by_peer_success) | test(decommission_durable_ilm_terminal_receipt_recovers_failed_source_cleanup) | test(decommission_durable_ilm_receipt_pagination_fails_closed_on_second_page) | test(decommission_durable_ilm_recovery_keeps_multiple_active_sources))'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
|
[[profile.ci.overrides]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(/^store::init::tests::(decommission_|suspended_.*decommission)$/)'
|
||||||
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# Serialize the bucket-incarnation / lifecycle-fence tests under the ci profile
|
# Serialize the bucket-incarnation / lifecycle-fence tests under the ci profile
|
||||||
# too (see the matching default-profile override near the top). No retries.
|
# too (see the matching default-profile override near the top). No retries.
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
@@ -278,7 +362,8 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
# allowlist", so any new replication test lands in nightly by default (never
|
# 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
|
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
||||||
# regexes byte-identical. The committed profile selection digests make changes
|
# 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
|
# 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
|
# (#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 —
|
# SSRF egress guard rejected on every PR after repl-1 (#4712). That is fixed —
|
||||||
@@ -316,13 +401,32 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
|
# 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
|
# Single-node servers on random ports with isolated temp dirs — meets the
|
||||||
# admission criteria unchanged.
|
# 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]
|
[profile.e2e-smoke]
|
||||||
default-filter = """
|
default-filter = """
|
||||||
package(e2e_test) & (
|
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(/^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::lifecycle::/)
|
||||||
| test(/^reliant::tiering::/)
|
| 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
|
fail-fast = false
|
||||||
@@ -362,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
|
# until it is explicitly promoted to the fast PR subset — no replication test
|
||||||
# is ever silently left out of CI.
|
# 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
|
# #[serial] does NOT serialize under nextest (process-per-test; see the file
|
||||||
# header). These tests need no cross-test serialization: each spawns its own
|
# 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
|
# server(s) on random ports with isolated temp dirs, so they are parallel-safe
|
||||||
@@ -379,7 +487,7 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
|||||||
[profile.e2e-repl-nightly]
|
[profile.e2e-repl-nightly]
|
||||||
default-filter = """
|
default-filter = """
|
||||||
package(e2e_test)
|
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)$/)
|
& !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
|
fail-fast = false
|
||||||
@@ -392,15 +500,29 @@ path = "junit.xml"
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# e2e-nightly profile — destructive multi-process cluster fault domains
|
# 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
|
# starts a real multi-process or multi-disk topology and exercises node/disk
|
||||||
# loss, quorum, cleanup, notification fan-in, or admin-timeout behavior. The
|
# loss, quorum, cleanup, notification fan-in, or admin-timeout behavior. The
|
||||||
# consolidated nightly workflow runs them serially to avoid resource
|
# consolidated nightly workflow runs them serially to avoid resource
|
||||||
# starvation; failures are never retried.
|
# 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]
|
[profile.e2e-nightly]
|
||||||
default-filter = """
|
default-filter = """
|
||||||
package(e2e_test)
|
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
|
fail-fast = false
|
||||||
|
|
||||||
@@ -411,6 +533,55 @@ path = "junit.xml"
|
|||||||
filter = 'package(e2e_test)'
|
filter = 'package(e2e_test)'
|
||||||
test-group = 'e2e-cluster-nightly'
|
test-group = 'e2e-cluster-nightly'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# e2e-distributed profile — 4-node 4-disk Actions suite
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Storage-sensitive PR / nightly / dispatch lane owned by
|
||||||
|
# .github/workflows/e2e-distributed.yml.
|
||||||
|
# Each case starts four rustfs processes (and for site replication, two
|
||||||
|
# clusters). Upgrade cases also require RUSTFS_UPGRADE_SOURCE_BINARY.
|
||||||
|
# Serialized via e2e-cluster-nightly with no retries.
|
||||||
|
[profile.e2e-distributed]
|
||||||
|
default-filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||||
|
fail-fast = false
|
||||||
|
# Decommission / rebalance cases poll for up to 180s with little stdout.
|
||||||
|
slow-timeout = { period = "120s", terminate-after = 6 }
|
||||||
|
|
||||||
|
[profile.e2e-distributed.junit]
|
||||||
|
path = "junit.xml"
|
||||||
|
|
||||||
|
[[profile.e2e-distributed.overrides]]
|
||||||
|
filter = 'package(e2e_test)'
|
||||||
|
test-group = 'e2e-cluster-nightly'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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
|
# e2e-protocols profile — serial protocol lane
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -431,16 +602,27 @@ path = "junit.xml"
|
|||||||
# quota, checksum, encryption,
|
# quota, checksum, encryption,
|
||||||
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
|
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
|
||||||
# skips. Budget <= 45 min; authority for the suite count is `cargo nextest list
|
# 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":
|
# 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
|
# * protocols:: — FTPS/SFTP/WebDAV, run from the dedicated protocol profile
|
||||||
# with one worker because the suite owns fixed ports.
|
# 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,
|
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
|
||||||
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
|
# namespace_lock_quorum, admin_timeout_regression, object_lambda) — too
|
||||||
# object_lambda) — too heavy for the merge budget; they run in the
|
# heavy for the merge budget; they run in the e2e-nightly serial
|
||||||
# e2e-nightly serial cluster-fault lane.
|
# 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
|
# * replication_extension_test — repl-1 already splits it into the PR
|
||||||
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
|
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
|
||||||
# it for those, so e2e-full does not double-run it.
|
# it for those, so e2e-full does not double-run it.
|
||||||
@@ -452,23 +634,15 @@ path = "junit.xml"
|
|||||||
# parallel-safe — the same property e2e-smoke relies on. The exceptions are the
|
# parallel-safe — the same property e2e-smoke relies on. The exceptions are the
|
||||||
# 4-disk reliability / degraded-read fault-injection tests and the fixed-port
|
# 4-disk reliability / degraded-read fault-injection tests and the fixed-port
|
||||||
# Vault tests, both serialized below.
|
# Vault tests, both serialized below.
|
||||||
# KNOWN-FAILURE EXCLUSIONS (characterization run 29381309848, 2026-07-15:
|
|
||||||
# 341 ran / 32 failed on the suites' first automated run ever). Deterministic
|
|
||||||
# product failures cannot be quarantined away with retries, so each family is
|
|
||||||
# excluded here with its tracking issue, under the same discipline as the
|
|
||||||
# ci-profile quarantine (docs/testing/README.md): every entry MUST cite one
|
|
||||||
# OPEN issue, and the fixing PR MUST delete the exclusion. The passing
|
|
||||||
# negative-path siblings of each family stay in as regression guards.
|
|
||||||
# * rustfs#4843 — over-limit archive entry paths hard-reject the whole
|
|
||||||
# archive even under ignore-errors semantics.
|
|
||||||
[profile.e2e-full]
|
[profile.e2e-full]
|
||||||
default-filter = """
|
default-filter = """
|
||||||
package(e2e_test)
|
package(e2e_test)
|
||||||
& !test(/^protocols::/)
|
& !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_extension_test::/)
|
||||||
& !test(/^multipart_auth_test::test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled$/)
|
& !test(/^replication_target_matrix_test::/)
|
||||||
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(ignores_invalid_entries_when_requested|supports_standard_headers_with_combined_extract_options)$/)
|
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
||||||
"""
|
"""
|
||||||
fail-fast = false
|
fail-fast = false
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"cases": {
|
||||||
|
"background-target-restart": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart",
|
||||||
|
"oracle": "background-target-restart.json",
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 4, "drives_per_node": 1},
|
||||||
|
"scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"release_pending": {
|
||||||
|
"G01": "W02/W04 complete root and quota authority coverage",
|
||||||
|
"G02": "W03 bounded checkpoint progress and independent version inventory",
|
||||||
|
"G03": "W17/W18 exact scoped ACK with durable publication and mixed peers",
|
||||||
|
"G04": "W03/W15/W16 crash at every cache/root/floor/intent boundary",
|
||||||
|
"G05": "W06/W07 per-object outcomes and bounded terminal retention",
|
||||||
|
"G06": "W06/W08/W23 concurrent status, legacy clients and truncation",
|
||||||
|
"G07": "W12/W13/W14 durable MRF responsibility at every commit boundary",
|
||||||
|
"G08": "W12/W13/W14 MRF capacity, disk-full and replica-loss matrix",
|
||||||
|
"G09": "W13/W18/W23 actual mixed-version reader/writer and rollback payloads",
|
||||||
|
"G10": "W05/W09/W10/W11 bounded scheduling and pressure recovery",
|
||||||
|
"G11": "W04/W19/W24 maintenance and complete producer coverage",
|
||||||
|
"G12": "W02/W15/W16 both quota paths during reset and settlement",
|
||||||
|
"G13": "W07/W14 quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
|
||||||
|
"G14": "W20/W21 same-window field evidence; 3x4 EC8+4 and multi-set/pool coverage",
|
||||||
|
"P1": "W20 measured cold-walk share and foreground latency/throughput",
|
||||||
|
"P2": "W20/W24 measured post-stop convergence and cold segment reuse",
|
||||||
|
"P3": "W20 measured two-hour pressure/heal capacity and recovery window",
|
||||||
|
"P4": "W20 measured MRF scale and replay cost with retained responsibility",
|
||||||
|
"R-E": "W03/W05 fixed-budget real process restart through enumeration and classification",
|
||||||
|
"R-D": "W07/W14 manager-to-event-to-ledger exact disposition, including grace",
|
||||||
|
"R-L": "W13/W14 legacy source conflicts, migration gaps and crash-safe source retirement"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ self-hosted-runner:
|
|||||||
- sm-standard-4
|
- sm-standard-4
|
||||||
- dind-sm-standard-2
|
- dind-sm-standard-2
|
||||||
- smoke-testing
|
- smoke-testing
|
||||||
|
- pf-testing
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -10,16 +10,16 @@ Use N/A when there is no related issue.
|
|||||||
|
|
||||||
## Summary of Changes
|
## Summary of Changes
|
||||||
<!--
|
<!--
|
||||||
Briefly explain what changed and why reviewers should accept it.
|
Describe the concrete problem and resulting behavior. For a behavior change, name the input or state that triggers it and the expected outcome. Explain any new dependency or abstraction that the change needs.
|
||||||
Focus on behavior, compatibility, and review-relevant context.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
<!--
|
<!--
|
||||||
List the commands or checks you ran, for example:
|
Give 1–3 concrete pieces of evidence for the changed behavior: the test or command, its observed result, and the regression it catches. For a bug fix, record a failing-before/passing-after check or explain why it was unavailable.
|
||||||
- `make pre-commit`
|
|
||||||
|
|
||||||
Use N/A only when verification is not applicable.
|
Identify the tested commit and any local changes. When testing a prebuilt binary or external service, include its source/version and artifact identity; a successful run against a different build is not evidence for this change.
|
||||||
|
|
||||||
|
List relevant checks not run and the remaining risk. Use the validation tier in AGENTS.md; do not run broader checks solely to fill this section. For documentation-only changes, list the applicable documentation checks. Use N/A only when verification is not applicable.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
## Impact
|
## Impact
|
||||||
|
|||||||
@@ -4,9 +4,19 @@
|
|||||||
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
||||||
|
{
|
||||||
|
"workflow": ".github/workflows/e2e-distributed.yml",
|
||||||
|
"max_age_hours": 36,
|
||||||
|
"never_ran_grace_until": "2026-09-18T00:00:00Z"
|
||||||
|
},
|
||||||
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
||||||
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
||||||
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
||||||
|
{
|
||||||
|
"workflow": ".github/workflows/minio-interop.yml",
|
||||||
|
"max_age_hours": 36,
|
||||||
|
"never_ran_grace_until": "2026-09-08T00:00:00Z"
|
||||||
|
},
|
||||||
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/nightly-gnu.yml", "max_age_hours": 36 },
|
||||||
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
|
{ "workflow": ".github/workflows/performance-ab.yml", "max_age_hours": 36 },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ on:
|
|||||||
- '.github/actions/**'
|
- '.github/actions/**'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
- 'scripts/release/create_or_update_release.sh'
|
- '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_performance_ab_workflow.sh'
|
||||||
- 'scripts/security/check_preview_release_workflow.sh'
|
- 'scripts/security/check_preview_release_workflow.sh'
|
||||||
|
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||||
- 'scripts/security/check_workflow_pins.sh'
|
- 'scripts/security/check_workflow_pins.sh'
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
@@ -37,8 +40,11 @@ on:
|
|||||||
- '.github/actions/**'
|
- '.github/actions/**'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
- 'scripts/release/create_or_update_release.sh'
|
- '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_performance_ab_workflow.sh'
|
||||||
- 'scripts/security/check_preview_release_workflow.sh'
|
- 'scripts/security/check_preview_release_workflow.sh'
|
||||||
|
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||||
- 'scripts/security/check_workflow_pins.sh'
|
- 'scripts/security/check_workflow_pins.sh'
|
||||||
schedule:
|
schedule:
|
||||||
# Daily, not weekly. This schedule exists to catch RustSec advisories
|
# Daily, not weekly. This schedule exists to catch RustSec advisories
|
||||||
@@ -146,6 +152,12 @@ jobs:
|
|||||||
- name: Check performance A/B workflow trust boundary
|
- name: Check performance A/B workflow trust boundary
|
||||||
run: ./scripts/security/check_performance_ab_workflow.sh
|
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:
|
dependency-review:
|
||||||
name: Dependency Review
|
name: Dependency Review
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ jobs:
|
|||||||
needs: [ build-check, prepare-platform-matrix ]
|
needs: [ build-check, prepare-platform-matrix ]
|
||||||
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
|
if: needs.build-check.outputs.should_build == 'true' && needs.prepare-platform-matrix.result == 'success'
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 150
|
timeout-minutes: 180
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
# Release binaries ship without dial9 telemetry and therefore do not need
|
# Release binaries ship without dial9 telemetry and therefore do not need
|
||||||
@@ -408,9 +408,9 @@ jobs:
|
|||||||
|
|
||||||
if [[ "${{ matrix.cross }}" == "true" ]]; then
|
if [[ "${{ matrix.cross }}" == "true" ]]; then
|
||||||
# All cross targets in the matrix are Linux; zigbuild handles them.
|
# 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
|
else
|
||||||
cargo build --release --target ${{ matrix.target }} -p rustfs --bins
|
cargo build --release --target ${{ matrix.target }} -p rustfs --bin rustfs
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create release package
|
- name: Create release package
|
||||||
@@ -1065,7 +1065,7 @@ jobs:
|
|||||||
while IFS= read -r preview_tag; do
|
while IFS= read -r preview_tag; do
|
||||||
[[ -n "$preview_tag" ]] || continue
|
[[ -n "$preview_tag" ]] || continue
|
||||||
echo "🧹 Deleting preview release $preview_tag (tag kept)"
|
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))
|
DELETED=$((DELETED + 1))
|
||||||
done < <(
|
done < <(
|
||||||
jq -r --arg tag "$TAG" '
|
jq -r --arg tag "$TAG" '
|
||||||
|
|||||||
@@ -12,24 +12,10 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
# Companion to ci.yml for required status checks.
|
# Reports the existing required checks for paths excluded by ci.yml.
|
||||||
#
|
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
||||||
# ci.yml skips docs-only pull requests via paths-ignore, but the branch ruleset
|
# action to keep validation coverage aligned. Keep this paths list in sync with
|
||||||
# requires a check named "Test and Lint" — without this workflow a docs-only PR
|
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
||||||
# would wait on it forever. This workflow triggers on exactly the paths ci.yml
|
|
||||||
# ignores and reports success under the same job name. Mixed PRs trigger both
|
|
||||||
# workflows and the real check still gates: a required check with any failing
|
|
||||||
# run blocks the merge.
|
|
||||||
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
|
|
||||||
#
|
|
||||||
# "Quick Checks" is mirrored here ahead of the ruleset change that will make it
|
|
||||||
# required too (rustfs/backlog#1599). Until that change lands this job is
|
|
||||||
# inert; mirroring it first is what lets the ruleset change happen without
|
|
||||||
# stranding docs-only PRs on a check nobody reports.
|
|
||||||
#
|
|
||||||
# Keep the paths list below in sync with the pull_request paths-ignore list
|
|
||||||
# in ci.yml, and keep the quick-checks steps below byte-identical to the
|
|
||||||
# quick-checks job in ci.yml.
|
|
||||||
|
|
||||||
name: Continuous Integration (docs only)
|
name: Continuous Integration (docs only)
|
||||||
|
|
||||||
@@ -59,19 +45,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Deliberately NOT a bare `echo`. Once "Quick Checks" becomes a required
|
|
||||||
# check, ci.yml gates every expensive job behind it, so a mixed PR reports
|
|
||||||
# two check runs with this name: the real one (45-51s) and this companion.
|
|
||||||
# GitHub has no written contract for how it picks between same-named
|
|
||||||
# required check runs ("latest wins" vs "any failure blocks"), so instead of
|
|
||||||
# relying on ordering we make both runs execute the same commands against
|
|
||||||
# the same merge ref — their conclusions are then necessarily identical and
|
|
||||||
# the choice does not matter. Keep these steps byte-identical to the
|
|
||||||
# quick-checks job in ci.yml (a guard script that asserts this, and the paths
|
|
||||||
# sync below, is tracked in rustfs/backlog#1603).
|
|
||||||
#
|
|
||||||
# For a genuinely docs-only PR this adds no strictness (no code changed, so
|
|
||||||
# fmt and the guards always pass) and costs ~50s of ubuntu-latest.
|
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -82,63 +55,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Run shared quick checks
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: ./.github/actions/quick-checks
|
||||||
with:
|
|
||||||
tool: ripgrep@15.2.0
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
|
||||||
with:
|
|
||||||
components: rustfmt
|
|
||||||
|
|
||||||
- name: Check code formatting
|
|
||||||
run: cargo fmt --all --check
|
|
||||||
|
|
||||||
- name: Check unsafe code allowances
|
|
||||||
run: ./scripts/check_unsafe_code_allowances.sh
|
|
||||||
|
|
||||||
- name: Check layered dependencies
|
|
||||||
run: ./scripts/check_layer_dependencies.sh
|
|
||||||
|
|
||||||
- name: Check architecture migration rules
|
|
||||||
run: ./scripts/check_architecture_migration_rules.sh
|
|
||||||
|
|
||||||
- name: Check logging guardrails
|
|
||||||
run: ./scripts/check_logging_guardrails.sh
|
|
||||||
|
|
||||||
- name: Check tokio io-uring feature guard
|
|
||||||
run: ./scripts/check_no_tokio_io_uring.sh
|
|
||||||
|
|
||||||
- name: Check extension schema boundaries
|
|
||||||
run: ./scripts/check_extension_schema_boundaries.sh
|
|
||||||
|
|
||||||
- name: Check body-cache whitelist guard
|
|
||||||
run: ./scripts/check_body_cache_whitelist.sh
|
|
||||||
|
|
||||||
- name: Check s3s footprint ratchet
|
|
||||||
run: ./scripts/check_s3s_footprint.sh
|
|
||||||
|
|
||||||
- name: Check cryptographic capability wording
|
|
||||||
run: ./scripts/check_fips_wording.sh
|
|
||||||
|
|
||||||
- name: Check no embedded secret material
|
|
||||||
run: ./scripts/check_embedded_secrets.sh
|
|
||||||
|
|
||||||
- name: Check test wiring
|
|
||||||
run: |
|
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
|
||||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
|
||||||
python3 ./scripts/check_test_wiring.py
|
|
||||||
|
|
||||||
- name: Check no planning docs committed
|
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
|
||||||
|
|
||||||
- name: Check CI paths stay in sync
|
|
||||||
run: ./scripts/check_ci_paths_sync.sh
|
|
||||||
|
|
||||||
- name: Check io_uring lane --lib precondition
|
|
||||||
run: ./scripts/check_uring_lane_lib_only.sh
|
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Test and Lint
|
||||||
|
|||||||
+15
-66
@@ -100,12 +100,7 @@ jobs:
|
|||||||
- name: Typos check with custom config file
|
- name: Typos check with custom config file
|
||||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||||
|
|
||||||
# Fast, compile-free checks that fail early so contributors get feedback in
|
# Fail early with compile-free checks shared with docs-only CI.
|
||||||
# ~1 minute instead of waiting for the full test job.
|
|
||||||
#
|
|
||||||
# These steps are mirrored byte-for-byte in ci-docs-only.yml so that a mixed
|
|
||||||
# PR, which reports two check runs named "Quick Checks", cannot get one red
|
|
||||||
# and one green. Edit both jobs together.
|
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -117,66 +112,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Run shared quick checks
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: ./.github/actions/quick-checks
|
||||||
with:
|
|
||||||
tool: ripgrep@15.2.0
|
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
|
||||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
|
||||||
with:
|
|
||||||
components: rustfmt
|
|
||||||
|
|
||||||
- name: Check code formatting
|
|
||||||
run: cargo fmt --all --check
|
|
||||||
|
|
||||||
- name: Check unsafe code allowances
|
|
||||||
run: ./scripts/check_unsafe_code_allowances.sh
|
|
||||||
|
|
||||||
- name: Check layered dependencies
|
|
||||||
run: ./scripts/check_layer_dependencies.sh
|
|
||||||
|
|
||||||
- name: Check architecture migration rules
|
|
||||||
run: ./scripts/check_architecture_migration_rules.sh
|
|
||||||
|
|
||||||
- name: Check logging guardrails
|
|
||||||
run: ./scripts/check_logging_guardrails.sh
|
|
||||||
|
|
||||||
- name: Check error other(format!) ratchet
|
|
||||||
run: ./scripts/check_error_other_format_ratchet.sh
|
|
||||||
|
|
||||||
- name: Check tokio io-uring feature guard
|
|
||||||
run: ./scripts/check_no_tokio_io_uring.sh
|
|
||||||
|
|
||||||
- name: Check extension schema boundaries
|
|
||||||
run: ./scripts/check_extension_schema_boundaries.sh
|
|
||||||
|
|
||||||
- name: Check body-cache whitelist guard
|
|
||||||
run: ./scripts/check_body_cache_whitelist.sh
|
|
||||||
|
|
||||||
- name: Check s3s footprint ratchet
|
|
||||||
run: ./scripts/check_s3s_footprint.sh
|
|
||||||
|
|
||||||
- name: Check cryptographic capability wording
|
|
||||||
run: ./scripts/check_fips_wording.sh
|
|
||||||
|
|
||||||
- name: Check no embedded secret material
|
|
||||||
run: ./scripts/check_embedded_secrets.sh
|
|
||||||
|
|
||||||
- name: Check test wiring
|
|
||||||
run: |
|
|
||||||
python3 ./scripts/check_test_wiring.py --self-test
|
|
||||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
|
||||||
python3 ./scripts/check_test_wiring.py
|
|
||||||
|
|
||||||
- name: Check no planning docs committed
|
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
|
||||||
|
|
||||||
- name: Check CI paths stay in sync
|
|
||||||
run: ./scripts/check_ci_paths_sync.sh
|
|
||||||
|
|
||||||
- name: Check io_uring lane --lib precondition
|
|
||||||
run: ./scripts/check_uring_lane_lib_only.sh
|
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Test and Lint
|
||||||
@@ -269,6 +206,7 @@ jobs:
|
|||||||
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts/test-and-lint
|
mkdir -p artifacts/test-and-lint
|
||||||
|
rm -f target/nextest/ci/junit.xml
|
||||||
./scripts/ci/resource_sampler.sh start nextest
|
./scripts/ci/resource_sampler.sh start nextest
|
||||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||||
set +e
|
set +e
|
||||||
@@ -277,6 +215,12 @@ jobs:
|
|||||||
--status-level all --final-status-level all \
|
--status-level all --final-status-level all \
|
||||||
2>&1 | tee artifacts/test-and-lint/nextest.log
|
2>&1 | tee artifacts/test-and-lint/nextest.log
|
||||||
status=${PIPESTATUS[0]}
|
status=${PIPESTATUS[0]}
|
||||||
|
if [[ "${status}" -eq 0 ]]; then
|
||||||
|
cargo nextest list --profile ci --all --exclude e2e_test --message-format json \
|
||||||
|
> artifacts/test-and-lint/core-test-listing.json \
|
||||||
|
&& python3 scripts/check_test_wiring.py --check-core artifacts/test-and-lint/core-test-listing.json \
|
||||||
|
&& test -s target/nextest/ci/junit.xml || status=$?
|
||||||
|
fi
|
||||||
{
|
{
|
||||||
echo "command=cargo nextest run --profile ci --all --exclude e2e_test"
|
echo "command=cargo nextest run --profile ci --all --exclude e2e_test"
|
||||||
echo "exit_status=${status}"
|
echo "exit_status=${status}"
|
||||||
@@ -906,6 +850,11 @@ jobs:
|
|||||||
cache-save-if: 'false'
|
cache-save-if: 'false'
|
||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Install network fault-injection tools
|
||||||
|
run: |
|
||||||
|
sudo apt-get install -y iptables
|
||||||
|
sudo -n iptables --version
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
# 4-node 4-disk distributed e2e lane.
|
||||||
|
#
|
||||||
|
# Each selected test starts a real localhost cluster via
|
||||||
|
# `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the
|
||||||
|
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
|
||||||
|
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
|
||||||
|
# nightly runs, and manual dispatches all execute the same fail-closed suite.
|
||||||
|
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
|
||||||
|
#
|
||||||
|
# Isolated pool filesystems: expand/decommission/rebalance cases require
|
||||||
|
# independent `statfs` capacity. This job runs on GitHub-hosted
|
||||||
|
# `ubuntu-latest` because the self-hosted `sm-standard-4` ARC pods cannot
|
||||||
|
# create filesystems: `mount -o loop` fails with ENOENT (no
|
||||||
|
# `/dev/loop-control`), and `mount -t tmpfs` fails with "cannot mount tmpfs
|
||||||
|
# read-only" (no `CAP_SYS_ADMIN` in the initial namespace). The same reason
|
||||||
|
# `uring-integration` and `e2e-s3tests.yml` left that label. The prepare
|
||||||
|
# step mounts four 1 GiB tmpfs instances and exports `RUSTFS_E2E_POOL_ROOTS`.
|
||||||
|
|
||||||
|
name: e2e-distributed
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "Cargo.lock"
|
||||||
|
- "Cargo.toml"
|
||||||
|
- ".config/nextest.toml"
|
||||||
|
- ".github/workflows/e2e-distributed.yml"
|
||||||
|
- "crates/audit/**"
|
||||||
|
- "crates/common/**"
|
||||||
|
- "crates/config/**"
|
||||||
|
- "crates/e2e_test/**"
|
||||||
|
- "crates/ecstore/**"
|
||||||
|
- "crates/filemeta/**"
|
||||||
|
- "crates/heal/**"
|
||||||
|
- "crates/iam/**"
|
||||||
|
- "crates/lock/**"
|
||||||
|
- "crates/madmin/**"
|
||||||
|
- "crates/notify/**"
|
||||||
|
- "crates/replication/**"
|
||||||
|
- "crates/s3-client/**"
|
||||||
|
- "crates/s3-ops/**"
|
||||||
|
- "crates/s3-types/**"
|
||||||
|
- "crates/scanner/**"
|
||||||
|
- "crates/storage-api/**"
|
||||||
|
- "crates/utils/**"
|
||||||
|
- "rustfs/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
filter:
|
||||||
|
description: "Optional nextest -E filter (default: the whole e2e-distributed profile)"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
schedule:
|
||||||
|
# 05:53 UTC nightly — clear of e2e-nightly (04:29) and ODM interop (05:23).
|
||||||
|
- cron: "53 5 * * *"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
distributed:
|
||||||
|
name: Distributed 4-node 4-disk e2e
|
||||||
|
# GitHub-hosted VM: loop and tmpfs mounts work here. sm-standard-4 is an
|
||||||
|
# ARC pod and rejects both (`mount -o loop` ENOENT, tmpfs "read-only").
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 180
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
NO_PROXY: 127.0.0.1,localhost
|
||||||
|
HTTP_PROXY: ""
|
||||||
|
HTTPS_PROXY: ""
|
||||||
|
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
|
||||||
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||||
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||||
|
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
# Dedicated key: ubuntu-latest and sm-standard-4 share runner.os, so
|
||||||
|
# a shared key would mix VM and ARC pod target/ artifacts.
|
||||||
|
cache-shared-key: ci-e2e-distributed-hosted
|
||||||
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Prepare isolated filesystems for pool movement
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||||
|
mkdir -p "${mount_base}"
|
||||||
|
roots=()
|
||||||
|
for pool in 0 1 2 3; do
|
||||||
|
mountpoint="${mount_base}/pool-${pool}"
|
||||||
|
mkdir -p "${mountpoint}"
|
||||||
|
# Sized tmpfs reports a distinct st_dev and independent 1G
|
||||||
|
# statfs capacity. Requires a VM runner (ubuntu-latest).
|
||||||
|
if ! sudo mount -t tmpfs -o size=1G,nosuid,nodev,mode=1777 tmpfs "${mountpoint}"; then
|
||||||
|
echo "tmpfs mount failed on $(uname -a)" >&2
|
||||||
|
findmnt || true
|
||||||
|
grep Cap /proc/self/status || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo chmod 1777 "${mountpoint}"
|
||||||
|
roots+=("${mountpoint}")
|
||||||
|
done
|
||||||
|
printf -v joined_roots '%s:' "${roots[@]}"
|
||||||
|
echo "RUSTFS_E2E_POOL_ROOTS=${joined_roots%:}" >> "${GITHUB_ENV}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[0]}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[1]}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[2]}"
|
||||||
|
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[3]}"
|
||||||
|
|
||||||
|
- name: Download pinned previous release
|
||||||
|
env:
|
||||||
|
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p "$SOURCE_DIR"
|
||||||
|
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
|
||||||
|
curl --fail --location --retry 3 --output "$archive" \
|
||||||
|
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||||
|
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||||
|
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||||
|
chmod +x "$SOURCE_DIR/rustfs"
|
||||||
|
test -x "$SOURCE_DIR/rustfs"
|
||||||
|
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Build rustfs binary
|
||||||
|
run: |
|
||||||
|
cargo build -p rustfs --bins
|
||||||
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
|
- name: Verify distributed e2e membership
|
||||||
|
env:
|
||||||
|
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||||
|
run: |
|
||||||
|
cargo nextest list --profile e2e-distributed -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
||||||
|
python3 ./scripts/check_test_wiring.py --check-profile e2e-distributed "${NEXTEST_LISTING}"
|
||||||
|
|
||||||
|
- name: Run distributed 4-node e2e suite
|
||||||
|
env:
|
||||||
|
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
|
||||||
|
FILTER: ${{ inputs.filter }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -n "${FILTER}" ]; then
|
||||||
|
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||||
|
else
|
||||||
|
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload distributed e2e diagnostics
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: e2e-distributed-${{ github.run_number }}
|
||||||
|
path: |
|
||||||
|
target/nextest/e2e-distributed/junit.xml
|
||||||
|
${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||||
|
${{ runner.temp }}/rustfs-e2e-distributed-logs/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
|
- name: Unmount isolated pool filesystems
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||||
|
for pool in 0 1 2 3; do
|
||||||
|
mountpoint="${mount_base}/pool-${pool}"
|
||||||
|
if mountpoint --quiet "${mountpoint}"; then
|
||||||
|
sudo umount "${mountpoint}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
needs: [distributed]
|
||||||
|
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Open or update failure-tracking issue
|
||||||
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -19,7 +19,9 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- ".github/workflows/e2e-upgrade.yml"
|
- ".github/workflows/e2e-upgrade.yml"
|
||||||
- "crates/e2e_test/src/common.rs"
|
- "crates/e2e_test/src/common.rs"
|
||||||
|
- "crates/e2e_test/src/fake_s3_target/**"
|
||||||
- "crates/e2e_test/src/lib.rs"
|
- "crates/e2e_test/src/lib.rs"
|
||||||
|
- "crates/e2e_test/src/replication_extension_test.rs"
|
||||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||||
- "crates/ecstore/**"
|
- "crates/ecstore/**"
|
||||||
- "crates/filemeta/**"
|
- "crates/filemeta/**"
|
||||||
@@ -44,13 +46,42 @@ concurrency:
|
|||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUST_BACKTRACE: 1
|
RUST_BACKTRACE: 1
|
||||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
|
||||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
|
||||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
direct-upgrade:
|
upgrade:
|
||||||
name: Direct upgrade from rc.2
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
@@ -64,7 +95,7 @@ jobs:
|
|||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
cache-shared-key: e2e-direct-upgrade
|
cache-shared-key: ${{ matrix.cache_key }}
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
install-build-packaging-tools: "false"
|
install-build-packaging-tools: "false"
|
||||||
|
|
||||||
@@ -89,17 +120,17 @@ jobs:
|
|||||||
cargo build --locked -p rustfs --bin rustfs
|
cargo build --locked -p rustfs --bin rustfs
|
||||||
: > target/debug/rustfs.features
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
- name: Run direct-upgrade compatibility test
|
- name: Run upgrade compatibility test
|
||||||
run: |
|
run: |
|
||||||
cargo test --locked -p e2e_test \
|
cargo test --locked -p e2e_test \
|
||||||
upgrade_compatibility_test::direct_upgrade_from_rc2_preserves_object_contracts \
|
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||||
-- --ignored --exact --nocapture
|
-- --ignored --exact --nocapture
|
||||||
|
|
||||||
- name: Upload server logs
|
- name: Upload server logs
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: direct-upgrade-server-logs-${{ github.run_number }}
|
name: ${{ matrix.artifact }}-server-logs-${{ github.run_number }}
|
||||||
path: ${{ runner.temp }}/rustfs-upgrade-logs
|
path: ${{ runner.temp }}/rustfs-upgrade-logs
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|||||||
@@ -20,27 +20,27 @@
|
|||||||
# each run with Docker and then runs the `#[ignore]` reader tests in
|
# each run with Docker and then runs the `#[ignore]` reader tests in
|
||||||
# rustfs/src/storage/minio_generated_read_test.rs.
|
# rustfs/src/storage/minio_generated_read_test.rs.
|
||||||
#
|
#
|
||||||
# Scope: end-to-end MinIO-to-RustFS SSE interop is NOT implemented yet. Both
|
# Scope: MinIO-to-RustFS SSE read interop is implemented behind the `rio-v2`
|
||||||
# envelope parsers reject MinIO's own wrapped-DEK shape — see
|
# feature for MinIO's builtin static-KMS deployments — SSE-S3 and SSE-KMS
|
||||||
# `is_data_key_envelope` in crates/kms/src/encryption/dek.rs and the
|
# (single- and multipart) since rustfs/rustfs#6191, SSE-C detection since the
|
||||||
# `deny_unknown_fields` `LocalSseDekEnvelope` in rustfs/src/storage/sse.rs — and
|
# rustfs/backlog#1638 D2 close-out. This job is the standing evidence: it
|
||||||
# closing that gap is tracked in rustfs/backlog#1638. Treat this job as the
|
# regenerates real MinIO backend trees and proves byte-identical plaintext
|
||||||
# harness for #1638, not as standing evidence that a MinIO migration reads back.
|
# reconstruction. KES/MinKMS-backed MinIO objects remain unreadable by design
|
||||||
|
# (their envelopes are sealed by the KES service, not by a key RustFS can
|
||||||
|
# hold), and default RustFS builds do not include the read path — it is a
|
||||||
|
# special-purpose migration capability, not a default-build feature.
|
||||||
#
|
#
|
||||||
# Runner: GitHub-hosted `ubuntu-latest`. It reliably ships Docker + Python,
|
# Runner: GitHub-hosted `ubuntu-latest`. It reliably ships Docker + Python,
|
||||||
# unlike the self-hosted fleet, whose pods drift in Docker/pip availability
|
# unlike the self-hosted fleet, whose pods drift in Docker/pip availability
|
||||||
# (see the infra note in e2e-s3tests.yml). Nightly + manual only.
|
# (see the infra note in e2e-s3tests.yml). Nightly + manual only.
|
||||||
# DISABLED. This workflow is switched off in the repository's Actions settings
|
|
||||||
# (state: disabled_manually) and does not run on any trigger, including its cron
|
|
||||||
# and workflow_dispatch. That state lives in GitHub's UI and is invisible when
|
|
||||||
# reading this file, which has already misled at least one audit — hence this
|
|
||||||
# banner. Re-enabling is a UI action; anyone doing so should first check that the
|
|
||||||
# workflow still matches the current CI layout. See rustfs/backlog#1603.
|
|
||||||
#
|
#
|
||||||
# While disabled, this workflow is deliberately absent from
|
# Enablement: this workflow was long disabled in the repository's Actions
|
||||||
# .github/scheduled-validations.json — a disabled workflow can never satisfy the
|
# settings (state: disabled_manually — a state that lives in GitHub's UI and is
|
||||||
# freshness check. Whoever re-enables it must re-add the entry in the same
|
# invisible in this file). The change that updated this banner also re-added
|
||||||
# change so the freshness gate covers it again.
|
# the .github/scheduled-validations.json entry; both only make sense together
|
||||||
|
# with re-enabling the workflow in the Actions settings. If it is ever disabled
|
||||||
|
# again, remove the scheduled-validations entry in the same change — a disabled
|
||||||
|
# workflow can never satisfy the freshness check. See rustfs/backlog#1603.
|
||||||
#
|
#
|
||||||
name: minio-interop
|
name: minio-interop
|
||||||
|
|
||||||
|
|||||||
@@ -166,8 +166,9 @@ jobs:
|
|||||||
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
||||||
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
||||||
- name: Upload DEB to Cloudflare R2
|
- name: Upload DEB to Cloudflare R2
|
||||||
if: env.R2_ACCESS_KEY_ID != ''
|
id: publish
|
||||||
env:
|
env:
|
||||||
|
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
@@ -182,28 +183,70 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v aws >/dev/null 2>&1; then
|
|
||||||
sudo apt-get update && sudo apt-get install -y -qq awscli
|
|
||||||
fi
|
|
||||||
|
|
||||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||||
export AWS_DEFAULT_REGION="auto"
|
export AWS_DEFAULT_REGION="auto"
|
||||||
|
|
||||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
SOURCE_SHA="$(git rev-parse HEAD)"
|
||||||
|
if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then
|
||||||
|
echo "Checkout SHA does not match the nightly build run" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
|
||||||
|
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
|
||||||
|
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
|
||||||
|
|
||||||
|
# Old AWS CLI models lack conditional PutObject support. Never fall
|
||||||
|
# back to an overwriting upload for a candidate.
|
||||||
|
AWS_CLI=aws
|
||||||
|
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y -qq python3-venv
|
||||||
|
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/nightly-awscli.XXXXXX")"
|
||||||
|
trap 'rm -rf "${AWS_CLI_DIR}"' EXIT
|
||||||
|
python3 -m venv "${AWS_CLI_DIR}"
|
||||||
|
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
|
||||||
|
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
|
||||||
|
fi
|
||||||
|
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
|
||||||
|
"${AWS_CLI}" --version
|
||||||
|
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${CANDIDATE_KEY}" \
|
||||||
|
--body "${DEB_FILE}" --if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
|
||||||
|
PUBLISHED_SHA256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${CANDIDATE_URL}" | sha256sum | cut -d ' ' -f 1)"
|
||||||
|
if [[ "${PUBLISHED_SHA256}" != "${DEB_SHA256}" ]]; then
|
||||||
|
echo "Published candidate checksum does not match the built package" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
||||||
|
|
||||||
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
||||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||||
|
|
||||||
# Stable "latest" alias so tests can fetch the newest nightly
|
# Stable "latest" alias so tests can fetch the newest nightly
|
||||||
# without knowing today's date.
|
# without knowing today's date.
|
||||||
echo "📤 Uploading latest alias"
|
echo "📤 Uploading latest alias"
|
||||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
||||||
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||||
|
|
||||||
echo "✅ R2 upload complete"
|
echo "✅ R2 upload complete"
|
||||||
|
|
||||||
|
CANDIDATE_FILE="${RUNNER_TEMP}/nightly-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
|
||||||
|
jq -n --arg source_sha "${SOURCE_SHA}" \
|
||||||
|
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||||
|
--arg package_url "${CANDIDATE_URL}" --arg package_sha256 "${DEB_SHA256}" \
|
||||||
|
'{schema: 1, source_sha: $source_sha, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' \
|
||||||
|
> "${CANDIDATE_FILE}"
|
||||||
|
echo "candidate_file=${CANDIDATE_FILE}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Upload nightly candidate manifest
|
||||||
|
if: ${{ steps.publish.outputs.candidate_file != '' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: nightly-candidate-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: ${{ steps.publish.outputs.candidate_file }}
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
||||||
#
|
#
|
||||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'flake.nix'
|
- 'flake.nix'
|
||||||
- 'flake.lock'
|
- 'flake.lock'
|
||||||
|
- 'nix/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- '.github/workflows/nix.yml'
|
- '.github/workflows/nix.yml'
|
||||||
@@ -36,6 +37,7 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'flake.nix'
|
- 'flake.nix'
|
||||||
- 'flake.lock'
|
- 'flake.lock'
|
||||||
|
- 'nix/**'
|
||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- '.github/workflows/nix.yml'
|
- '.github/workflows/nix.yml'
|
||||||
|
|||||||
@@ -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 }}
|
||||||
+170
-97
@@ -21,10 +21,10 @@
|
|||||||
# - workflow_run: automatically package after "Build and Release" completes
|
# - workflow_run: automatically package after "Build and Release" completes
|
||||||
# for a release tag (the mac/windows/linux binaries are already uploaded
|
# for a release tag (the mac/windows/linux binaries are already uploaded
|
||||||
# to the GitHub release before packaging starts)
|
# 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:
|
# 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
|
# 2. Download Linux binaries (x86_64-gnu, aarch64-gnu) from build artifacts
|
||||||
# 3. Build DEB packages for amd64 and arm64
|
# 3. Build DEB packages for amd64 and arm64
|
||||||
# 4. Build RPM packages for x86_64 and aarch64
|
# 4. Build RPM packages for x86_64 and aarch64
|
||||||
@@ -51,7 +51,7 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
type: string
|
type: string
|
||||||
build_run_id:
|
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
|
required: false
|
||||||
type: string
|
type: string
|
||||||
|
|
||||||
@@ -82,6 +82,9 @@ jobs:
|
|||||||
version: ${{ steps.resolve.outputs.version }}
|
version: ${{ steps.resolve.outputs.version }}
|
||||||
build_type: ${{ steps.resolve.outputs.build_type }}
|
build_type: ${{ steps.resolve.outputs.build_type }}
|
||||||
build_run_id: ${{ steps.resolve.outputs.build_run_id }}
|
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 }}
|
tag: ${{ steps.resolve.outputs.tag }}
|
||||||
steps:
|
steps:
|
||||||
- name: Resolve build run
|
- name: Resolve build run
|
||||||
@@ -89,90 +92,129 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||||
INPUT_RUN_ID: ${{ github.event.inputs.build_run_id }}
|
INPUT_RUN_ID: ${{ github.event.inputs.build_run_id }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Determine tag
|
fail() {
|
||||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
echo "❌ $1" >&2
|
||||||
TAG="${HEAD_BRANCH}"
|
exit 1
|
||||||
elif [[ -n "$INPUT_TAG" ]]; then
|
}
|
||||||
TAG="$INPUT_TAG"
|
|
||||||
|
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
|
else
|
||||||
TAG=""
|
BUILD_TYPE=development
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Tag: ${TAG:-<none>}"
|
if [[ -n "$BUILD_RUN_ID" ]]; then
|
||||||
|
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "build run ID must be a positive decimal integer"
|
||||||
# Determine build run ID
|
echo "Using selected build run: $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"
|
|
||||||
|
|
||||||
elif [[ -n "$TAG" ]]; then
|
elif [[ -n "$TAG" ]]; then
|
||||||
# Find the build run that produced this tag
|
|
||||||
echo "Looking for build run for tag: $TAG"
|
echo "Looking for build run for tag: $TAG"
|
||||||
BUILD_RUN_ID=$(gh api \
|
BUILD_RUN_ID=$(gh api --method GET \
|
||||||
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?branch=${TAG}&status=success&per_page=1" \
|
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||||
--jq '.workflow_runs[0].id' 2>/dev/null || echo "")
|
-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
|
if [[ -z "$BUILD_RUN_ID" ]]; then
|
||||||
# Tag might not be a branch; try event=push with head_branch matching
|
BUILD_RUN_ID=$(gh api --method GET \
|
||||||
BUILD_RUN_ID=$(gh api \
|
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||||
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?event=push&status=success&per_page=100" \
|
-f event=push -f status=success -F per_page=100 2>/dev/null |
|
||||||
--jq ".workflow_runs[] | select(.head_branch == \"$TAG\") | .id" 2>/dev/null | head -1 || echo "")
|
jq -r --arg tag "$TAG" \
|
||||||
fi
|
'[.workflow_runs[] | select(.head_branch == $tag)][0].id // empty' || true)
|
||||||
|
|
||||||
if [[ -z "$BUILD_RUN_ID" || "$BUILD_RUN_ID" == "null" ]]; then
|
|
||||||
echo "❌ No successful build run found for tag: $TAG"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful build run found for tag"
|
||||||
echo "Found build run: $BUILD_RUN_ID"
|
echo "Found build run: $BUILD_RUN_ID"
|
||||||
|
|
||||||
else
|
else
|
||||||
# No tag — latest successful main build
|
|
||||||
echo "No tag specified, looking for latest main build"
|
echo "No tag specified, looking for latest main build"
|
||||||
BUILD_RUN_ID=$(gh api \
|
BUILD_RUN_ID=$(gh api --method GET \
|
||||||
"repos/${{ github.repository }}/actions/workflows/build.yml/runs?branch=main&status=success&per_page=1" \
|
"repos/${REPOSITORY}/actions/workflows/build.yml/runs" \
|
||||||
--jq '.workflow_runs[0].id' 2>/dev/null || echo "")
|
-f branch=main -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
|
[[ "$BUILD_RUN_ID" =~ ^[1-9][0-9]*$ ]] || fail "no successful main build found"
|
||||||
echo "❌ No successful main build found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Latest main build: $BUILD_RUN_ID"
|
echo "Latest main build: $BUILD_RUN_ID"
|
||||||
fi
|
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
|
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"
|
VERSION="$TAG"
|
||||||
if [[ "$TAG" == *"-preview"* ]]; then
|
DEV_SEQUENCE=""
|
||||||
BUILD_TYPE="preview"
|
|
||||||
elif [[ "$TAG" == *"alpha"* || "$TAG" == *"beta"* || "$TAG" == *"rc"* ]]; then
|
|
||||||
BUILD_TYPE="prerelease"
|
|
||||||
else
|
|
||||||
BUILD_TYPE="release"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
SHORT_SHA=$(gh api "repos/${{ github.repository }}/actions/runs/${BUILD_RUN_ID}" \
|
VERSION="dev-${HEAD_SHA}"
|
||||||
--jq '.head_sha' 2>/dev/null | head -c 7)
|
DEV_SEQUENCE="$RUN_NUMBER"
|
||||||
VERSION="dev-${SHORT_SHA}"
|
|
||||||
BUILD_TYPE="development"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "version=$VERSION"
|
echo "version=$VERSION"
|
||||||
echo "build_type=$BUILD_TYPE"
|
echo "build_type=$BUILD_TYPE"
|
||||||
echo "build_run_id=$BUILD_RUN_ID"
|
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}"
|
echo "tag=${TAG}"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
@@ -180,6 +222,7 @@ jobs:
|
|||||||
echo " Version: $VERSION"
|
echo " Version: $VERSION"
|
||||||
echo " Build type: $BUILD_TYPE"
|
echo " Build type: $BUILD_TYPE"
|
||||||
echo " Build run ID: $BUILD_RUN_ID"
|
echo " Build run ID: $BUILD_RUN_ID"
|
||||||
|
echo " Build run number: $RUN_NUMBER"
|
||||||
|
|
||||||
# Build DEB and RPM packages for each architecture
|
# Build DEB and RPM packages for each architecture
|
||||||
package:
|
package:
|
||||||
@@ -206,6 +249,22 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
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
|
- name: Download binary artifact from build run
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
with:
|
with:
|
||||||
@@ -224,7 +283,7 @@ jobs:
|
|||||||
ZIP_FILE=$(find ./binary-artifact -name "*.zip" -type f | head -1)
|
ZIP_FILE=$(find ./binary-artifact -name "*.zip" -type f | head -1)
|
||||||
if [[ -z "$ZIP_FILE" ]]; then
|
if [[ -z "$ZIP_FILE" ]]; then
|
||||||
echo "❌ No binary artifact found"
|
echo "❌ No binary artifact found"
|
||||||
ls -la ./binary-artifact/ || true
|
find ./binary-artifact -mindepth 1 -maxdepth 1 -print 2>/dev/null || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -239,24 +298,22 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
chmod +x ./bin/rustfs
|
chmod +x ./bin/rustfs
|
||||||
ls -lh ./bin/rustfs
|
stat --printf='%n %s bytes\n' ./bin/rustfs
|
||||||
echo "✅ Binary extracted"
|
echo "✅ Binary extracted"
|
||||||
|
|
||||||
- name: Build DEB package
|
- name: Build DEB package
|
||||||
id: deb
|
id: deb
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
DEB_VERSION: ${{ steps.versions.outputs.deb_version }}
|
||||||
|
DEB_ARCH: ${{ matrix.deb_arch }}
|
||||||
|
DEB_FILE: ${{ steps.versions.outputs.deb_file }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${{ needs.resolve.outputs.version }}"
|
PKG_DIR="${DEB_FILE%.deb}"
|
||||||
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}"
|
|
||||||
|
|
||||||
echo "Building DEB: ${PKG_DIR}.deb"
|
echo "Building DEB: ${DEB_FILE}"
|
||||||
|
|
||||||
mkdir -p "${PKG_DIR}/DEBIAN"
|
mkdir -p "${PKG_DIR}/DEBIAN"
|
||||||
mkdir -p "${PKG_DIR}/usr/bin"
|
mkdir -p "${PKG_DIR}/usr/bin"
|
||||||
@@ -333,26 +390,32 @@ jobs:
|
|||||||
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
|
cp LICENSE "${PKG_DIR}/usr/share/doc/rustfs/"
|
||||||
cp README.md "${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 ]]
|
||||||
ls -lh "$DEB_FILE"
|
[[ $(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_file=$DEB_FILE" >> "$GITHUB_OUTPUT"
|
||||||
echo "✅ DEB built: $DEB_FILE"
|
echo "✅ DEB built: $DEB_FILE"
|
||||||
|
|
||||||
- name: Build RPM package
|
- name: Build RPM package
|
||||||
id: rpm
|
id: rpm
|
||||||
shell: bash
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
VERSION="${{ needs.resolve.outputs.version }}"
|
|
||||||
RPM_ARCH="${{ matrix.rpm_arch }}"
|
|
||||||
|
|
||||||
echo "Building RPM for ${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
|
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,
|
# Create config file for fpm (DEB build creates it in its package dir structure,
|
||||||
# but fpm needs the file to exist before packaging)
|
# but fpm needs the file to exist before packaging)
|
||||||
@@ -367,8 +430,10 @@ jobs:
|
|||||||
|
|
||||||
fpm -s dir -t rpm \
|
fpm -s dir -t rpm \
|
||||||
--name rustfs \
|
--name rustfs \
|
||||||
--version "$VERSION" \
|
--version "$RPM_VERSION" \
|
||||||
|
--iteration "$RPM_RELEASE" \
|
||||||
--architecture "$RPM_ARCH" \
|
--architecture "$RPM_ARCH" \
|
||||||
|
--package "$RPM_FILE" \
|
||||||
--depends "glibc >= 2.31" \
|
--depends "glibc >= 2.31" \
|
||||||
--maintainer "RustFS Team <support@rustfs.com>" \
|
--maintainer "RustFS Team <support@rustfs.com>" \
|
||||||
--description "High-performance distributed object storage" \
|
--description "High-performance distributed object storage" \
|
||||||
@@ -410,13 +475,16 @@ jobs:
|
|||||||
LICENSE=/usr/share/doc/rustfs/LICENSE \
|
LICENSE=/usr/share/doc/rustfs/LICENSE \
|
||||||
README.md=/usr/share/doc/rustfs/README.md
|
README.md=/usr/share/doc/rustfs/README.md
|
||||||
|
|
||||||
RPM_FILE=$(ls -1 rustfs-*.rpm 2>/dev/null | head -1)
|
if [[ ! -f "$RPM_FILE" ]]; then
|
||||||
if [[ -z "$RPM_FILE" ]]; then
|
|
||||||
echo "❌ RPM build failed"
|
echo "❌ RPM build failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ls -lh "$RPM_FILE"
|
RPM_METADATA=$(rpm -qp --qf '%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n' "$RPM_FILE")
|
||||||
|
EXPECTED_METADATA=$(printf 'rustfs\n%s\n%s\n%s' "$RPM_VERSION" "$RPM_RELEASE" "$RPM_ARCH")
|
||||||
|
[[ "$RPM_METADATA" == "$EXPECTED_METADATA" ]]
|
||||||
|
rpm -qpl "$RPM_FILE" | grep -Fx '/usr/bin/rustfs' >/dev/null
|
||||||
|
stat --printf='%n %s bytes\n' "$RPM_FILE"
|
||||||
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
|
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
|
||||||
echo "✅ RPM built: $RPM_FILE"
|
echo "✅ RPM built: $RPM_FILE"
|
||||||
|
|
||||||
@@ -437,6 +505,9 @@ jobs:
|
|||||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||||
AWS_EC2_METADATA_DISABLED: true
|
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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -454,7 +525,6 @@ jobs:
|
|||||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||||
export AWS_DEFAULT_REGION="auto"
|
export AWS_DEFAULT_REGION="auto"
|
||||||
|
|
||||||
BUILD_TYPE="${{ needs.resolve.outputs.build_type }}"
|
|
||||||
if [[ "$BUILD_TYPE" == "development" ]]; then
|
if [[ "$BUILD_TYPE" == "development" ]]; then
|
||||||
R2_PREFIX="artifacts/rustfs/packages/dev"
|
R2_PREFIX="artifacts/rustfs/packages/dev"
|
||||||
else
|
else
|
||||||
@@ -464,9 +534,6 @@ jobs:
|
|||||||
|
|
||||||
echo "📤 Uploading to $R2_PATH"
|
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
|
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||||
if [[ -n "$f" && -f "$f" ]]; then
|
if [[ -n "$f" && -f "$f" ]]; then
|
||||||
echo "Uploading: $f"
|
echo "Uploading: $f"
|
||||||
@@ -492,14 +559,13 @@ jobs:
|
|||||||
if: needs.resolve.outputs.tag != ''
|
if: needs.resolve.outputs.tag != ''
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
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
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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
|
# Upload the packages, then refresh the release checksums so the new
|
||||||
# assets are covered, matching the binary release flow.
|
# assets are covered, matching the binary release flow.
|
||||||
for f in "$DEB_FILE" "$RPM_FILE"; do
|
for f in "$DEB_FILE" "$RPM_FILE"; do
|
||||||
@@ -551,12 +617,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Print summary
|
- name: Print summary
|
||||||
shell: bash
|
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: |
|
run: |
|
||||||
echo "## 📦 Package Summary" >> "$GITHUB_STEP_SUMMARY"
|
{
|
||||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
echo "## 📦 Package Summary"
|
||||||
echo "| Item | Value |" >> "$GITHUB_STEP_SUMMARY"
|
echo ""
|
||||||
echo "|------|-------|" >> "$GITHUB_STEP_SUMMARY"
|
echo "| Item | Value |"
|
||||||
echo "| Version | \`${{ needs.resolve.outputs.version }}\` |" >> "$GITHUB_STEP_SUMMARY"
|
echo "|------|-------|"
|
||||||
echo "| Build Type | ${{ needs.resolve.outputs.build_type }} |" >> "$GITHUB_STEP_SUMMARY"
|
echo "| Version | \`${SUMMARY_VERSION}\` |"
|
||||||
echo "| Build Run | #${{ needs.resolve.outputs.build_run_id }} |" >> "$GITHUB_STEP_SUMMARY"
|
echo "| Build Type | ${SUMMARY_BUILD_TYPE} |"
|
||||||
echo "| Package Status | ${{ needs.package.result }} |" >> "$GITHUB_STEP_SUMMARY"
|
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'
|
||||||
@@ -15,10 +15,6 @@ on:
|
|||||||
description: 'Stop warp when surviving nodes reach N GiB'
|
description: 'Stop warp when surviving nodes reach N GiB'
|
||||||
required: false
|
required: false
|
||||||
default: '40'
|
default: '40'
|
||||||
heal_target_gb:
|
|
||||||
description: 'Outage node must reach N GiB after heal to pass'
|
|
||||||
required: false
|
|
||||||
default: '40'
|
|
||||||
cleanup_before:
|
cleanup_before:
|
||||||
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -27,6 +23,11 @@ on:
|
|||||||
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -34,7 +35,7 @@ permissions:
|
|||||||
# Only one test at a time: both this and the pool-expansion workflow mutate
|
# Only one test at a time: both this and the pool-expansion workflow mutate
|
||||||
# the same test environment, so they share one concurrency group.
|
# the same test environment, so they share one concurrency group.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: rustfs-pool-expansion-test
|
group: rustfs-shared-functional-tests
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
@@ -47,17 +48,51 @@ env:
|
|||||||
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
heal-test:
|
heal-test:
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
timeout-minutes: 480
|
timeout-minutes: 480
|
||||||
|
# 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:
|
steps:
|
||||||
- name: Checkout
|
- name: Initialize functional evidence
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
id: evidence
|
||||||
with:
|
run: |
|
||||||
persist-credentials: false
|
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
|
- name: Show environment
|
||||||
run: |
|
run: |
|
||||||
@@ -67,11 +102,24 @@ jobs:
|
|||||||
warp --version || true
|
warp --version || true
|
||||||
df -h /data | tail -1
|
df -h /data | tail -1
|
||||||
|
|
||||||
- name: Reset test environment (before)
|
- name: Cleanup environment (before)
|
||||||
if: ${{ inputs.cleanup_before != 'false' }}
|
if: ${{ inputs.cleanup_before != 'false' }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/test/rustfs_heal_test.sh
|
set -euo pipefail
|
||||||
./scripts/test/rustfs_heal_test.sh --reset -y
|
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
|
- name: Install RustFS package & start cluster
|
||||||
run: |
|
run: |
|
||||||
@@ -81,7 +129,7 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./scripts/test/rustfs_heal_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Preflight checks
|
- name: Preflight checks
|
||||||
run: |
|
run: |
|
||||||
@@ -91,32 +139,286 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./scripts/test/rustfs_heal_test.sh "${ARGS[@]}"
|
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Run heal test (write -> outage -> heal -> verify)
|
- name: Run heal test (write -> outage -> heal -> verify)
|
||||||
|
id: test
|
||||||
run: |
|
run: |
|
||||||
./scripts/test/rustfs_heal_test.sh \
|
./auto-testing/rustfs_heal_test.sh \
|
||||||
--steps "3,4,5,6,7" -y \
|
--steps "3,4,5,6,7" -y \
|
||||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||||
--stop-node-gb "${{ inputs.stop_node_gb }}" \
|
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
||||||
--warp-stop-gb "${{ inputs.warp_stop_gb }}" \
|
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
||||||
--heal-target-gb "${{ inputs.heal_target_gb }}" \
|
--log-file "${LOG_FILE}"
|
||||||
--log-file /tmp/rustfs-heal-test.log
|
|
||||||
|
- name: Generate report
|
||||||
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
|
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
|
||||||
|
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: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
|
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() && steps.evidence.outcome == 'success' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
|
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"
|
||||||
|
# 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: '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
|
- name: Upload test logs
|
||||||
if: always()
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-heal-test-${{ github.run_id }}
|
name: rustfs-heal-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: |
|
||||||
/tmp/rustfs-heal-test*.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||||
/tmp/rustfs-warp.*.log
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||||
if-no-files-found: warn
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/warp.log
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/steps.md
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Reset test environment (after)
|
- name: Cleanup environment (after)
|
||||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||||
run: |
|
run: |
|
||||||
./scripts/test/rustfs_heal_test.sh --reset -y
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: "Continue functional chain (next: Pool expansion)"
|
||||||
|
# Only chain-triggered runs forward to the next suite; standalone
|
||||||
|
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||||
|
# handoff must never pass silently: it retries, then files an alert
|
||||||
|
# issue in rustfs/backlog so a stalled chain is visible.
|
||||||
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DISPATCHED=0
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
|
-f event_type='rustfs-chain-pool' \
|
||||||
|
-F 'client_payload[from_suite]=heal'; then
|
||||||
|
echo "dispatched next suite Pool expansion (attempt ${attempt})"
|
||||||
|
DISPATCHED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||||
|
sleep "${attempt}0"
|
||||||
|
done
|
||||||
|
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||||
|
echo "ERROR: functional chain stalled: could not dispatch Pool expansion after 3 attempts" >&2
|
||||||
|
TITLE="[functional][chain] stalled after heal (run ${GITHUB_RUN_ID})"
|
||||||
|
BODY_FILE="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "The functional chain could not hand off from **heal** to **Pool expansion** after 3 attempts."
|
||||||
|
echo ""
|
||||||
|
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
|
echo "- Expected next event: 'rustfs-chain-pool'"
|
||||||
|
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||||
|
echo "- Recovery: re-dispatch manually with"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-pool'"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
} > "${BODY_FILE}"
|
||||||
|
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||||
|
--body-file "${BODY_FILE}" --label functional-test \
|
||||||
|
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||||
|
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
name: RustFS KMS Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
rustfs_version:
|
||||||
|
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||||
|
required: false
|
||||||
|
default: '1.0.0-rc.4-preview.1'
|
||||||
|
package_url:
|
||||||
|
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
enforce_sse_key_policy:
|
||||||
|
description: 'Enable RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY (runs KMS-401/402)'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
frame_v2:
|
||||||
|
description: 'Enable RUSTFS_ENCRYPTION_FRAME_V2 (runs KMS-318)'
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
config_secret:
|
||||||
|
description: 'Set RUSTFS_KMS_CONFIG_SECRET (runs KMS-107 config sealing)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
repository_dispatch:
|
||||||
|
# Chain handoff: dispatched when the S3 compatibility suite finishes.
|
||||||
|
types: [rustfs-chain-kms]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: rustfs-shared-functional-tests
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
|
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||||
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
kms-test:
|
||||||
|
runs-on: smoke-testing
|
||||||
|
timeout-minutes: 420
|
||||||
|
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-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: |
|
||||||
|
uname -a
|
||||||
|
jq --version
|
||||||
|
openssl version
|
||||||
|
docker --version || true
|
||||||
|
|
||||||
|
- name: Cleanup environment (before)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Ensure docker (Vault container)
|
||||||
|
run: |
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y docker.io
|
||||||
|
fi
|
||||||
|
sudo systemctl enable --now docker
|
||||||
|
docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1
|
||||||
|
|
||||||
|
- name: Run KMS suite
|
||||||
|
id: test
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
chmod +x auto-testing/rustfs-kms-test.sh
|
||||||
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
|
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||||
|
ARGS=(--all-topologies --backends "local,vault-kv2" -y --log-file "${LOG_FILE}")
|
||||||
|
EXTRA_ENV=""
|
||||||
|
if [ "${{ inputs.enforce_sse_key_policy }}" = "true" ]; then
|
||||||
|
EXTRA_ENV+="RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY=true"$'\n'
|
||||||
|
fi
|
||||||
|
if [ "${{ inputs.frame_v2 }}" = "true" ]; then
|
||||||
|
EXTRA_ENV+="RUSTFS_ENCRYPTION_FRAME_V2=true"$'\n'
|
||||||
|
fi
|
||||||
|
if [ -n "${{ inputs.config_secret }}" ]; then
|
||||||
|
EXTRA_ENV+="RUSTFS_KMS_CONFIG_SECRET=${{ inputs.config_secret }}"$'\n'
|
||||||
|
fi
|
||||||
|
if [ -n "${EXTRA_ENV}" ]; then
|
||||||
|
ARGS+=(--extra-env "${EXTRA_ENV}")
|
||||||
|
fi
|
||||||
|
if [ -n "${PACKAGE_URL}" ]; then
|
||||||
|
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||||
|
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||||
|
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
|
fi
|
||||||
|
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Generate report
|
||||||
|
if: ${{ always() && 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}" ]; then
|
||||||
|
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||||
|
else
|
||||||
|
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
|
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 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: ${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: kms
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
|
# 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: '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() && steps.evidence.outcome == 'success' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-kms-test-${{ 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 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: "Continue functional chain (next: Tier)"
|
||||||
|
# Only chain-triggered runs forward to the next suite; standalone
|
||||||
|
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||||
|
# handoff must never pass silently: it retries, then files an alert
|
||||||
|
# issue in rustfs/backlog so a stalled chain is visible.
|
||||||
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DISPATCHED=0
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
|
-f event_type='rustfs-chain-tier' \
|
||||||
|
-F 'client_payload[from_suite]=kms'; then
|
||||||
|
echo "dispatched next suite Tier (attempt ${attempt})"
|
||||||
|
DISPATCHED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||||
|
sleep "${attempt}0"
|
||||||
|
done
|
||||||
|
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||||
|
echo "ERROR: functional chain stalled: could not dispatch Tier after 3 attempts" >&2
|
||||||
|
TITLE="[functional][chain] stalled after kms (run ${GITHUB_RUN_ID})"
|
||||||
|
BODY_FILE="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "The functional chain could not hand off from **kms** to **Tier** after 3 attempts."
|
||||||
|
echo ""
|
||||||
|
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
|
echo "- Expected next event: 'rustfs-chain-tier'"
|
||||||
|
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||||
|
echo "- Recovery: re-dispatch manually with"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-tier'"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
} > "${BODY_FILE}"
|
||||||
|
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||||
|
--body-file "${BODY_FILE}" --label functional-test \
|
||||||
|
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||||
|
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "RustFS KMS suite failed"
|
||||||
|
echo "See the uploaded report and log artifacts for details."
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
name: RustFS Performance Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
package_url:
|
||||||
|
description: 'Direct .deb URL (nightly/R2). Defaults to the latest nightly deb.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
test_method:
|
||||||
|
description: 'Benchmark method(s) to run (manual runs only; "all" = GET+PUT+MIXED)'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- get
|
||||||
|
- put
|
||||||
|
- mixed
|
||||||
|
default: 'all'
|
||||||
|
object_size:
|
||||||
|
description: 'Object size(s) to test (manual runs only; "all" = all 10 sizes)'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- 1KiB
|
||||||
|
- 4KiB
|
||||||
|
- 16KiB
|
||||||
|
- 128KiB
|
||||||
|
- 1MiB
|
||||||
|
- 4MiB
|
||||||
|
- 8MiB
|
||||||
|
- 16MiB
|
||||||
|
- 32MiB
|
||||||
|
- 64MiB
|
||||||
|
default: 'all'
|
||||||
|
warp_duration:
|
||||||
|
description: 'warp duration per round (e.g. 5m, 30s)'
|
||||||
|
required: false
|
||||||
|
default: '5m'
|
||||||
|
warp_concurrency:
|
||||||
|
description: 'warp concurrency'
|
||||||
|
required: false
|
||||||
|
default: '64'
|
||||||
|
cleanup_before:
|
||||||
|
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
cleanup_after:
|
||||||
|
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
repository_dispatch:
|
||||||
|
# Chain handoff: dispatched when the replication suite finishes.
|
||||||
|
types: [rustfs-chain-performance]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# 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-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 }}
|
||||||
|
# Performance test uses its own node list (4 nodes); the shared
|
||||||
|
# RUSTFS_NODES secret is used by the 3-node pool-expansion / heal tests.
|
||||||
|
RUSTFS_NODES: ${{ secrets.RUSTFS_PERF_NODES || vars.RUSTFS_PERF_NODES || 'vm000 vm001 vm002 vm003' }}
|
||||||
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
# 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' }}
|
||||||
|
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
|
||||||
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
performance-test:
|
||||||
|
runs-on: pf-testing
|
||||||
|
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_name == 'repository_dispatch' }}
|
||||||
|
steps:
|
||||||
|
- name: Initialize functional evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-performance-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||||
|
{
|
||||||
|
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'RUSTFS_RESULT_DIR=%s/results\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
printf 'VERSION_FILE=%s/version.txt\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||||
|
} >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
|
# 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
|
||||||
|
warp --version || true
|
||||||
|
df -h /data | tail -1
|
||||||
|
|
||||||
|
- name: Reset test environment (before)
|
||||||
|
if: ${{ inputs.cleanup_before != 'false' }}
|
||||||
|
run: |
|
||||||
|
chmod +x auto-testing/rustfs_performance_test.sh
|
||||||
|
./auto-testing/rustfs_performance_test.sh --step 1 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
|
- name: Install RustFS package & start cluster (4x4)
|
||||||
|
run: |
|
||||||
|
ARGS=(--steps "2,3,4" -y)
|
||||||
|
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_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
|
- name: Preflight checks
|
||||||
|
run: |
|
||||||
|
ARGS=(--preflight)
|
||||||
|
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_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||||
|
|
||||||
|
- name: Run benchmark (GET/PUT/MIXED)
|
||||||
|
id: benchmark
|
||||||
|
run: |
|
||||||
|
# Empty on automatic (workflow_run) runs -> full 30 rounds.
|
||||||
|
# Manual dispatch can restrict method(s)/size(s).
|
||||||
|
export WARP_METHODS="${{ inputs.test_method }}"
|
||||||
|
export WARP_SIZES="${{ inputs.object_size }}"
|
||||||
|
./auto-testing/rustfs_performance_test.sh \
|
||||||
|
--step 5 -y \
|
||||||
|
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
||||||
|
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
||||||
|
--log-file "${LOG_FILE}"
|
||||||
|
|
||||||
|
- name: Analyze results
|
||||||
|
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||||
|
run: |
|
||||||
|
./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' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES}"
|
||||||
|
[ "${#NODES[@]}" -gt 0 ] || { echo "RUSTFS_NODES is empty"; exit 1; }
|
||||||
|
NODE="${NODES[0]}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
{
|
||||||
|
echo "Node: ${NODE}"
|
||||||
|
echo "Command: rustfs --version"
|
||||||
|
echo ""
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||||
|
"${SSH_USER}@${NODE}" 'rustfs --version'
|
||||||
|
} > "${VERSION_FILE}"
|
||||||
|
|
||||||
|
- name: Upload report to dashboard (reports/YYYY-MM-DD.md)
|
||||||
|
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
|
RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; skipping report upload"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
SUMMARY="${RESULT_DIR}/summary.md"
|
||||||
|
[ -s "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
||||||
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
REPORT_PATH="reports/${DATE}.md"
|
||||||
|
{
|
||||||
|
echo "# RustFS nightly build performance testing report"
|
||||||
|
echo ""
|
||||||
|
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 ""
|
||||||
|
cat "${SUMMARY}"
|
||||||
|
echo ""
|
||||||
|
echo "## RustFS version"
|
||||||
|
echo '```text'
|
||||||
|
cat "${VERSION_FILE}"
|
||||||
|
echo '```'
|
||||||
|
} > "${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}" \
|
||||||
|
'{message:$msg, content:$content, sha:$sha}' \
|
||||||
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
|
echo "updated ${REPORT_PATH} in rustfs/dashboard"
|
||||||
|
else
|
||||||
|
jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" \
|
||||||
|
'{message:$msg, content:$content}' \
|
||||||
|
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||||
|
echo "created ${REPORT_PATH} in rustfs/dashboard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: File failure issue in rustfs/backlog
|
||||||
|
if: ${{ always() && (failure() || steps.benchmark.outcome == 'failure' || steps.benchmark.outcome == 'cancelled') }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
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() && steps.evidence.outcome == 'success' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-perf-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: |
|
||||||
|
${{ 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 --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "RustFS performance test failed"
|
||||||
|
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||||
|
echo "See the uploaded log artifact for details."
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
name: RustFS Pool Expansion / Decommission Test
|
name: RustFS Pool Expansion Test
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
rustfs_version:
|
rustfs_version:
|
||||||
description: 'RustFS release tag to test (e.g. 1.0.0-rc.3)'
|
description: 'RustFS release tag to test (leave empty to use the latest nightly deb)'
|
||||||
required: false
|
required: false
|
||||||
default: '1.0.0-rc.3'
|
|
||||||
package_url:
|
package_url:
|
||||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||||
required: false
|
required: false
|
||||||
@@ -26,22 +25,14 @@ on:
|
|||||||
description: 'warp write duration (e.g. 5m, 10m)'
|
description: 'warp write duration (e.g. 5m, 10m)'
|
||||||
required: false
|
required: false
|
||||||
default: '10m'
|
default: '10m'
|
||||||
|
warp_concurrent:
|
||||||
|
description: 'Pool fill: concurrent warp operations'
|
||||||
|
required: false
|
||||||
|
default: '32'
|
||||||
run_decommission:
|
run_decommission:
|
||||||
description: 'Run the pool decommission step (3-pool topology only)'
|
description: 'Run the pool decommission step (3-pool topology only)'
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
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'
|
|
||||||
heal_target_gb:
|
|
||||||
description: 'Heal: outage node must reach N GiB after heal'
|
|
||||||
required: false
|
|
||||||
default: '40'
|
|
||||||
cleanup_before:
|
cleanup_before:
|
||||||
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -50,18 +41,18 @@ on:
|
|||||||
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
workflow_run:
|
repository_dispatch:
|
||||||
# Run after the nightly build completes: pool expansion first, then heal.
|
# Chain handoff: dispatched when the heal suite finishes.
|
||||||
workflows: ["Nightly GNU Build"]
|
types: [rustfs-chain-pool]
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
# Only one pool-expansion test at a time: the workflow mutates a shared
|
# Only one test run at a time: the job mutates the same shared test
|
||||||
# test environment, so concurrent runs must not clobber each other.
|
# environment (vm000/vm001/vm002), so concurrent runs must not clobber each
|
||||||
|
# other.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: rustfs-pool-expansion-test
|
group: rustfs-shared-functional-tests
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|
||||||
defaults:
|
defaults:
|
||||||
@@ -74,23 +65,52 @@ env:
|
|||||||
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
RUSTFS_API_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
# Package used by the nightly run (workflow_dispatch inputs are empty for
|
# Package used by the nightly run (workflow_dispatch inputs are empty for
|
||||||
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
||||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# 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:
|
pool-expansion-test:
|
||||||
|
name: Pool expansion / decommission test
|
||||||
runs-on: smoke-testing
|
runs-on: smoke-testing
|
||||||
timeout-minutes: 360
|
timeout-minutes: 360
|
||||||
# Run on manual dispatch, or when the nightly build completed successfully
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
# (its deb is what the tests install). Skipped when nightly failed.
|
env:
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
|
RUSTFS_POOL_ADMIN_ENDPOINT: ${{ secrets.RUSTFS_POOL_ADMIN_ENDPOINT || vars.RUSTFS_POOL_ADMIN_ENDPOINT || 'http://rustfs-node1:9000' }}
|
||||||
|
RUSTFS_POOL_PROXY_ENDPOINT: http://127.0.0.1:19000
|
||||||
|
RUSTFS_POOL_WARP_ENDPOINT: http://127.0.0.1:19000
|
||||||
|
RUSTFS_SHARED_PROXY_ENDPOINT: ${{ secrets.RUSTFS_API_ENDPOINT || vars.RUSTFS_API_ENDPOINT || vars.RUSTFS_RC_ENDPOINT }}
|
||||||
|
RUSTFS_POOL_NODE_ENDPOINTS: ${{ secrets.RUSTFS_POOL_NODE_ENDPOINTS || vars.RUSTFS_POOL_NODE_ENDPOINTS || 'http://rustfs-node1:9000 http://rustfs-node2:9000 http://rustfs-node3:9000' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
with:
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
persist-credentials: false
|
env:
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf auto-testing
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||||
|
echo "auto-testing cloned (attempt ${attempt})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -rf auto-testing
|
||||||
|
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||||
|
sleep $((attempt * 15))
|
||||||
|
done
|
||||||
|
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Initialize pool test artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ARTIFACT_DIR="${RUNNER_TEMP}/rustfs-pool-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -p "${ARTIFACT_DIR}"
|
||||||
|
echo "POOL_ARTIFACT_DIR=${ARTIFACT_DIR}" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
- name: Show environment
|
- name: Show environment
|
||||||
run: |
|
run: |
|
||||||
@@ -100,15 +120,32 @@ jobs:
|
|||||||
warp --version || true
|
warp --version || true
|
||||||
df -h /data | tail -1
|
df -h /data | tail -1
|
||||||
|
|
||||||
- name: Reset test environment (before)
|
- name: Cleanup environment (before)
|
||||||
if: ${{ inputs.cleanup_before != 'false' }}
|
if: ${{ inputs.cleanup_before != 'false' }}
|
||||||
run: |
|
run: |
|
||||||
chmod +x scripts/test/rustfs_pool_expand.sh
|
set -euo pipefail
|
||||||
./scripts/test/rustfs_pool_expand.sh --reset -y
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
- name: Install RustFS package & start first pool
|
- name: Install RustFS package & start cluster
|
||||||
run: |
|
run: |
|
||||||
ARGS=(--steps "1,2,3" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
ARGS=(--steps "1,2,3" -y \
|
||||||
|
--admin-endpoint "${RUSTFS_POOL_ADMIN_ENDPOINT}" \
|
||||||
|
--warp-endpoint "${RUSTFS_POOL_WARP_ENDPOINT}" \
|
||||||
|
--node-endpoints "${RUSTFS_POOL_NODE_ENDPOINTS}" \
|
||||||
|
--log-file "${POOL_ARTIFACT_DIR}/pool-test.log")
|
||||||
if [ -n "${{ inputs.package_url }}" ]; then
|
if [ -n "${{ inputs.package_url }}" ]; then
|
||||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||||
@@ -116,11 +153,15 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
|
./auto-testing/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||||
|
|
||||||
- name: Preflight checks
|
- name: Preflight checks
|
||||||
run: |
|
run: |
|
||||||
ARGS=(--preflight --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
ARGS=(--preflight \
|
||||||
|
--admin-endpoint "${RUSTFS_POOL_ADMIN_ENDPOINT}" \
|
||||||
|
--warp-endpoint "${RUSTFS_POOL_WARP_ENDPOINT}" \
|
||||||
|
--node-endpoints "${RUSTFS_POOL_NODE_ENDPOINTS}" \
|
||||||
|
--log-file "${POOL_ARTIFACT_DIR}/pool-test.log")
|
||||||
if [ -n "${{ inputs.package_url }}" ]; then
|
if [ -n "${{ inputs.package_url }}" ]; then
|
||||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||||
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||||
@@ -128,7 +169,61 @@ jobs:
|
|||||||
else
|
else
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
fi
|
fi
|
||||||
./scripts/test/rustfs_pool_expand.sh "${ARGS[@]}"
|
./auto-testing/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Reset dedicated pool proxy
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUSTFS_POOL_NGINX_CONFIG_PATH=/etc/nginx/conf.d/rustfs-pool-test.conf \
|
||||||
|
RUSTFS_POOL_NGINX_LISTEN="${RUSTFS_POOL_PROXY_ENDPOINT#http://}" \
|
||||||
|
RUSTFS_POOL_NGINX_ACCESS_LOG=/var/log/nginx/rustfs-pool-test-access.log \
|
||||||
|
RUSTFS_POOL_NGINX_ERROR_LOG=/var/log/nginx/rustfs-pool-test-error.log \
|
||||||
|
./auto-testing/rustfs_pool_nginx_stage.sh cleanup
|
||||||
|
|
||||||
|
- name: Capture pool test baseline
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
BASELINE_FILE="${POOL_ARTIFACT_DIR}/pool-baseline.log"
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
read -r -a DIRECT_ENDPOINTS <<< "${RUSTFS_POOL_NODE_ENDPOINTS}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
failed=0
|
||||||
|
: > "${BASELINE_FILE}"
|
||||||
|
|
||||||
|
if [ "${#DIRECT_ENDPOINTS[@]}" -lt "${#NODES[@]}" ]; then
|
||||||
|
echo "not enough direct endpoints for the configured nodes" | tee -a "${BASELINE_FILE}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for index in "${!NODES[@]}"; do
|
||||||
|
node="${NODES[$index]}"
|
||||||
|
endpoint="${DIRECT_ENDPOINTS[$index]}"
|
||||||
|
body_file="${POOL_ARTIFACT_DIR}/ready-baseline-$((index + 1)).body"
|
||||||
|
{
|
||||||
|
echo "--- node=${node} endpoint=${endpoint} ---"
|
||||||
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||||
|
"${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
echo "--- rustfs version ---"
|
||||||
|
rustfs --version
|
||||||
|
echo "--- systemd state ---"
|
||||||
|
${SUDO} systemctl show rustfs --no-pager \
|
||||||
|
--property=ActiveState,SubState,Result,ExecMainPID,ExecMainStartTimestamp,NRestarts
|
||||||
|
'; then
|
||||||
|
echo "baseline collection failed for ${node}"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
curl -sS --connect-timeout 5 --max-time 15 -o "${body_file}" \
|
||||||
|
-w "baseline_ready=${endpoint} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
|
||||||
|
"${endpoint%/}/health/ready" || true
|
||||||
|
echo "--- readiness body ---"
|
||||||
|
cat "${body_file}" 2>/dev/null || true
|
||||||
|
echo
|
||||||
|
} >> "${BASELINE_FILE}" 2>&1
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "${failed}" -eq 0 ] || exit 1
|
||||||
|
|
||||||
- name: Run pool expansion & decommission test
|
- name: Run pool expansion & decommission test
|
||||||
id: pool_test
|
id: pool_test
|
||||||
@@ -141,27 +236,470 @@ jobs:
|
|||||||
STEPS="$STEPS,9"
|
STEPS="$STEPS,9"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
./scripts/test/rustfs_pool_expand.sh \
|
ARGS=(--steps "$STEPS" --with-warp -y \
|
||||||
--steps "$STEPS" --with-warp -y \
|
--admin-endpoint "${RUSTFS_POOL_ADMIN_ENDPOINT}" \
|
||||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
--warp-endpoint "${RUSTFS_POOL_WARP_ENDPOINT}" \
|
||||||
|
--node-endpoints "${RUSTFS_POOL_NODE_ENDPOINTS}" \
|
||||||
--storage-threshold "${{ inputs.storage_threshold || '50' }}" \
|
--storage-threshold "${{ inputs.storage_threshold || '50' }}" \
|
||||||
--warp-duration "${{ inputs.warp_duration || '10m' }}" \
|
--warp-duration "${{ inputs.warp_duration || '10m' }}" \
|
||||||
--log-file /tmp/rustfs-pool-test.log
|
--warp-concurrent "${{ inputs.warp_concurrent || '32' }}" \
|
||||||
|
--log-file "${POOL_ARTIFACT_DIR}/pool-test.log")
|
||||||
|
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
|
||||||
|
ARGS+=(--proxy-endpoint "${RUSTFS_POOL_PROXY_ENDPOINT}")
|
||||||
|
fi
|
||||||
|
if [ -n "${{ inputs.package_url }}" ]; then
|
||||||
|
ARGS+=(--package-url "${{ inputs.package_url }}")
|
||||||
|
elif [ -n "${{ inputs.rustfs_version }}" ]; then
|
||||||
|
ARGS+=(--version "${{ inputs.rustfs_version }}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||||
|
fi
|
||||||
|
RUSTFS_WARP_LOG_FILE="${POOL_ARTIFACT_DIR}/warp.log" \
|
||||||
|
RUSTFS_PROXY_STAGE_HOOK=./auto-testing/rustfs_pool_nginx_stage.sh \
|
||||||
|
RUSTFS_POOL_NGINX_CONFIG_PATH=/etc/nginx/conf.d/rustfs-pool-test.conf \
|
||||||
|
RUSTFS_POOL_NGINX_LISTEN="${RUSTFS_POOL_PROXY_ENDPOINT#http://}" \
|
||||||
|
RUSTFS_POOL_NGINX_ACCESS_LOG=/var/log/nginx/rustfs-pool-test-access.log \
|
||||||
|
RUSTFS_POOL_NGINX_ERROR_LOG=/var/log/nginx/rustfs-pool-test-error.log \
|
||||||
|
./auto-testing/rustfs_pool_expand.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Collect pool test diagnostics
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
ARTIFACT_DIR="${POOL_ARTIFACT_DIR:-${RUNNER_TEMP}/rustfs-pool-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}}"
|
||||||
|
mkdir -p "${ARTIFACT_DIR}"
|
||||||
|
echo "POOL_ARTIFACT_DIR=${ARTIFACT_DIR}" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
redact() {
|
||||||
|
sed -E \
|
||||||
|
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)=).*/\1[REDACTED]/Ig' \
|
||||||
|
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||||
|
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||||
|
-e 's/(proxy_set_header[[:space:]]+Authorization[[:space:]]+).*/\1[REDACTED];/Ig' \
|
||||||
|
-e 's/^.*(password|secret|token).*/[REDACTED SENSITIVE LINE]/Ig'
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
|
SUDO=()
|
||||||
|
else
|
||||||
|
SUDO=(sudo -n)
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "captured_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
echo "run_id=${GITHUB_RUN_ID}"
|
||||||
|
echo "run_attempt=${GITHUB_RUN_ATTEMPT}"
|
||||||
|
if command -v nginx >/dev/null 2>&1; then
|
||||||
|
"${SUDO[@]}" nginx -T 2>&1 || echo "nginx -T failed"
|
||||||
|
else
|
||||||
|
echo "nginx is not installed on the runner"
|
||||||
|
fi
|
||||||
|
} | redact > "${ARTIFACT_DIR}/nginx-config-redacted.txt"
|
||||||
|
|
||||||
|
for log_path in \
|
||||||
|
/var/log/nginx/access.log \
|
||||||
|
/var/log/nginx/error.log \
|
||||||
|
/var/log/nginx/rustfs-pool-test-access.log \
|
||||||
|
/var/log/nginx/rustfs-pool-test-error.log; do
|
||||||
|
log_name="$(basename "${log_path}")"
|
||||||
|
if "${SUDO[@]}" test -r "${log_path}" 2>/dev/null; then
|
||||||
|
"${SUDO[@]}" cat "${log_path}" 2>&1 | redact \
|
||||||
|
> "${ARTIFACT_DIR}/nginx-${log_name%.log}-redacted.log"
|
||||||
|
else
|
||||||
|
echo "unavailable: ${log_path}" > "${ARTIFACT_DIR}/nginx-${log_name%.log}-redacted.log"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
"${SUDO[@]}" journalctl -u nginx --no-pager -n 5000 2>&1 | redact \
|
||||||
|
> "${ARTIFACT_DIR}/nginx-journal-redacted.log" || true
|
||||||
|
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
safe_node="${node//[^A-Za-z0-9_.-]/_}"
|
||||||
|
{
|
||||||
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||||
|
"${SSH_USER}@${node}" '
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
echo "--- rustfs version ---"
|
||||||
|
rustfs --version 2>&1 || true
|
||||||
|
echo "--- systemd state ---"
|
||||||
|
${SUDO} systemctl show rustfs --no-pager \
|
||||||
|
--property=ActiveState,SubState,Result,ExecMainPID,ExecMainStartTimestamp,NRestarts 2>&1 || true
|
||||||
|
echo "--- rustfs journal ---"
|
||||||
|
${SUDO} journalctl -u rustfs --no-pager -n 10000 2>&1 || true
|
||||||
|
echo "--- rustfs file logs ---"
|
||||||
|
if ${SUDO} test -d /var/log/rustfs; then
|
||||||
|
${SUDO} find /var/log/rustfs -maxdepth 2 -type f -print 2>/dev/null | while IFS= read -r file; do
|
||||||
|
echo "--- ${file} (last 5000 lines) ---"
|
||||||
|
${SUDO} tail -n 5000 "${file}" 2>&1 || true
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo "/var/log/rustfs is unavailable"
|
||||||
|
fi
|
||||||
|
'; then
|
||||||
|
echo "SSH diagnostics failed for ${node}"
|
||||||
|
fi
|
||||||
|
} 2>&1 | redact > "${ARTIFACT_DIR}/${safe_node}-rustfs-redacted.log"
|
||||||
|
done
|
||||||
|
|
||||||
|
: > "${ARTIFACT_DIR}/endpoint-ready-probes.log"
|
||||||
|
read -r -a DIRECT_ENDPOINTS <<< "${RUSTFS_POOL_NODE_ENDPOINTS}"
|
||||||
|
probe_index=0
|
||||||
|
for endpoint in "${DIRECT_ENDPOINTS[@]}"; do
|
||||||
|
probe_index=$((probe_index + 1))
|
||||||
|
curl -sS --connect-timeout 5 --max-time 15 -o "${ARTIFACT_DIR}/ready-direct-${probe_index}.body" \
|
||||||
|
-w "direct[${probe_index}]=${endpoint} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
|
||||||
|
"${endpoint%/}/health/ready" >> "${ARTIFACT_DIR}/endpoint-ready-probes.log" 2>&1 || true
|
||||||
|
done
|
||||||
|
if [ -n "${RUSTFS_POOL_PROXY_ENDPOINT}" ]; then
|
||||||
|
curl -sS --connect-timeout 5 --max-time 15 -o "${ARTIFACT_DIR}/ready-proxy.body" \
|
||||||
|
-w "proxy=${RUSTFS_POOL_PROXY_ENDPOINT} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
|
||||||
|
"${RUSTFS_POOL_PROXY_ENDPOINT%/}/health/ready" >> "${ARTIFACT_DIR}/endpoint-ready-probes.log" 2>&1 || true
|
||||||
|
fi
|
||||||
|
if [ -n "${RUSTFS_SHARED_PROXY_ENDPOINT}" ]; then
|
||||||
|
curl -sS --connect-timeout 5 --max-time 15 -o "${ARTIFACT_DIR}/ready-shared-proxy.body" \
|
||||||
|
-w "shared_proxy=${RUSTFS_SHARED_PROXY_ENDPOINT} http=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
|
||||||
|
"${RUSTFS_SHARED_PROXY_ENDPOINT%/}/health/ready" >> "${ARTIFACT_DIR}/endpoint-ready-probes.log" 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Generate report
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
LOG_FILE="${POOL_ARTIFACT_DIR}/pool-test.log"
|
||||||
|
REPORT_FILE="${POOL_ARTIFACT_DIR}/pool-report.md"
|
||||||
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
|
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||||
|
if [ -n "${PACKAGE_URL}" ]; then
|
||||||
|
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||||
|
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||||
|
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||||
|
else
|
||||||
|
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
|
fi
|
||||||
|
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 ""
|
||||||
|
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||||
|
echo "- Trigger: ${{ github.event_name }}"
|
||||||
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
|
echo "- Warp concurrent: ${{ inputs.warp_concurrent || '32' }}"
|
||||||
|
echo "- Test Step Outcome: ${{ steps.pool_test.outcome }}"
|
||||||
|
echo ""
|
||||||
|
cat "${STEPS_TABLE}" || true
|
||||||
|
echo ""
|
||||||
|
echo "## Log tail"
|
||||||
|
echo '```text'
|
||||||
|
tail -n 200 "${LOG_FILE}" || true
|
||||||
|
echo '```'
|
||||||
|
} | tee "${REPORT_FILE}"
|
||||||
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
|
||||||
|
- name: Validate pool diagnostic completeness
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
failed=0
|
||||||
|
require_nonempty() {
|
||||||
|
if [ ! -s "$1" ]; then
|
||||||
|
echo "required diagnostic is missing or empty: $1" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
require_available() {
|
||||||
|
if [ ! -e "$1" ]; then
|
||||||
|
echo "required diagnostic is missing: $1" >&2
|
||||||
|
failed=1
|
||||||
|
elif grep -Fq 'unavailable:' "$1" 2>/dev/null; then
|
||||||
|
echo "required diagnostic could not be collected: $1" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_nonempty "${POOL_ARTIFACT_DIR}/pool-test.log"
|
||||||
|
require_nonempty "${POOL_ARTIFACT_DIR}/warp.log"
|
||||||
|
require_nonempty "${POOL_ARTIFACT_DIR}/pool-report.md"
|
||||||
|
require_nonempty "${POOL_ARTIFACT_DIR}/pool-baseline.log"
|
||||||
|
require_nonempty "${POOL_ARTIFACT_DIR}/nginx-config-redacted.txt"
|
||||||
|
require_nonempty "${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-access-redacted.log"
|
||||||
|
require_available "${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-access-redacted.log"
|
||||||
|
require_available "${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-error-redacted.log"
|
||||||
|
require_nonempty "${POOL_ARTIFACT_DIR}/endpoint-ready-probes.log"
|
||||||
|
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
if grep -Fq 'baseline collection failed' "${POOL_ARTIFACT_DIR}/pool-baseline.log" 2>/dev/null; then
|
||||||
|
echo "one or more node baselines could not be collected" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
safe_node="${node//[^A-Za-z0-9_.-]/_}"
|
||||||
|
node_log="${POOL_ARTIFACT_DIR}/${safe_node}-rustfs-redacted.log"
|
||||||
|
require_nonempty "${node_log}"
|
||||||
|
if grep -Fq "SSH diagnostics failed for ${node}" "${node_log}" 2>/dev/null; then
|
||||||
|
echo "node diagnostics failed: ${node_log}" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
if ! grep -Eq '^rustfs @' "${node_log}" 2>/dev/null \
|
||||||
|
|| ! grep -Eq '^NRestarts=[0-9]+$' "${node_log}" 2>/dev/null; then
|
||||||
|
echo "node version or restart evidence is incomplete: ${node_log}" >&2
|
||||||
|
failed=1
|
||||||
|
elif grep -Eq '^NRestarts=[1-9][0-9]*$' "${node_log}"; then
|
||||||
|
echo "RustFS restarted unexpectedly during the run: ${node_log}" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! grep -Fq "upstream_status=\"\$upstream_status\"" \
|
||||||
|
"${POOL_ARTIFACT_DIR}/nginx-config-redacted.txt"; then
|
||||||
|
echo "Nginx config does not expose upstream status fields" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
if ! grep -Eq '^proxy=.* http=200([[:space:]]|$)' "${POOL_ARTIFACT_DIR}/endpoint-ready-probes.log"; then
|
||||||
|
echo "dedicated proxy readiness probe did not return HTTP 200" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
if grep -Eq 'status=50(2|4)|upstream_status="[^"]*50(2|4)' \
|
||||||
|
"${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-access-redacted.log"; then
|
||||||
|
echo "dedicated proxy access log contains a 502/504 response" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
if grep -Eiq 'upstream prematurely closed connection|upstream timed out|(connect\(\)|recv\(\)|send\(\)) failed.*upstream|connection reset by peer.*upstream' \
|
||||||
|
"${POOL_ARTIFACT_DIR}/nginx-rustfs-pool-test-error-redacted.log"; then
|
||||||
|
echo "dedicated proxy error log contains an upstream timeout or connection failure" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
[ "${failed}" -eq 0 ] || exit 1
|
||||||
|
|
||||||
|
- name: Upload functional report to dashboard
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
|
SUITE: pool
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
REPORT_FILE="${POOL_ARTIFACT_DIR}/pool-report.md"
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
|
# 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.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
|
- name: Upload test logs
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-pool-test-${{ github.run_id }}
|
name: rustfs-pool-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
path: |
|
path: ${{ runner.temp }}/rustfs-pool-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
/tmp/rustfs-pool-test*.log
|
|
||||||
/tmp/rustfs-warp.*.log
|
|
||||||
if-no-files-found: warn
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: Reset test environment (after)
|
- name: Restore dedicated pool proxy
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RUSTFS_POOL_NGINX_CONFIG_PATH=/etc/nginx/conf.d/rustfs-pool-test.conf \
|
||||||
|
RUSTFS_POOL_NGINX_LISTEN="${RUSTFS_POOL_PROXY_ENDPOINT#http://}" \
|
||||||
|
RUSTFS_POOL_NGINX_ACCESS_LOG=/var/log/nginx/rustfs-pool-test-access.log \
|
||||||
|
RUSTFS_POOL_NGINX_ERROR_LOG=/var/log/nginx/rustfs-pool-test-error.log \
|
||||||
|
./auto-testing/rustfs_pool_nginx_stage.sh cleanup
|
||||||
|
|
||||||
|
- name: Cleanup environment (after)
|
||||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||||
run: |
|
run: |
|
||||||
./scripts/test/rustfs_pool_expand.sh --reset -y
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: "Continue functional chain (next: Security)"
|
||||||
|
# Only chain-triggered runs forward to the next suite; standalone
|
||||||
|
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||||
|
# handoff must never pass silently: it retries, then files an alert
|
||||||
|
# issue in rustfs/backlog so a stalled chain is visible.
|
||||||
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DISPATCHED=0
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
|
-f event_type='rustfs-chain-security' \
|
||||||
|
-F 'client_payload[from_suite]=pool'; then
|
||||||
|
echo "dispatched next suite Security (attempt ${attempt})"
|
||||||
|
DISPATCHED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||||
|
sleep "${attempt}0"
|
||||||
|
done
|
||||||
|
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||||
|
echo "ERROR: functional chain stalled: could not dispatch Security after 3 attempts" >&2
|
||||||
|
TITLE="[functional][chain] stalled after pool (run ${GITHUB_RUN_ID})"
|
||||||
|
BODY_FILE="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "The functional chain could not hand off from **pool** to **Security** after 3 attempts."
|
||||||
|
echo ""
|
||||||
|
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
|
echo "- Expected next event: 'rustfs-chain-security'"
|
||||||
|
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||||
|
echo "- Recovery: re-dispatch manually with"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-security'"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
} > "${BODY_FILE}"
|
||||||
|
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||||
|
--body-file "${BODY_FILE}" --label functional-test \
|
||||||
|
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||||
|
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -169,75 +707,3 @@ jobs:
|
|||||||
echo "RustFS pool expansion test failed"
|
echo "RustFS pool expansion test failed"
|
||||||
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||||
echo "See the uploaded log artifact for details."
|
echo "See the uploaded log artifact for details."
|
||||||
|
|
||||||
# Heal regression runs after the pool test regardless of its outcome: a pool
|
|
||||||
# failure must be reported (it makes the run red) but must not block heal.
|
|
||||||
heal-test:
|
|
||||||
name: Heal test (after pool test)
|
|
||||||
runs-on: smoke-testing
|
|
||||||
timeout-minutes: 480
|
|
||||||
needs: pool-expansion-test
|
|
||||||
if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.ref }}
|
|
||||||
|
|
||||||
- name: Reset test environment (before)
|
|
||||||
if: ${{ inputs.cleanup_before != 'false' }}
|
|
||||||
run: |
|
|
||||||
chmod +x scripts/test/rustfs_heal_test.sh
|
|
||||||
./scripts/test/rustfs_heal_test.sh --reset -y
|
|
||||||
|
|
||||||
- name: Install RustFS package & start cluster
|
|
||||||
run: |
|
|
||||||
ARGS=(--steps "1,2" -y --endpoint "${{ env.RUSTFS_API_ENDPOINT }}")
|
|
||||||
if [ -n "${{ inputs.package_url }}" ]; then
|
|
||||||
ARGS+=(--package-url "${{ inputs.package_url }}")
|
|
||||||
else
|
|
||||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
|
||||||
fi
|
|
||||||
./scripts/test/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
|
|
||||||
./scripts/test/rustfs_heal_test.sh "${ARGS[@]}"
|
|
||||||
|
|
||||||
- name: Run heal test (write -> outage -> heal -> verify)
|
|
||||||
run: |
|
|
||||||
./scripts/test/rustfs_heal_test.sh \
|
|
||||||
--steps 3,4,5,6,7 -y \
|
|
||||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
|
||||||
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
|
||||||
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
|
||||||
--heal-target-gb "${{ inputs.heal_target_gb || '40' }}" \
|
|
||||||
--log-file /tmp/rustfs-heal-test.log
|
|
||||||
|
|
||||||
- name: Upload test logs
|
|
||||||
if: always()
|
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
|
||||||
with:
|
|
||||||
name: rustfs-heal-test-${{ github.run_id }}
|
|
||||||
path: |
|
|
||||||
/tmp/rustfs-heal-test.log
|
|
||||||
/tmp/rustfs-warp.*.log
|
|
||||||
if-no-files-found: warn
|
|
||||||
|
|
||||||
- name: Reset test environment (after)
|
|
||||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
|
||||||
run: |
|
|
||||||
./scripts/test/rustfs_heal_test.sh --reset -y
|
|
||||||
|
|
||||||
- name: Notify on failure
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
echo "RustFS heal test failed"
|
|
||||||
echo "See the uploaded log artifact for details."
|
|
||||||
|
|||||||
@@ -0,0 +1,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."
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
name: RustFS S3 Compatibility Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
rustfs_version:
|
||||||
|
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||||
|
required: false
|
||||||
|
default: '1.0.0-rc.4-preview.1'
|
||||||
|
package_url:
|
||||||
|
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
repository_dispatch:
|
||||||
|
# Chain handoff: dispatched when the upgrade suite finishes.
|
||||||
|
types: [rustfs-chain-s3]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: rustfs-shared-functional-tests
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
|
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||||
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
s3-compat-test:
|
||||||
|
runs-on: smoke-testing
|
||||||
|
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-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: |
|
||||||
|
uname -a
|
||||||
|
jq --version
|
||||||
|
openssl version
|
||||||
|
df -h /data | tail -1
|
||||||
|
|
||||||
|
- name: Cleanup environment (before)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Run S3 compatibility suite
|
||||||
|
id: test
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
chmod +x auto-testing/rustfs-s3-compat-test.sh
|
||||||
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
|
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||||
|
ARGS=(--all-topologies -y --log-file "${LOG_FILE}")
|
||||||
|
if [ -n "${PACKAGE_URL}" ]; then
|
||||||
|
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||||
|
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||||
|
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
|
fi
|
||||||
|
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Generate report
|
||||||
|
if: ${{ always() && 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}" ]; 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 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: ${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: s3
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
|
# 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: '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() && steps.evidence.outcome == 'success' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-s3-compat-${{ 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 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: "Continue functional chain (next: KMS)"
|
||||||
|
# Only chain-triggered runs forward to the next suite; standalone
|
||||||
|
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||||
|
# handoff must never pass silently: it retries, then files an alert
|
||||||
|
# issue in rustfs/backlog so a stalled chain is visible.
|
||||||
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DISPATCHED=0
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
|
-f event_type='rustfs-chain-kms' \
|
||||||
|
-F 'client_payload[from_suite]=s3'; then
|
||||||
|
echo "dispatched next suite KMS (attempt ${attempt})"
|
||||||
|
DISPATCHED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||||
|
sleep "${attempt}0"
|
||||||
|
done
|
||||||
|
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||||
|
echo "ERROR: functional chain stalled: could not dispatch KMS after 3 attempts" >&2
|
||||||
|
TITLE="[functional][chain] stalled after s3 (run ${GITHUB_RUN_ID})"
|
||||||
|
BODY_FILE="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "The functional chain could not hand off from **s3** to **KMS** after 3 attempts."
|
||||||
|
echo ""
|
||||||
|
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
|
echo "- Expected next event: 'rustfs-chain-kms'"
|
||||||
|
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||||
|
echo "- Recovery: re-dispatch manually with"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-kms'"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
} > "${BODY_FILE}"
|
||||||
|
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||||
|
--body-file "${BODY_FILE}" --label functional-test \
|
||||||
|
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||||
|
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "RustFS S3 compatibility suite failed"
|
||||||
|
echo "See the uploaded report and log artifacts for details."
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
name: RustFS Security Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
rustfs_version:
|
||||||
|
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||||
|
required: false
|
||||||
|
default: '1.0.0-rc.4-preview.1'
|
||||||
|
package_url:
|
||||||
|
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
topology:
|
||||||
|
description: 'Topology to run (all = SNSD, SNMD, MNMD)'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- single-single
|
||||||
|
- single-multi
|
||||||
|
- multi-multi
|
||||||
|
default: all
|
||||||
|
oidc_live:
|
||||||
|
description: 'Run the live Keycloak OIDC/SSO gate as part of the suite'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
cleanup_before:
|
||||||
|
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
cleanup_after:
|
||||||
|
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
repository_dispatch:
|
||||||
|
# Chain handoff: dispatched when the pool expansion suite finishes.
|
||||||
|
types: [rustfs-chain-security]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# The security suite uses the same shared VMs as the other functional tests,
|
||||||
|
# so it must serialize with them instead of running in parallel.
|
||||||
|
concurrency:
|
||||||
|
group: rustfs-shared-functional-tests
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
|
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||||
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security-test:
|
||||||
|
runs-on: smoke-testing
|
||||||
|
timeout-minutes: 360
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
|
steps:
|
||||||
|
# Checkout the repository into its own subdirectory. Checking out at
|
||||||
|
# the workspace root would wipe the auto-testing clone above (that is
|
||||||
|
# exactly how run 33934141181 lost rustfs-security-test.sh).
|
||||||
|
- name: Checkout repository (for the OIDC live gate script)
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
path: rustfs-repo
|
||||||
|
|
||||||
|
- name: Initialize security evidence
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||||
|
mkdir -- "${SECURITY_ARTIFACTS_DIR}" "${SECURITY_ARTIFACTS_DIR}-scratch"
|
||||||
|
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
|
||||||
|
|
||||||
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf auto-testing
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||||
|
echo "auto-testing cloned (attempt ${attempt})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -rf auto-testing
|
||||||
|
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||||
|
sleep $((attempt * 15))
|
||||||
|
done
|
||||||
|
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: Show environment
|
||||||
|
run: |
|
||||||
|
uname -a
|
||||||
|
jq --version
|
||||||
|
openssl version
|
||||||
|
aws --version || true
|
||||||
|
docker --version || true
|
||||||
|
df -h /data | tail -1
|
||||||
|
|
||||||
|
- name: Cleanup environment (before)
|
||||||
|
if: ${{ inputs.cleanup_before != 'false' || github.event_name != 'workflow_dispatch' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Run security suite
|
||||||
|
id: test
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
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
|
||||||
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
|
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||||
|
TOPOLOGY='${{ inputs.topology }}'
|
||||||
|
ARGS=(-y)
|
||||||
|
if [ "${TOPOLOGY}" = "all" ] || [ -z "${TOPOLOGY}" ] || [ "${TOPOLOGY}" = "null" ]; then
|
||||||
|
ARGS+=(--all-topologies)
|
||||||
|
else
|
||||||
|
ARGS+=(--topology "${TOPOLOGY}")
|
||||||
|
fi
|
||||||
|
if [ "${{ inputs.oidc_live }}" = "true" ] || [ "${{ github.event_name }}" != "workflow_dispatch" ]; then
|
||||||
|
ARGS+=(--oidc-live)
|
||||||
|
fi
|
||||||
|
if [ -n "${PACKAGE_URL}" ]; then
|
||||||
|
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||||
|
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
|
||||||
|
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
|
fi
|
||||||
|
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}" 2>&1 | tee "${SECURITY_ARTIFACTS_DIR}/suite.log"
|
||||||
|
|
||||||
|
- name: Generate report
|
||||||
|
id: report
|
||||||
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
|
env:
|
||||||
|
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RESULT=failure
|
||||||
|
if [ "${TEST_OUTCOME}" = "success" ] && [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
|
||||||
|
RESULT=success
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
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() && steps.evidence.outcome == 'success' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
|
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||||
|
SUITE: security
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
|
# 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 }}
|
||||||
|
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() && steps.evidence.outcome == 'success' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: |
|
||||||
|
${{ 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)
|
||||||
|
if: ${{ always() && (inputs.cleanup_after != 'false' || github.event_name != 'workflow_dispatch') }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: "Continue functional chain (next: Replication)"
|
||||||
|
# Only chain-triggered runs forward to the next suite; standalone
|
||||||
|
# workflow_dispatch runs stop after their own cleanup.
|
||||||
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Dispatching next functional suite: Replication"
|
||||||
|
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
|
-f event_type='rustfs-chain-replication' \
|
||||||
|
-F 'client_payload[from_suite]=security'
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "RustFS security test failed"
|
||||||
|
echo "Package source: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||||
|
echo "See the uploaded report and logs for details."
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
name: RustFS Storage Engine Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
rustfs_version:
|
||||||
|
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||||
|
required: false
|
||||||
|
default: '1.0.0-rc.4-preview.1'
|
||||||
|
package_url:
|
||||||
|
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
topology:
|
||||||
|
description: 'Topology to run (all = SNSD, SNMD, MNMD)'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- single-single
|
||||||
|
- single-multi
|
||||||
|
- multi-multi
|
||||||
|
default: all
|
||||||
|
repository_dispatch:
|
||||||
|
# Chain handoff: dispatched when the tier suite finishes.
|
||||||
|
types: [rustfs-chain-storage]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: rustfs-shared-functional-tests
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
|
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||||
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
storage-test:
|
||||||
|
runs-on: smoke-testing
|
||||||
|
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-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: |
|
||||||
|
uname -a
|
||||||
|
jq --version
|
||||||
|
openssl version
|
||||||
|
df -h /data | tail -1
|
||||||
|
|
||||||
|
- name: Cleanup environment (before)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Run storage engine suite
|
||||||
|
id: test
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
chmod +x auto-testing/rustfs-storage-test.sh
|
||||||
|
PACKAGE_URL='${{ inputs.package_url }}'
|
||||||
|
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||||
|
TOPOLOGY='${{ inputs.topology }}'
|
||||||
|
ARGS=(-y --log-file "${LOG_FILE}")
|
||||||
|
if [ "${TOPOLOGY}" = "all" ] || [ -z "${TOPOLOGY}" ] || [ "${TOPOLOGY}" = "null" ]; then
|
||||||
|
ARGS+=(--all-topologies)
|
||||||
|
else
|
||||||
|
ARGS+=(--topology "${TOPOLOGY}")
|
||||||
|
fi
|
||||||
|
if [ -n "${PACKAGE_URL}" ]; then
|
||||||
|
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||||
|
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||||
|
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
|
fi
|
||||||
|
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Generate report
|
||||||
|
if: ${{ always() && 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}" ]; 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 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: ${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: storage
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
|
# 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: '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() && steps.evidence.outcome == 'success' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-storage-${{ 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 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: "Continue functional chain (next: Heal)"
|
||||||
|
# Only chain-triggered runs forward to the next suite; standalone
|
||||||
|
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||||
|
# handoff must never pass silently: it retries, then files an alert
|
||||||
|
# issue in rustfs/backlog so a stalled chain is visible.
|
||||||
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DISPATCHED=0
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
|
-f event_type='rustfs-chain-heal' \
|
||||||
|
-F 'client_payload[from_suite]=storage'; then
|
||||||
|
echo "dispatched next suite Heal (attempt ${attempt})"
|
||||||
|
DISPATCHED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||||
|
sleep "${attempt}0"
|
||||||
|
done
|
||||||
|
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||||
|
echo "ERROR: functional chain stalled: could not dispatch Heal after 3 attempts" >&2
|
||||||
|
TITLE="[functional][chain] stalled after storage (run ${GITHUB_RUN_ID})"
|
||||||
|
BODY_FILE="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "The functional chain could not hand off from **storage** to **Heal** after 3 attempts."
|
||||||
|
echo ""
|
||||||
|
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
|
echo "- Expected next event: 'rustfs-chain-heal'"
|
||||||
|
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||||
|
echo "- Recovery: re-dispatch manually with"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-heal'"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
} > "${BODY_FILE}"
|
||||||
|
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||||
|
--body-file "${BODY_FILE}" --label functional-test \
|
||||||
|
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||||
|
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "RustFS storage engine suite failed"
|
||||||
|
echo "See the uploaded report and log artifacts for details."
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
name: RustFS Tier Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
rustfs_version:
|
||||||
|
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||||
|
required: false
|
||||||
|
default: '1.0.0-rc.4-preview.1'
|
||||||
|
package_url:
|
||||||
|
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
rc_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
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: rustfs-shared-functional-tests
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
|
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||||
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
RUSTFS_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
|
||||||
|
timeout-minutes: 420
|
||||||
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
|
steps:
|
||||||
|
- name: Initialize run evidence directory
|
||||||
|
id: evidence
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
umask 077
|
||||||
|
if ! mkdir -- "${TIER_ARTIFACTS_DIR}"; then
|
||||||
|
echo "refusing to reuse tier evidence path: ${TIER_ARTIFACTS_DIR}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
test -d "${TIER_ARTIFACTS_DIR}"
|
||||||
|
test ! -L "${TIER_ARTIFACTS_DIR}"
|
||||||
|
|
||||||
|
# auto-testing is private: clone it with the dedicated PF token (not
|
||||||
|
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||||
|
- name: Checkout auto-testing scripts (with retry)
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf auto-testing
|
||||||
|
for attempt in 1 2 3 4 5; do
|
||||||
|
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||||
|
echo "auto-testing cloned (attempt ${attempt})"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
rm -rf auto-testing
|
||||||
|
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||||
|
sleep $((attempt * 15))
|
||||||
|
done
|
||||||
|
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- name: 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: |
|
||||||
|
uname -a
|
||||||
|
jq --version
|
||||||
|
openssl version
|
||||||
|
df -h /data | tail -1
|
||||||
|
|
||||||
|
- name: Cleanup environment (before)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
|
||||||
|
sudo rm -f /tmp/rustfs-mosquitto.conf
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Ensure MQTT broker + clients
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if ! command -v mosquitto_sub >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y mosquitto-clients
|
||||||
|
fi
|
||||||
|
command -v docker >/dev/null 2>&1 || { echo 'docker not found on runner'; exit 1; }
|
||||||
|
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
|
||||||
|
cat <<'EOF' | sudo tee /tmp/rustfs-mosquitto.conf >/dev/null
|
||||||
|
listener 1883 0.0.0.0
|
||||||
|
allow_anonymous true
|
||||||
|
EOF
|
||||||
|
sudo docker run -d --name rustfs-test-mqtt -p 1883:1883 \
|
||||||
|
-v /tmp/rustfs-mosquitto.conf:/mosquitto/config/mosquitto.conf:ro \
|
||||||
|
eclipse-mosquitto:2 >/dev/null
|
||||||
|
for _ in {1..10}; do
|
||||||
|
if ss -tln 2>/dev/null | grep -q ':1883'; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
ss -tln 2>/dev/null | grep -q ':1883' || {
|
||||||
|
echo 'mosquitto container is not listening on 1883'
|
||||||
|
sudo docker logs rustfs-test-mqtt || true
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: Run tier suite
|
||||||
|
id: test
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
|
||||||
|
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="${PACKAGE_URL_INPUT}"
|
||||||
|
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
|
||||||
|
ARGS=(
|
||||||
|
--all-topologies
|
||||||
|
-y
|
||||||
|
--log-file "${LOG_FILE}"
|
||||||
|
--rc-bin "${RC_BIN}"
|
||||||
|
--artifacts-dir "${TIER_ARTIFACTS_DIR}"
|
||||||
|
)
|
||||||
|
if [ -n "${RUSTFS_EXPECTED_RC_SHA256}" ]; then
|
||||||
|
ARGS+=(--expected-rc-sha256 "${RUSTFS_EXPECTED_RC_SHA256}")
|
||||||
|
fi
|
||||||
|
if [ -n "${PACKAGE_URL}" ]; then
|
||||||
|
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||||
|
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||||
|
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||||
|
else
|
||||||
|
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
|
fi
|
||||||
|
./auto-testing/rustfs-tier-test.sh "${ARGS[@]}"
|
||||||
|
|
||||||
|
- name: Inject diagnostic case failure
|
||||||
|
if: ${{ always() && steps.evidence.outcome == 'success' && inputs.force_case_failure }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
RESULT_FILE="${TIER_ARTIFACTS_DIR}/cases/single-single--TIER-101.json"
|
||||||
|
test -s "${RESULT_FILE}"
|
||||||
|
TMP_FILE="$(mktemp "${TIER_ARTIFACTS_DIR}/cases/.forced.XXXXXX")"
|
||||||
|
jq '.status = "FAIL" | .case_rc = 97' "${RESULT_FILE}" > "${TMP_FILE}"
|
||||||
|
mv "${TMP_FILE}" "${RESULT_FILE}"
|
||||||
|
|
||||||
|
- name: Generate report
|
||||||
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
|
env:
|
||||||
|
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
|
||||||
|
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
|
||||||
|
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
TRIGGER_NAME: ${{ github.event_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -d "${TIER_ARTIFACTS_DIR}"
|
||||||
|
test ! -L "${TIER_ARTIFACTS_DIR}"
|
||||||
|
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
|
||||||
|
REPORT_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier-report.md"
|
||||||
|
CASE_TABLE="${TIER_ARTIFACTS_DIR}/rustfs-tier-cases.md"
|
||||||
|
GATE_RC_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier-gate.rc"
|
||||||
|
PACKAGE_URL="${PACKAGE_URL_INPUT}"
|
||||||
|
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
|
||||||
|
if [ -n "${PACKAGE_URL}" ]; then
|
||||||
|
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||||
|
elif [ -n "${RUSTFS_VERSION}" ]; then
|
||||||
|
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||||
|
else
|
||||||
|
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
|
fi
|
||||||
|
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: ${RUN_URL}"
|
||||||
|
echo "- Trigger: ${TRIGGER_NAME}"
|
||||||
|
echo "- Package: ${PACKAGE_SOURCE}"
|
||||||
|
echo "- Client bootstrap: ${RC_BOOTSTRAP_SUMMARY}"
|
||||||
|
echo "- Test Step Outcome: ${TEST_OUTCOME}"
|
||||||
|
echo "- Structured Gate Exit: ${CASE_GATE_RC}"
|
||||||
|
echo ""
|
||||||
|
cat "${CASE_TABLE}"
|
||||||
|
echo ""
|
||||||
|
echo "## Log tail"
|
||||||
|
echo '```text'
|
||||||
|
tail -n 200 "${LOG_FILE}" || true
|
||||||
|
echo '```'
|
||||||
|
} | tee "${REPORT_FILE}"
|
||||||
|
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||||
|
|
||||||
|
- name: Upload functional report to dashboard
|
||||||
|
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
|
REPORT_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-report.md
|
||||||
|
SUITE: tier
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
|
# 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: 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() && steps.evidence.outcome == 'success' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-tier-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||||
|
path: ${{ env.TIER_ARTIFACTS_DIR }}/
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
- name: Cleanup environment (after)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
|
||||||
|
sudo rm -f /tmp/rustfs-mosquitto.conf
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: 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: |
|
||||||
|
echo "RustFS tier suite failed"
|
||||||
|
echo "See the uploaded report and log artifacts for details."
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
# Copyright 2024 RustFS Team
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
name: RustFS Upgrade Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
from_version:
|
||||||
|
description: 'OLD RustFS release tag, e.g. 1.0.0-rc.3 (its release must ship a .deb asset). Leave empty for the default.'
|
||||||
|
required: false
|
||||||
|
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, 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.'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
topology:
|
||||||
|
description: 'Topology to run (all = SNSD, SNMD, MNMD)'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- all
|
||||||
|
- single-single
|
||||||
|
- single-multi
|
||||||
|
- multi-multi
|
||||||
|
default: all
|
||||||
|
backends:
|
||||||
|
description: 'KMS backends to run (local,vault-kv2)'
|
||||||
|
required: false
|
||||||
|
default: 'local,vault-kv2'
|
||||||
|
cleanup_before:
|
||||||
|
description: 'Reset the nodes before the test (DESTROYS existing data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
cleanup_after:
|
||||||
|
description: 'Reset the nodes after the test (DESTROYS test data/config)'
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
repository_dispatch:
|
||||||
|
# Functional-chain entry: dispatched by rustfs-functional-chain.yml.
|
||||||
|
types: [rustfs-chain-upgrade]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: rustfs-shared-functional-tests
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
env:
|
||||||
|
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||||
|
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||||
|
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||||
|
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||||
|
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||||
|
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upgrade-test:
|
||||||
|
runs-on: smoke-testing
|
||||||
|
timeout-minutes: 420
|
||||||
|
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-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: |
|
||||||
|
uname -a
|
||||||
|
jq --version
|
||||||
|
openssl version
|
||||||
|
aws --version || true
|
||||||
|
docker --version || true
|
||||||
|
df -h /data | tail -1
|
||||||
|
|
||||||
|
- name: Cleanup environment (before)
|
||||||
|
if: ${{ inputs.cleanup_before != 'false' || github.event_name != 'workflow_dispatch' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Ensure docker (Vault container)
|
||||||
|
run: |
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y docker.io
|
||||||
|
fi
|
||||||
|
sudo systemctl enable --now docker
|
||||||
|
docker info >/dev/null 2>&1 || sudo docker info >/dev/null 2>&1
|
||||||
|
|
||||||
|
- name: Run upgrade compatibility suite
|
||||||
|
id: test
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
chmod +x auto-testing/rustfs-upgrade-test.sh
|
||||||
|
FROM_URL='${{ inputs.from_url }}'
|
||||||
|
FROM_VERSION='${{ inputs.from_version }}'
|
||||||
|
TO_URL='${{ inputs.to_url }}'
|
||||||
|
TO_VERSION='${{ inputs.to_version }}'
|
||||||
|
TOPOLOGY='${{ inputs.topology }}'
|
||||||
|
BACKENDS='${{ inputs.backends }}'
|
||||||
|
ARGS=(-y --log-file "${LOG_FILE}")
|
||||||
|
if [ "${TOPOLOGY}" = "all" ] || [ -z "${TOPOLOGY}" ] || [ "${TOPOLOGY}" = "null" ]; then
|
||||||
|
ARGS+=(--all-topologies)
|
||||||
|
else
|
||||||
|
ARGS+=(--topology "${TOPOLOGY}")
|
||||||
|
fi
|
||||||
|
if [ -n "${BACKENDS}" ] && [ "${BACKENDS}" != "null" ]; then
|
||||||
|
ARGS+=(--backends "${BACKENDS}")
|
||||||
|
fi
|
||||||
|
if [ -n "${FROM_URL}" ]; then
|
||||||
|
ARGS+=(--from-url "${FROM_URL}")
|
||||||
|
elif [ -n "${FROM_VERSION}" ] && [ "${FROM_VERSION}" != "null" ]; then
|
||||||
|
ARGS+=(--from-version "${FROM_VERSION}")
|
||||||
|
fi
|
||||||
|
if [ -n "${TO_URL}" ]; then
|
||||||
|
ARGS+=(--to-url "${TO_URL}")
|
||||||
|
elif [ -n "${TO_VERSION}" ] && [ "${TO_VERSION}" != "null" ]; then
|
||||||
|
ARGS+=(--to-version "${TO_VERSION}")
|
||||||
|
else
|
||||||
|
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||||
|
fi
|
||||||
|
# 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() && steps.evidence.outcome == 'success' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
FROM_URL='${{ inputs.from_url }}'
|
||||||
|
FROM_VERSION='${{ inputs.from_version }}'
|
||||||
|
TO_URL='${{ inputs.to_url }}'
|
||||||
|
TO_VERSION='${{ inputs.to_version }}'
|
||||||
|
if [ -n "${FROM_URL}" ]; then
|
||||||
|
FROM_SOURCE="${FROM_URL}"
|
||||||
|
elif [ -n "${FROM_VERSION}" ]; then
|
||||||
|
FROM_SOURCE="version ${FROM_VERSION}"
|
||||||
|
else
|
||||||
|
FROM_SOURCE="release (default)"
|
||||||
|
fi
|
||||||
|
if [ -n "${TO_URL}" ]; then
|
||||||
|
TO_SOURCE="${TO_URL}"
|
||||||
|
elif [ -n "${TO_VERSION}" ]; then
|
||||||
|
TO_SOURCE="version ${TO_VERSION}"
|
||||||
|
else
|
||||||
|
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||||
|
fi
|
||||||
|
CASE_TABLE="${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: ${RESULT}"
|
||||||
|
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||||
|
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() && steps.evidence.outcome == 'success' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||||
|
SUITE: upgrade
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
DATE="$(date -u +%Y-%m-%d)"
|
||||||
|
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||||
|
# 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: '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() && steps.evidence.outcome == 'success' }}
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: rustfs-upgrade-test-${{ 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
|
||||||
|
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/matrix.md
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 3
|
||||||
|
|
||||||
|
- name: Cleanup environment (after)
|
||||||
|
if: ${{ always() && (inputs.cleanup_after != 'false' || github.event_name != 'workflow_dispatch') }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||||
|
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||||
|
for node in "${NODES[@]}"; do
|
||||||
|
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||||
|
set -euo pipefail
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||||
|
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||||
|
${SUDO} dpkg -P rustfs
|
||||||
|
fi
|
||||||
|
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||||
|
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms /var/lib/rustfs/kms-backup
|
||||||
|
'
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: "Continue functional chain (next: S3 compatibility)"
|
||||||
|
# Only chain-triggered runs forward to the next suite; standalone
|
||||||
|
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||||
|
# handoff must never pass silently: it retries, then files an alert
|
||||||
|
# issue in rustfs/backlog so a stalled chain is visible.
|
||||||
|
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -z "${GH_TOKEN:-}" ]; then
|
||||||
|
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
DISPATCHED=0
|
||||||
|
for attempt in 1 2 3; do
|
||||||
|
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||||
|
-f event_type='rustfs-chain-s3' \
|
||||||
|
-F 'client_payload[from_suite]=upgrade'; then
|
||||||
|
echo "dispatched next suite S3 compatibility (attempt ${attempt})"
|
||||||
|
DISPATCHED=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||||
|
sleep "${attempt}0"
|
||||||
|
done
|
||||||
|
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||||
|
echo "ERROR: functional chain stalled: could not dispatch S3 compatibility after 3 attempts" >&2
|
||||||
|
TITLE="[functional][chain] stalled after upgrade (run ${GITHUB_RUN_ID})"
|
||||||
|
BODY_FILE="$(mktemp)"
|
||||||
|
{
|
||||||
|
echo "The functional chain could not hand off from **upgrade** to **S3 compatibility** after 3 attempts."
|
||||||
|
echo ""
|
||||||
|
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||||
|
echo "- Expected next event: 'rustfs-chain-s3'"
|
||||||
|
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||||
|
echo "- Recovery: re-dispatch manually with"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-s3'"
|
||||||
|
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||||
|
} > "${BODY_FILE}"
|
||||||
|
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||||
|
--body-file "${BODY_FILE}" --label functional-test \
|
||||||
|
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||||
|
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Notify on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "RustFS upgrade compatibility test failed"
|
||||||
|
echo "From: ${{ inputs.from_url || inputs.from_version || 'release (default)' }}"
|
||||||
|
echo "To: ${{ inputs.to_url || inputs.to_version || 'nightly (R2 latest)' }}"
|
||||||
|
echo "See the uploaded report and logs for details."
|
||||||
@@ -42,6 +42,7 @@ jobs:
|
|||||||
- name: Check latest scheduled runs
|
- name: Check latest scheduled runs
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
RUSTFS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||||
run: |
|
run: |
|
||||||
set +e
|
set +e
|
||||||
python3 scripts/check_scheduled_validation_freshness.py \
|
python3 scripts/check_scheduled_validation_freshness.py \
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ on:
|
|||||||
- "Continuous Integration"
|
- "Continuous Integration"
|
||||||
- "coverage"
|
- "coverage"
|
||||||
- "e2e-nightly"
|
- "e2e-nightly"
|
||||||
|
- "e2e-distributed"
|
||||||
- "e2e-s3tests"
|
- "e2e-s3tests"
|
||||||
- "Fuzz"
|
- "Fuzz"
|
||||||
- "mint"
|
- "mint"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
|
- uses: overtrue/repo-visuals-action@fd79cba437ecfac933d00a69add17eb95d3939c3 # v1.3.1
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
output-branch: star-history
|
output-branch: star-history
|
||||||
|
|||||||
+5
-3
@@ -33,6 +33,7 @@ profile.json
|
|||||||
*.zst
|
*.zst
|
||||||
.secrets
|
.secrets
|
||||||
*.go
|
*.go
|
||||||
|
!crates/zip/tests/fixtures/snowball/**/generate/*.go
|
||||||
*.pb
|
*.pb
|
||||||
*.svg
|
*.svg
|
||||||
deploy/logs/*.log.*
|
deploy/logs/*.log.*
|
||||||
@@ -55,11 +56,10 @@ docs/*
|
|||||||
!docs/architecture/**
|
!docs/architecture/**
|
||||||
!docs/operations/
|
!docs/operations/
|
||||||
!docs/operations/**
|
!docs/operations/**
|
||||||
|
!docs/postmortems/
|
||||||
|
!docs/postmortems/**
|
||||||
!docs/testing/
|
!docs/testing/
|
||||||
!docs/testing/**
|
!docs/testing/**
|
||||||
docs/heal-scanner-logging-governance.md
|
|
||||||
docs/benchmark/rustfs-target-bench/
|
|
||||||
docs/benchmark/*.md
|
|
||||||
.codegraph/*
|
.codegraph/*
|
||||||
.docker/test/compat/data/*
|
.docker/test/compat/data/*
|
||||||
.docker/test/compat/kms/*
|
.docker/test/compat/kms/*
|
||||||
@@ -83,6 +83,8 @@ worktrees/*
|
|||||||
|
|
||||||
# Local AI-agent review artifacts (omo evidence dumps)
|
# Local AI-agent review artifacts (omo evidence dumps)
|
||||||
.omo/
|
.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
|
# insta scratch files; the accepted .snap files ARE the assertions and are committed
|
||||||
*.snap.new
|
*.snap.new
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
- id: rustfs-dev-check
|
- id: rustfs-fmt-check
|
||||||
name: rustfs dev-check
|
name: Rust formatting
|
||||||
entry: make dev-check
|
entry: cargo fmt --all --check
|
||||||
language: system
|
language: system
|
||||||
types: [rust]
|
types: [rust]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|||||||
@@ -31,8 +31,13 @@ This file contains repository-wide rules. Use the nearest subdirectory
|
|||||||
- An existing clean, isolated task worktree is sufficient. Create another
|
- An existing clean, isolated task worktree is sufficient. Create another
|
||||||
worktree only when the current checkout is shared, dirty with unrelated work,
|
worktree only when the current checkout is shared, dirty with unrelated work,
|
||||||
or belongs to another task.
|
or belongs to another task.
|
||||||
- Never commit from a shared checkout. Use an `overtrue/` feature branch unless
|
- Never commit from a shared checkout.
|
||||||
the user requests another name.
|
- Use a task-specific branch named `<type>/<topic>`, such as `fix/...`,
|
||||||
|
`feat/...`, `test/...`, or `docs/...`, unless the user specifies a name.
|
||||||
|
- Do not include agent, tool, contributor, account, or organization names in
|
||||||
|
branch names.
|
||||||
|
- Push to the user-requested remote or the repository's configured push remote.
|
||||||
|
Do not hard-code or infer a remote from an account name.
|
||||||
- Check free space before artifact-heavy builds, tests, coverage, or downloads.
|
- Check free space before artifact-heavy builds, tests, coverage, or downloads.
|
||||||
Re-check before a broad gate when space is tight.
|
Re-check before a broad gate when space is tight.
|
||||||
- Remove only task-owned temporary/build artifacts. Never delete another task's
|
- Remove only task-owned temporary/build artifacts. Never delete another task's
|
||||||
@@ -81,6 +86,7 @@ This file contains repository-wide rules. Use the nearest subdirectory
|
|||||||
- CI gates: `.github/workflows/ci.yml`.
|
- CI gates: `.github/workflows/ci.yml`.
|
||||||
- PR format: `.github/pull_request_template.md`.
|
- PR format: `.github/pull_request_template.md`.
|
||||||
- Architecture routing: `ARCHITECTURE.md` and `docs/architecture/README.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`.
|
- Agent skills: `.agents/skills/*/SKILL.md`.
|
||||||
|
|
||||||
Do not commit one-shot plans, trackers, migration ledgers, benchmark snapshots,
|
Do not commit one-shot plans, trackers, migration ledgers, benchmark snapshots,
|
||||||
@@ -118,12 +124,13 @@ runtime/build output:
|
|||||||
- Use `make pre-commit` only when its repository-wide fast checks add confidence
|
- Use `make pre-commit` only when its repository-wide fast checks add confidence
|
||||||
beyond the focused checks.
|
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
|
Do not run `make pre-pr` by default before opening a PR. Consider it only when
|
||||||
cannot bound the impact, including dependency/toolchain/build-matrix changes,
|
the final diff is broad, spans multiple modules, and targeted checks cannot
|
||||||
unbounded cross-crate APIs, or locking, durability, erasure coding, replication,
|
bound the impact. Decide dynamically from the affected boundaries and risks;
|
||||||
RPC, IAM/KMS/auth, cryptography, on-disk/on-wire, and S3-visible behavior.
|
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
|
`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.
|
diff. Do not repeat a check already covered by a successful umbrella gate.
|
||||||
@@ -155,6 +162,12 @@ Risk and review shape:
|
|||||||
S3-visible semantics. Cover all applicable lenses using exactly two
|
S3-visible semantics. Cover all applicable lenses using exactly two
|
||||||
independent reviewers when delegation is explicitly authorized. Split the
|
independent reviewers when delegation is explicitly authorized. Split the
|
||||||
lenses between them. Otherwise perform two fresh sequential passes.
|
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,
|
Available domain lenses are security, concurrency/durability, compatibility,
|
||||||
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
||||||
|
|||||||
+3
-3
@@ -62,7 +62,7 @@ rustfs/ # Workspace root (virtual manifest)
|
|||||||
│ ├── utils/ # Pure utility functions
|
│ ├── utils/ # Pure utility functions
|
||||||
│ ├── ... # (see "Crate Reference" below)
|
│ ├── ... # (see "Crate Reference" below)
|
||||||
│ └── e2e_test/ # End-to-end integration tests
|
│ └── 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/`)
|
### Main Crate Layers (`rustfs/src/`)
|
||||||
@@ -92,7 +92,7 @@ refactors.
|
|||||||
|
|
||||||
| Domain | Current workspace crates | Responsibility |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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
|
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
|
||||||
collision, not copies; renaming is tracked in rustfs/backlog#1847.
|
collision, not copies; renaming is tracked in rustfs/backlog#1847.
|
||||||
- `LastMinuteLatency` has two deliberately different implementations: the
|
- `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
|
the in-memory endpoint-health sample tracker in
|
||||||
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
|
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
|
||||||
why it stays local).
|
why it stays local).
|
||||||
|
|||||||
@@ -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.
|
- **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
|
### 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.
|
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
|
||||||
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
|
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
|
||||||
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
|
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ cargo check -p <crate> # fast type-check one crate
|
|||||||
cargo test -p <crate> # test one crate
|
cargo test -p <crate> # test one crate
|
||||||
cargo fmt --all # format (required before PR)
|
cargo fmt --all # format (required before PR)
|
||||||
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
|
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
|
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)
|
## 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`
|
- Crate membership: `Cargo.toml` `[workspace].members`
|
||||||
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
|
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||||
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
|
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
|
||||||
|
|||||||
+28
-41
@@ -62,12 +62,20 @@ make test
|
|||||||
# Fast pre-commit gate — see below for exactly what it runs
|
# Fast pre-commit gate — see below for exactly what it runs
|
||||||
make pre-commit
|
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 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`.
|
> `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 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).
|
> 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`
|
8. `quick-check` — `cargo check --workspace --exclude e2e_test`
|
||||||
|
|
||||||
**`make pre-commit` does NOT run clippy and does NOT run any tests.**
|
**`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,
|
`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`)
|
then `clippy-check` (`cargo clippy --all-targets --all-features -- -D warnings`)
|
||||||
and `test` (shell script tests, workspace tests excluding `e2e_test`, and doc
|
and `test` (shell script tests, workspace tests excluding `e2e_test`, and doc
|
||||||
tests). Complete the applicable multi-role adversarial review described in
|
tests). Complete the applicable multi-role adversarial review described in
|
||||||
`AGENTS.md` before running `make pre-pr`; then run the gate before opening or
|
`AGENTS.md` first. Do not run `make pre-pr` locally by default before opening or
|
||||||
updating a pull request. This is what CI enforces.
|
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 Pre-commit Hooks (optional)
|
||||||
|
|
||||||
Git hooks are **not** versioned in this repository, so a fresh clone has no
|
The optional hook uses the checked-in `.pre-commit-config.yaml`. Install [pre-commit](https://pre-commit.com/#installation), then run this from the checkout or a linked worktree:
|
||||||
active pre-commit hook. If you add your own `.git/hooks/pre-commit` (a good
|
|
||||||
choice is a one-liner that runs `make pre-commit`), you can mark it executable
|
|
||||||
with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make setup-hooks
|
make setup-hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
Or manually:
|
The hook runs `cargo fmt --all --check` when staged files include Rust source. It does not compile the workspace or run tests. Fix formatting with `cargo fmt --all`, inspect and stage the result, then commit again.
|
||||||
|
|
||||||
```bash
|
`pre-commit install` resolves Git's hook directory for linked worktrees and preserves an existing hook in migration mode. If you use `core.hooksPath`, keep that hook manager and integrate `pre-commit run` there; the installer refuses to silently replace that configuration.
|
||||||
chmod +x .git/hooks/pre-commit
|
|
||||||
```
|
|
||||||
|
|
||||||
With or without a hook, the expectation is the same: run `make pre-commit`
|
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.
|
||||||
before committing and `make pre-pr` before opening a pull request.
|
|
||||||
|
|
||||||
### 📝 Formatting Configuration
|
### 📝 Formatting Configuration
|
||||||
|
|
||||||
@@ -127,34 +131,16 @@ fn_call_width = 90
|
|||||||
single_line_let_else_max_width = 100
|
single_line_let_else_max_width = 100
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚫 Commit Prevention
|
|
||||||
|
|
||||||
If you set up a pre-commit hook and your code doesn't meet the formatting requirements, the hook will:
|
|
||||||
|
|
||||||
1. **Block the commit** and show clear error messages
|
|
||||||
2. **Provide exact commands** to fix the issues
|
|
||||||
3. **Guide you through** the resolution process
|
|
||||||
|
|
||||||
Example output when formatting fails:
|
|
||||||
|
|
||||||
```
|
|
||||||
❌ Code formatting check failed!
|
|
||||||
💡 Please run 'cargo fmt --all' to format your code before committing.
|
|
||||||
|
|
||||||
🔧 Quick fix:
|
|
||||||
cargo fmt --all
|
|
||||||
git add .
|
|
||||||
git commit
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🔄 Development Workflow
|
### 🔄 Development Workflow
|
||||||
|
|
||||||
1. **Make your changes**
|
1. **Make your changes**
|
||||||
2. **Format your code**: `make fmt` or `cargo fmt --all`
|
2. **Format your code**: `make fmt` or `cargo fmt --all`
|
||||||
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
|
3. **Select relevant checks** using the validation tier in `AGENTS.md`; use `make pre-commit` when its broader fast gate adds useful coverage
|
||||||
4. **Commit your changes**: `git commit -m "your message"`
|
4. **Commit your changes**: `git commit -m "your message"`
|
||||||
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
|
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
|
||||||
6. **Run 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`
|
7. **Push to your branch**: `git push`
|
||||||
|
|
||||||
### 🛠️ IDE Integration
|
### 🛠️ IDE Integration
|
||||||
@@ -193,11 +179,12 @@ Configure your IDE to:
|
|||||||
#### Pre-commit hook not running?
|
#### Pre-commit hook not running?
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if hook is executable
|
pre-commit validate-config
|
||||||
ls -la .git/hooks/pre-commit
|
pre-commit run --all-files
|
||||||
|
# Inspect any configured hook manager; do not overwrite it.
|
||||||
# Make it executable if needed
|
git config --get core.hooksPath
|
||||||
chmod +x .git/hooks/pre-commit
|
# Install if no separate hook manager is configured.
|
||||||
|
make setup-hooks
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Formatting issues?
|
#### Formatting issues?
|
||||||
|
|||||||
Generated
+742
-398
File diff suppressed because it is too large
Load Diff
+95
-87
@@ -29,6 +29,7 @@ members = [
|
|||||||
"crates/heal-contracts", # Heal request/response channel contracts
|
"crates/heal-contracts", # Heal request/response channel contracts
|
||||||
"crates/iam", # Identity and Access Management
|
"crates/iam", # Identity and Access Management
|
||||||
"crates/keystone", # OpenStack Keystone integration
|
"crates/keystone", # OpenStack Keystone integration
|
||||||
|
"crates/license", # License and entitlement provider contracts
|
||||||
"crates/lifecycle", # Lifecycle rule evaluation contracts
|
"crates/lifecycle", # Lifecycle rule evaluation contracts
|
||||||
"crates/kms", # Key Management Service
|
"crates/kms", # Key Management Service
|
||||||
"crates/lock", # Distributed locking implementation
|
"crates/lock", # Distributed locking implementation
|
||||||
@@ -51,7 +52,7 @@ members = [
|
|||||||
"crates/s3select-api", # S3 Select API interface
|
"crates/s3select-api", # S3 Select API interface
|
||||||
"crates/s3select-query", # S3 Select query engine
|
"crates/s3select-query", # S3 Select query engine
|
||||||
"crates/scanner", # Scanner for data integrity checks and health monitoring
|
"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/security-governance", # Security governance contracts
|
||||||
"crates/extension-schema", # Extension schema contracts
|
"crates/extension-schema", # Extension schema contracts
|
||||||
"crates/signer", # client signer
|
"crates/signer", # client signer
|
||||||
@@ -71,8 +72,8 @@ resolver = "3"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
repository = "https://github.com/rustfs/rustfs"
|
repository = "https://github.com/rustfs/rustfs"
|
||||||
rust-version = "1.97.1"
|
rust-version = "1.98.0"
|
||||||
version = "1.0.0-rc.4"
|
version = "1.0.0-rc.5"
|
||||||
homepage = "https://rustfs.com"
|
homepage = "https://rustfs.com"
|
||||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||||
@@ -89,61 +90,62 @@ redundant_clone = "warn"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# RustFS Internal Crates
|
# RustFS Internal Crates
|
||||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.4" }
|
rustfs = { path = "./rustfs", version = "1.0.0-rc.5" }
|
||||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.4" }
|
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.5" }
|
||||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.4" }
|
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.4" }
|
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.0-rc.5" }
|
||||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.4" }
|
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.5" }
|
||||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.4" }
|
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.5" }
|
||||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.4" }
|
rustfs-common = { path = "crates/common", version = "1.0.0-rc.5" }
|
||||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.4" }
|
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-rc.5" }
|
||||||
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.4" }
|
rustfs-config = { path = "./crates/config", version = "1.0.0-rc.5" }
|
||||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.4" }
|
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-rc.5" }
|
||||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.4" }
|
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-rc.5" }
|
||||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.4" }
|
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-rc.5" }
|
||||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.4" }
|
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-rc.5" }
|
||||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.4" }
|
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-rc.5" }
|
||||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.4" }
|
rustfs-iam = { path = "crates/iam", version = "1.0.0-rc.5" }
|
||||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.4" }
|
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-rc.5" }
|
||||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.4" }
|
rustfs-license = { path = "crates/license", version = "1.0.0-rc.5" }
|
||||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.4" }
|
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-rc.5" }
|
||||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.4" }
|
rustfs-kms = { path = "crates/kms", version = "1.0.0-rc.5" }
|
||||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.4" }
|
rustfs-lock = { path = "crates/lock", version = "1.0.0-rc.5" }
|
||||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.4" }
|
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-rc.5" }
|
||||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.4" }
|
rustfs-notify = { path = "crates/notify", version = "1.0.0-rc.5" }
|
||||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.4" }
|
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-rc.5" }
|
||||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.4" }
|
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-rc.5" }
|
||||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.4", default-features = false }
|
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-rc.5" }
|
||||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.4" }
|
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-rc.5", default-features = false }
|
||||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.4" }
|
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-rc.5" }
|
||||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.4" }
|
rustfs-obs = { path = "crates/obs", version = "1.0.0-rc.5" }
|
||||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.4" }
|
rustfs-policy = { path = "crates/policy", version = "1.0.0-rc.5" }
|
||||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.4" }
|
rustfs-protos = { path = "crates/protos", version = "1.0.0-rc.5" }
|
||||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.4" }
|
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-rc.5" }
|
||||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.4" }
|
rustfs-replication = { path = "crates/replication", version = "1.0.0-rc.5" }
|
||||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.4" }
|
rustfs-rio = { path = "crates/rio", version = "1.0.0-rc.5" }
|
||||||
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.4" }
|
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-rc.5" }
|
||||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.4" }
|
rustfs-s3-client = { path = "crates/s3-client", version = "1.0.0-rc.5" }
|
||||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.4" }
|
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-rc.5" }
|
||||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.4" }
|
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-rc.5" }
|
||||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.4" }
|
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-rc.5" }
|
||||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.4" }
|
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-rc.5" }
|
||||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.4" }
|
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-rc.5" }
|
||||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.4" }
|
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-rc.5" }
|
||||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.4" }
|
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-rc.5" }
|
||||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.4" }
|
rustfs-signer = { path = "crates/signer", version = "1.0.0-rc.5" }
|
||||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.4" }
|
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-rc.5" }
|
||||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.4" }
|
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-rc.5" }
|
||||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.4" }
|
rustfs-targets = { path = "crates/targets", version = "1.0.0-rc.5" }
|
||||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.4" }
|
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-rc.5" }
|
||||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.4" }
|
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-rc.5" }
|
||||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.4" }
|
rustfs-utils = { path = "crates/utils", version = "1.0.0-rc.5" }
|
||||||
|
rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
|
||||||
|
|
||||||
# Async Runtime and Networking
|
# Async Runtime and Networking
|
||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
async_zip = { default-features = false, version = "0.0.19" }
|
async_zip = { default-features = false, version = "0.0.19" }
|
||||||
mysql_async = { default-features = false, version = "0.37" }
|
mysql_async = { default-features = false, version = "0.37.1" }
|
||||||
async-compression = { version = "0.4.43" }
|
async-compression = { version = "0.4.44" }
|
||||||
async-recursion = "1.1.1"
|
async-recursion = "1.1.1"
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
async-nats = { version = "0.50.0", default-features = false }
|
async-nats = { version = "0.50.0", default-features = false }
|
||||||
@@ -155,7 +157,7 @@ futures-util = "0.3.34"
|
|||||||
pollster = "1.0.1"
|
pollster = "1.0.1"
|
||||||
pulsar = { default-features = false, version = "6.9.0" }
|
pulsar = { default-features = false, version = "6.9.0" }
|
||||||
lapin = { default-features = false, version = "4.10.0" }
|
lapin = { default-features = false, version = "4.10.0" }
|
||||||
hyper = { version = "1.11.0" }
|
hyper = { version = "1.11.1" }
|
||||||
hyper-rustls = { default-features = false, version = "0.27.9" }
|
hyper-rustls = { default-features = false, version = "0.27.9" }
|
||||||
hyper-util = { version = "0.1.20" }
|
hyper-util = { version = "0.1.20" }
|
||||||
http = "1.5.0"
|
http = "1.5.0"
|
||||||
@@ -163,10 +165,10 @@ http-body = "1.1.0"
|
|||||||
http-body-util = "0.1.5"
|
http-body-util = "0.1.5"
|
||||||
minlz = "1.2.3"
|
minlz = "1.2.3"
|
||||||
reqwest = "0.13.4"
|
reqwest = "0.13.4"
|
||||||
rustfs-kafka-async = { version = "1.2.0" }
|
rustfs-kafka-async = { version = "1.3.1" }
|
||||||
socket2 = { version = "0.6.5" }
|
socket2 = { version = "0.6.5" }
|
||||||
tokio = { version = "1.53.1" }
|
tokio = { version = "1.53.1" }
|
||||||
tokio-rustls = { default-features = false, version = "0.26.4" }
|
tokio-rustls = { default-features = false, version = "0.26.5" }
|
||||||
tokio-stream = { version = "0.1.19" }
|
tokio-stream = { version = "0.1.19" }
|
||||||
tokio-test = "0.4.5"
|
tokio-test = "0.4.5"
|
||||||
tokio-util = { version = "0.7.19" }
|
tokio-util = { version = "0.7.19" }
|
||||||
@@ -174,7 +176,7 @@ tonic = { version = "0.14.6" }
|
|||||||
tonic-prost = { version = "0.14.6" }
|
tonic-prost = { version = "0.14.6" }
|
||||||
tonic-prost-build = { version = "0.14.6" }
|
tonic-prost-build = { version = "0.14.6" }
|
||||||
tower = { version = "0.5.3" }
|
tower = { version = "0.5.3" }
|
||||||
tower-http = { version = "0.7.0" }
|
tower-http = { version = "0.7.1" }
|
||||||
|
|
||||||
# Serialization and Data Formats
|
# Serialization and Data Formats
|
||||||
apache-avro = { version = "0.22.0", features = ["snappy", "zstandard"] }
|
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
|
# matching stable releases are not available yet, while previous stable lines
|
||||||
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
|
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
|
||||||
# releases.
|
# releases.
|
||||||
aes-gcm = { version = "=0.11.1" }
|
aes-gcm = { version = "0.11.1" }
|
||||||
argon2 = { version = "=0.6.0-rc.8" }
|
argon2 = { version = "0.6.0" }
|
||||||
blake2 = "=0.11.0"
|
blake2 = "0.11.0"
|
||||||
chacha20poly1305 = { version = "=0.11.0" }
|
chacha20poly1305 = { version = "0.11.0" }
|
||||||
crc-fast = "1.10.0"
|
crc-fast = "1.10.0"
|
||||||
hmac = { version = "0.13.0" }
|
hmac = { version = "0.13.0" }
|
||||||
jsonwebtoken = { version = "11.0.0" }
|
jsonwebtoken = { version = "11.0.0" }
|
||||||
@@ -232,32 +234,37 @@ tokio-postgres-rustls = "0.14.0"
|
|||||||
# Utilities and Tools
|
# Utilities and Tools
|
||||||
anyhow = "1.0.104"
|
anyhow = "1.0.104"
|
||||||
arc-swap = "1.9.2"
|
arc-swap = "1.9.2"
|
||||||
astral-tokio-tar = "0.6.4"
|
# 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"
|
atoi = "3.1.0"
|
||||||
atomic_enum = "0.3.0"
|
atomic_enum = "0.3.0"
|
||||||
aws-config = { version = "1.11.0" }
|
aws-config = { version = "1.12.0" }
|
||||||
aws-credential-types = { version = "1.3.0" }
|
aws-credential-types = { version = "1.3.0" }
|
||||||
aws-sdk-kms = { default-features = false, version = "1.117.0" }
|
aws-sdk-kms = { default-features = false, version = "1.118.0" }
|
||||||
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
|
aws-sdk-s3 = { default-features = false, version = "1.145.0" }
|
||||||
aws-sdk-sts = { default-features = false, version = "1.113.0" }
|
aws-sdk-sts = { default-features = false, version = "1.114.0" }
|
||||||
|
aws-smithy-async = { version = "1.3.0" }
|
||||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
||||||
aws-smithy-runtime-api = { version = "1.15.0" }
|
aws-smithy-runtime-api = { version = "1.16.0" }
|
||||||
aws-smithy-types = { version = "1.6.2" }
|
aws-smithy-types = { version = "1.6.3" }
|
||||||
base64-simd = "0.8.0"
|
base64-simd = "0.8.0"
|
||||||
brotli = "8.0.4"
|
brotli = "9.0.0"
|
||||||
clap = { version = "4.6.6" }
|
clap = { version = "4.6.6" }
|
||||||
const-str = { version = "1.1.0" }
|
const-str = { version = "1.1.0" }
|
||||||
convert_case = "0.11.0"
|
convert_case = "0.12.0"
|
||||||
criterion = { version = "0.8" }
|
criterion = { version = "0.8" }
|
||||||
crossbeam-queue = "0.3.13"
|
crossbeam-queue = "0.3.14"
|
||||||
crossbeam-channel = "0.5.16"
|
crossbeam-channel = "0.5.17"
|
||||||
crossbeam-deque = "0.8.7"
|
crossbeam-deque = "0.8.8"
|
||||||
crossbeam-utils = "0.8.22"
|
crossbeam-utils = "0.8.23"
|
||||||
datafusion = { default-features = false, version = "55.0.0" }
|
datafusion = { default-features = false, version = "55.0.0" }
|
||||||
derive_builder = "0.20.2"
|
derive_builder = "0.20.2"
|
||||||
enumset = "1.1.14"
|
enumset = "1.1.14"
|
||||||
faster-hex = "0.10.0"
|
faster-hex = "0.10.0"
|
||||||
flate2 = "1.1.9"
|
flate2 = "1.1.10"
|
||||||
glob = "0.3.4"
|
glob = "0.3.4"
|
||||||
google-cloud-storage = "1.18.0"
|
google-cloud-storage = "1.18.0"
|
||||||
google-cloud-auth = "1.16.0"
|
google-cloud-auth = "1.16.0"
|
||||||
@@ -282,7 +289,7 @@ mime_guess = "2.0.5"
|
|||||||
moka = { version = "0.12.16" }
|
moka = { version = "0.12.16" }
|
||||||
netif = "0.1.6"
|
netif = "0.1.6"
|
||||||
num_cpus = { version = "1.17.0" }
|
num_cpus = { version = "1.17.0" }
|
||||||
nvml-wrapper = "0.12.1"
|
nvml-wrapper = "0.13.0"
|
||||||
parking_lot = "0.12.5"
|
parking_lot = "0.12.5"
|
||||||
path-absolutize = "4.0.1"
|
path-absolutize = "4.0.1"
|
||||||
percent-encoding = "2.3.2"
|
percent-encoding = "2.3.2"
|
||||||
@@ -295,20 +302,20 @@ pretty_assertions = "1.4.1"
|
|||||||
rand = { version = "0.10.2" }
|
rand = { version = "0.10.2" }
|
||||||
ratelimit = "2.0.0"
|
ratelimit = "2.0.0"
|
||||||
rayon = "1.12.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"
|
reed-solomon-simd = "3.1.0"
|
||||||
regex = { version = "1.13.1" }
|
regex = { version = "1.13.1" }
|
||||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||||
redis = { version = "1.6.0" }
|
redis = { version = "1.7.0" }
|
||||||
rustify = { version = "0.7", default-features = false }
|
rustify = { version = "0.7", default-features = false }
|
||||||
rustix = { version = "1.1.4" }
|
rustix = { version = "1.1.4" }
|
||||||
rust-embed = { version = "8.12.0" }
|
rust-embed = { version = "8.12.0" }
|
||||||
rustc-hash = { version = "2.1.3" }
|
rustc-hash = { version = "2.1.3" }
|
||||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "0f6f83d98b37fd9edcaa3be573db4aa8f568e088", 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"
|
serial_test = "4.0.1"
|
||||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||||
siphasher = "1.0.3"
|
siphasher = "1.0.3"
|
||||||
smallvec = { version = "1.15.2" }
|
smallvec = { version = "1.16.0" }
|
||||||
compact_str = "0.10.0"
|
compact_str = "0.10.0"
|
||||||
snap = "1.1.2"
|
snap = "1.1.2"
|
||||||
starshard = { version = "2.3.0" }
|
starshard = { version = "2.3.0" }
|
||||||
@@ -336,7 +343,7 @@ windows = { version = "0.62.2" }
|
|||||||
windows-sys = "0.61.2"
|
windows-sys = "0.61.2"
|
||||||
xxhash-rust = { version = "0.8.18" }
|
xxhash-rust = { version = "0.8.18" }
|
||||||
zip = "8.6.0"
|
zip = "8.6.0"
|
||||||
zstd = "0.13.3"
|
zstd = "0.14.0"
|
||||||
|
|
||||||
# Observability and Metrics
|
# Observability and Metrics
|
||||||
metrics = "0.24.6"
|
metrics = "0.24.6"
|
||||||
@@ -354,17 +361,18 @@ pyroscope = { version = "2.1.1" }
|
|||||||
# FTP and SFTP
|
# FTP and SFTP
|
||||||
libunftp = { version = "0.23.0" }
|
libunftp = { version = "0.23.0" }
|
||||||
unftp-core = "0.1.0"
|
unftp-core = "0.1.0"
|
||||||
suppaftp = { version = "10.0.2" }
|
suppaftp = { version = "11.0.0" }
|
||||||
rcgen = { version = "0.14.9", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
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"
|
russh-sftp = "2.4.0"
|
||||||
|
|
||||||
# WebDAV
|
# WebDAV
|
||||||
dav-server = "0.11.0"
|
dav-server = "0.11.0"
|
||||||
|
|
||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
rustfs-mimalloc = { version = "0.5.1" }
|
rustfs-mimalloc = { version = "0.5.3" }
|
||||||
hotpath = { version = "0.24.0", default-features = false }
|
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||||
|
hotpath = { version = "=0.25.0", default-features = false }
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ SHELL := $(shell which bash)
|
|||||||
.SHELLFLAGS = -eu -o pipefail -c
|
.SHELLFLAGS = -eu -o pipefail -c
|
||||||
|
|
||||||
DOCKER_CLI ?= docker
|
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
|
IMAGE_NAME ?= rustfs:v1.0.0
|
||||||
CONTAINER_NAME ?= rustfs-dev
|
CONTAINER_NAME ?= rustfs-dev
|
||||||
# Docker build configurations
|
# Docker build configurations
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://docs.rustfs.com/installation/">Getting Started</a>
|
<a href="https://docs.rustfs.com/en/installation">Getting Started</a>
|
||||||
· <a href="https://docs.rustfs.com/">Docs</a>
|
· <a href="https://docs.rustfs.com/">Docs</a>
|
||||||
· <a href="https://github.com/rustfs/rustfs/issues">Bug reports</a>
|
· <a href="https://github.com/rustfs/rustfs/issues">Bug reports</a>
|
||||||
· <a href="https://github.com/rustfs/rustfs/discussions">Discussions</a>
|
· <a href="https://github.com/rustfs/rustfs/discussions">Discussions</a>
|
||||||
@@ -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.
|
- **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.
|
- **User-Friendly**: Designed with simplicity in mind for easy deployment and management.
|
||||||
|
|
||||||
| Feature | Status | Feature | Status |
|
Status legend: ✅ Available — shipped and covered by CI gates; 🧪 Preview — shipped behind an opt-in flag or with a bounded compatibility claim.
|
||||||
| :---------------------- | :----------- | :----------------------- | :--------------- |
|
|
||||||
| **S3 Core Features** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
| Feature | Status | Feature | Status |
|
||||||
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
| :------------------------------- | :----------- | :--------------------------------- | :----------- |
|
||||||
| **Versioning** | ✅ Available | **Bucket Replication** | ✅ Available |
|
| **S3 Core Features** | ✅ Available | **Distributed Mode** | ✅ Available |
|
||||||
| **Logging** | ✅ Available | **Lifecycle Management** | 🚧 Under Testing |
|
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
||||||
| **Event Notifications** | ✅ Available | **Distributed Mode** | 🚧 Under Testing |
|
| **Versioning** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
||||||
| **K8s Helm Charts** | ✅ Available | **RustFS KMS** | 🚧 Under Testing |
|
| **Object Lock (WORM)** | ✅ Available | **Healing & Scanner** | ✅ Available |
|
||||||
| **Keystone Auth** | ✅ Available | **Multi-Tenancy** | ✅ Available |
|
| **Server-Side Encryption** | ✅ Available | **Pool Expansion / Decommission** | ✅ Available |
|
||||||
| **Swift API** | ✅ Available | **Swift Metadata Ops** | 🚧 Partial |
|
| **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
|
## RustFS vs MinIO Performance
|
||||||
|
|
||||||
@@ -115,7 +132,7 @@ chown -R 10001:10001 data logs
|
|||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||||
|
|
||||||
# Using specific version
|
# Using specific version
|
||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.5
|
||||||
```
|
```
|
||||||
|
|
||||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||||
@@ -245,6 +262,26 @@ nix build
|
|||||||
nix run
|
nix run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The flake also exports a NixOS module and the RustFS `rc` client. Add the
|
||||||
|
module to your system and provide credentials through runtime files (for
|
||||||
|
example, sops-nix or agenix) so secrets are never stored in the Nix store:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
imports = [ inputs.rustfs.nixosModules.rustfs ];
|
||||||
|
|
||||||
|
services.rustfs = {
|
||||||
|
enable = true;
|
||||||
|
accessKeyFile = "/run/secrets/rustfs-access-key";
|
||||||
|
secretKeyFile = "/run/secrets/rustfs-secret-key";
|
||||||
|
volumes = [ "/var/lib/rustfs" ];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Install the S3-compatible client with
|
||||||
|
`nix profile install github:rustfs/rustfs#rustfs-client` (the executable is named
|
||||||
|
`rc`), or use `inputs.rustfs.packages.${pkgs.system}.rustfs-client` in a system
|
||||||
|
configuration.
|
||||||
|
|
||||||
### 6\. X-CMD (Option 6)
|
### 6\. X-CMD (Option 6)
|
||||||
|
|
||||||
If you are an [x-cmd](https://www.x-cmd.com/install/rustfs) user:
|
If you are an [x-cmd](https://www.x-cmd.com/install/rustfs) user:
|
||||||
|
|||||||
+8
-2
@@ -16,7 +16,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://docs.rustfs.com/installation/">快速开始</a>
|
<a href="https://docs.rustfs.com/zh/installation">快速开始</a>
|
||||||
· <a href="https://docs.rustfs.com/">文档</a>
|
· <a href="https://docs.rustfs.com/">文档</a>
|
||||||
· <a href="https://github.com/rustfs/rustfs/issues">报告 Bug</a>
|
· <a href="https://github.com/rustfs/rustfs/issues">报告 Bug</a>
|
||||||
· <a href="https://github.com/rustfs/rustfs/discussions">社区讨论</a>
|
· <a href="https://github.com/rustfs/rustfs/discussions">社区讨论</a>
|
||||||
@@ -112,7 +112,7 @@ chown -R 10001:10001 data logs
|
|||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||||
|
|
||||||
# 使用指定版本运行
|
# 使用指定版本运行
|
||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.4
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-rc.5
|
||||||
```
|
```
|
||||||
|
|
||||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||||
@@ -191,6 +191,12 @@ nix build
|
|||||||
nix run
|
nix run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
该 Flake 同时提供 NixOS 模块和 RustFS `rc` 客户端。将
|
||||||
|
`inputs.rustfs.nixosModules.rustfs` 加入 `imports`,并通过运行时密钥文件
|
||||||
|
(例如 sops-nix 或 agenix)配置 `accessKeyFile` 与 `secretKeyFile`,避免密钥
|
||||||
|
进入 Nix store。客户端包为
|
||||||
|
`inputs.rustfs.packages.${pkgs.system}.rustfs-client`,安装后的命令名为 `rc`。
|
||||||
|
|
||||||
### 6\. X-CMD (Option 6)
|
### 6\. X-CMD (Option 6)
|
||||||
|
|
||||||
如果你是 [x-cmd](https://www.x-cmd.com/install/rustfs) 用户:
|
如果你是 [x-cmd](https://www.x-cmd.com/install/rustfs) 用户:
|
||||||
|
|||||||
@@ -422,9 +422,9 @@ fn unix_now_ms() -> u64 {
|
|||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A repair the MRF consumer landed, fanned out so retry ledgers can drop
|
/// Legacy, unverified repair notice. Its identity lacks kind, set scope,
|
||||||
/// entries the journal no longer tracks (backlog#1894 axis B). The payload
|
/// bucket incarnation and responsibility generation. Consumers must not use
|
||||||
/// mirrors the intent identity so consumers match without re-parsing.
|
/// it to discharge persisted repair responsibility.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct MrfRepairedEvent {
|
pub struct MrfRepairedEvent {
|
||||||
pub bucket: Arc<str>,
|
pub bucket: Arc<str>,
|
||||||
@@ -439,8 +439,8 @@ const MRF_REPAIRED_EVENT_CAP: usize = 4096;
|
|||||||
|
|
||||||
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
||||||
|
|
||||||
/// Record that the MRF consumer landed a repair. Never blocks: the critical
|
/// Record a legacy notification for compatibility. This is not an
|
||||||
/// section is a deque push under a std mutex.
|
/// acknowledgement of storage verification or durable repair completion.
|
||||||
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
||||||
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||||
let Ok(mut events) = registry.lock() else {
|
let Ok(mut events) = registry.lock() else {
|
||||||
@@ -515,6 +515,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
coalescer_release(&key, Some(lease));
|
coalescer_release(&key, Some(lease));
|
||||||
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
|
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
|
||||||
|
assert_ne!(lease, retry_lease);
|
||||||
|
coalescer_release(&key, Some(lease));
|
||||||
|
assert_eq!(coalescer_admit(key.clone()), Err(MrfIngressResult::Coalesced));
|
||||||
coalescer_release(&key, Some(retry_lease));
|
coalescer_release(&key, Some(retry_lease));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,76 @@ pub trait WorkloadAdmissionSnapshotProvider {
|
|||||||
fn workload_admission_snapshot(&self) -> WorkloadAdmissionRegistrySnapshot;
|
fn workload_admission_snapshot(&self) -> WorkloadAdmissionRegistrySnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Foreground workload pressure observed against a configured utilization threshold.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct ForegroundPressure {
|
||||||
|
/// Foreground workload class whose utilization reached its threshold.
|
||||||
|
pub class: WorkloadClass,
|
||||||
|
/// Observed utilization percentage for the class.
|
||||||
|
pub usage_pct: usize,
|
||||||
|
/// Configured threshold percentage that the observed utilization reached.
|
||||||
|
pub threshold_pct: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ForegroundPressure {
|
||||||
|
/// Return a stable reason label for logs and metrics.
|
||||||
|
pub const fn reason(self) -> &'static str {
|
||||||
|
match self.class {
|
||||||
|
WorkloadClass::ForegroundRead => "foreground_read_pressure",
|
||||||
|
WorkloadClass::ForegroundWrite => "foreground_write_pressure",
|
||||||
|
_ => "foreground_pressure",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the strongest foreground pressure in `snapshot`, if any.
|
||||||
|
///
|
||||||
|
/// A zero threshold disables its class. `Saturated` counts as full utilization
|
||||||
|
/// regardless of the reported limit; otherwise a class contributes only when it
|
||||||
|
/// reports a non-zero limit, with a missing active count read as zero. When both
|
||||||
|
/// classes are above their threshold the higher utilization wins.
|
||||||
|
///
|
||||||
|
/// Callers own the enable switch: this function evaluates thresholds only.
|
||||||
|
pub fn foreground_pressure(
|
||||||
|
snapshot: &WorkloadAdmissionRegistrySnapshot,
|
||||||
|
read_threshold_pct: usize,
|
||||||
|
write_threshold_pct: usize,
|
||||||
|
) -> Option<ForegroundPressure> {
|
||||||
|
[
|
||||||
|
(WorkloadClass::ForegroundRead, read_threshold_pct),
|
||||||
|
(WorkloadClass::ForegroundWrite, write_threshold_pct),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(class, threshold_pct)| {
|
||||||
|
if threshold_pct == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = snapshot.get(class)?;
|
||||||
|
let usage_pct = if matches!(entry.state, AdmissionState::Saturated) {
|
||||||
|
100
|
||||||
|
} else {
|
||||||
|
let limit = entry.limit?;
|
||||||
|
if limit == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
entry
|
||||||
|
.active
|
||||||
|
.unwrap_or(0)
|
||||||
|
.saturating_mul(100)
|
||||||
|
.checked_div(limit)
|
||||||
|
.unwrap_or(100)
|
||||||
|
};
|
||||||
|
|
||||||
|
(usage_pct >= threshold_pct).then_some(ForegroundPressure {
|
||||||
|
class,
|
||||||
|
usage_pct,
|
||||||
|
threshold_pct,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.max_by_key(|pressure| pressure.usage_pct)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -314,4 +384,205 @@ mod tests {
|
|||||||
|
|
||||||
assert!(err.to_string().contains("unexpected"));
|
assert!(err.to_string().contains("unexpected"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn counted(
|
||||||
|
class: WorkloadClass,
|
||||||
|
state: AdmissionState,
|
||||||
|
active: Option<usize>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
) -> WorkloadAdmissionSnapshot {
|
||||||
|
WorkloadAdmissionSnapshot::new(class, state).with_counts(active, None, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn registry(entries: Vec<WorkloadAdmissionSnapshot>) -> WorkloadAdmissionRegistrySnapshot {
|
||||||
|
WorkloadAdmissionRegistrySnapshot::new(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_reason_labels_cover_non_foreground_classes() {
|
||||||
|
let read = ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundRead,
|
||||||
|
usage_pct: 90,
|
||||||
|
threshold_pct: 80,
|
||||||
|
};
|
||||||
|
let write = ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundWrite,
|
||||||
|
usage_pct: 90,
|
||||||
|
threshold_pct: 80,
|
||||||
|
};
|
||||||
|
let repair = ForegroundPressure {
|
||||||
|
class: WorkloadClass::Repair,
|
||||||
|
usage_pct: 90,
|
||||||
|
threshold_pct: 80,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(read.reason(), "foreground_read_pressure");
|
||||||
|
assert_eq!(write.reason(), "foreground_write_pressure");
|
||||||
|
assert_eq!(repair.reason(), "foreground_pressure");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_is_disabled_when_both_thresholds_are_zero() {
|
||||||
|
let snapshot = registry(vec![
|
||||||
|
counted(WorkloadClass::ForegroundRead, AdmissionState::Saturated, Some(8), Some(8)),
|
||||||
|
counted(WorkloadClass::ForegroundWrite, AdmissionState::Saturated, Some(8), Some(8)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(foreground_pressure(&snapshot, 0, 0), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_skips_only_the_class_whose_threshold_is_zero() {
|
||||||
|
let snapshot = registry(vec![
|
||||||
|
counted(WorkloadClass::ForegroundRead, AdmissionState::Open, Some(10), Some(10)),
|
||||||
|
counted(WorkloadClass::ForegroundWrite, AdmissionState::Open, Some(9), Some(10)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
foreground_pressure(&snapshot, 0, 80),
|
||||||
|
Some(ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundWrite,
|
||||||
|
usage_pct: 90,
|
||||||
|
threshold_pct: 80,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
foreground_pressure(&snapshot, 80, 0),
|
||||||
|
Some(ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundRead,
|
||||||
|
usage_pct: 100,
|
||||||
|
threshold_pct: 80,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_ignores_missing_entries() {
|
||||||
|
let snapshot = registry(vec![counted(WorkloadClass::Scanner, AdmissionState::Saturated, Some(8), Some(8))]);
|
||||||
|
|
||||||
|
assert_eq!(foreground_pressure(&snapshot, 1, 1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_ignores_missing_and_zero_limits() {
|
||||||
|
let missing_limit = registry(vec![counted(
|
||||||
|
WorkloadClass::ForegroundRead,
|
||||||
|
AdmissionState::Throttled,
|
||||||
|
Some(8),
|
||||||
|
None,
|
||||||
|
)]);
|
||||||
|
let zero_limit = registry(vec![counted(
|
||||||
|
WorkloadClass::ForegroundWrite,
|
||||||
|
AdmissionState::Throttled,
|
||||||
|
Some(8),
|
||||||
|
Some(0),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
assert_eq!(foreground_pressure(&missing_limit, 1, 1), None);
|
||||||
|
assert_eq!(foreground_pressure(&zero_limit, 1, 1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_treats_saturated_as_full_without_reading_limit() {
|
||||||
|
let snapshot = registry(vec![
|
||||||
|
counted(WorkloadClass::ForegroundRead, AdmissionState::Saturated, None, None),
|
||||||
|
counted(WorkloadClass::ForegroundWrite, AdmissionState::Saturated, Some(0), Some(0)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
foreground_pressure(&snapshot, 100, 0),
|
||||||
|
Some(ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundRead,
|
||||||
|
usage_pct: 100,
|
||||||
|
threshold_pct: 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
foreground_pressure(&snapshot, 0, 100),
|
||||||
|
Some(ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundWrite,
|
||||||
|
usage_pct: 100,
|
||||||
|
threshold_pct: 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_reads_missing_active_as_zero() {
|
||||||
|
let snapshot = registry(vec![counted(WorkloadClass::ForegroundRead, AdmissionState::Open, None, Some(8))]);
|
||||||
|
|
||||||
|
assert_eq!(foreground_pressure(&snapshot, 1, 1), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_returns_the_higher_utilization_when_both_classes_exceed() {
|
||||||
|
let read_higher = registry(vec![
|
||||||
|
counted(WorkloadClass::ForegroundRead, AdmissionState::Open, Some(19), Some(20)),
|
||||||
|
counted(WorkloadClass::ForegroundWrite, AdmissionState::Open, Some(17), Some(20)),
|
||||||
|
]);
|
||||||
|
let write_higher = registry(vec![
|
||||||
|
counted(WorkloadClass::ForegroundRead, AdmissionState::Open, Some(17), Some(20)),
|
||||||
|
counted(WorkloadClass::ForegroundWrite, AdmissionState::Open, Some(19), Some(20)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
foreground_pressure(&read_higher, 80, 80),
|
||||||
|
Some(ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundRead,
|
||||||
|
usage_pct: 95,
|
||||||
|
threshold_pct: 80,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
foreground_pressure(&write_higher, 80, 80),
|
||||||
|
Some(ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundWrite,
|
||||||
|
usage_pct: 95,
|
||||||
|
threshold_pct: 80,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_breaks_utilization_ties_toward_the_write_class() {
|
||||||
|
let snapshot = registry(vec![
|
||||||
|
counted(WorkloadClass::ForegroundRead, AdmissionState::Open, Some(18), Some(20)),
|
||||||
|
counted(WorkloadClass::ForegroundWrite, AdmissionState::Open, Some(18), Some(20)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
foreground_pressure(&snapshot, 80, 80),
|
||||||
|
Some(ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundWrite,
|
||||||
|
usage_pct: 90,
|
||||||
|
threshold_pct: 80,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn foreground_pressure_triggers_exactly_at_the_threshold_and_not_below() {
|
||||||
|
let at_threshold = registry(vec![counted(
|
||||||
|
WorkloadClass::ForegroundRead,
|
||||||
|
AdmissionState::Open,
|
||||||
|
Some(8),
|
||||||
|
Some(10),
|
||||||
|
)]);
|
||||||
|
let below_threshold = registry(vec![counted(
|
||||||
|
WorkloadClass::ForegroundRead,
|
||||||
|
AdmissionState::Open,
|
||||||
|
Some(7),
|
||||||
|
Some(10),
|
||||||
|
)]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
foreground_pressure(&at_threshold, 80, 80),
|
||||||
|
Some(ForegroundPressure {
|
||||||
|
class: WorkloadClass::ForegroundRead,
|
||||||
|
usage_pct: 80,
|
||||||
|
threshold_pct: 80,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(foreground_pressure(&below_threshold, 80, 80), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,21 @@ Scanner cycle budget controls:
|
|||||||
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
|
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
|
||||||
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
|
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
|
||||||
|
|
||||||
|
## Remote tier timeout environment variables
|
||||||
|
|
||||||
|
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
|
||||||
|
- remote tier TCP connect timeout.
|
||||||
|
- default is `10`.
|
||||||
|
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
|
||||||
|
- `RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS`
|
||||||
|
- remote tier request timeout through response headers.
|
||||||
|
- default is `86400` so large transition uploads keep a production-safe budget.
|
||||||
|
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default. Very large values are accepted and act as a correspondingly long budget.
|
||||||
|
- `RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS`
|
||||||
|
- maximum idle time between remote tier response-body chunks.
|
||||||
|
- default is `60`; the timer resets only when non-empty body data keeps progressing.
|
||||||
|
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
|
||||||
|
|
||||||
## Drive timeout environment variables
|
## Drive timeout environment variables
|
||||||
|
|
||||||
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
|
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
|
||||||
@@ -157,6 +172,14 @@ Drive timeout profile preset:
|
|||||||
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
||||||
- Then the profile-derived default (`default` or `high_latency`).
|
- Then the profile-derived default (`default` or `high_latency`).
|
||||||
|
|
||||||
|
## Admin peer probe timeout
|
||||||
|
|
||||||
|
- `RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS`
|
||||||
|
- total per-peer budget for the `server_info`/`storage_info` admin probe round; `server_info` may reconnect once and `storage_info` remains a single attempt.
|
||||||
|
- default is `10` seconds, preserving the previous two-attempt worst-case budget.
|
||||||
|
- values must be positive; `0` or an invalid value falls back to the default, and values above `60` are clamped to `60`.
|
||||||
|
- the setting is read by the aggregating node only; it does not change the internode RPC wire contract. Any retry shares one round deadline rather than receiving a fresh timeout.
|
||||||
|
|
||||||
## Startup filesystem boundary policy
|
## Startup filesystem boundary policy
|
||||||
|
|
||||||
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
|
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
|
||||||
|
|||||||
@@ -59,20 +59,20 @@ pub const ENV_CAPACITY_MAX_TIMEOUT: &str = "RUSTFS_CAPACITY_MAX_TIMEOUT";
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Scheduled update interval in seconds
|
/// Scheduled update interval in seconds
|
||||||
/// Default: 120 seconds (2 minutes)
|
/// Default: 600 seconds (10 minutes)
|
||||||
pub const DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS: u64 = 120;
|
pub const DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS: u64 = 600;
|
||||||
|
|
||||||
/// Write trigger delay in seconds
|
/// Write trigger delay in seconds
|
||||||
/// Default: 5 seconds
|
/// Default: 30 seconds
|
||||||
pub const DEFAULT_WRITE_TRIGGER_DELAY_SECS: u64 = 5;
|
pub const DEFAULT_WRITE_TRIGGER_DELAY_SECS: u64 = 30;
|
||||||
|
|
||||||
/// Write frequency threshold (writes per minute)
|
/// Write frequency threshold (writes per minute)
|
||||||
/// Default: 5 writes/minute
|
/// Default: 20 writes/minute
|
||||||
pub const DEFAULT_WRITE_FREQUENCY_THRESHOLD: usize = 5;
|
pub const DEFAULT_WRITE_FREQUENCY_THRESHOLD: usize = 20;
|
||||||
|
|
||||||
/// Fast update threshold in seconds
|
/// Fast update threshold in seconds
|
||||||
/// Default: 30 seconds
|
/// Default: 120 seconds
|
||||||
pub const DEFAULT_FAST_UPDATE_THRESHOLD_SECS: u64 = 30;
|
pub const DEFAULT_FAST_UPDATE_THRESHOLD_SECS: u64 = 120;
|
||||||
|
|
||||||
/// Maximum files threshold for sampling
|
/// Maximum files threshold for sampling
|
||||||
/// Default: 200,000 files
|
/// Default: 200,000 files
|
||||||
@@ -129,4 +129,16 @@ mod tests {
|
|||||||
assert_eq!(ENV_CAPACITY_MIN_TIMEOUT, "RUSTFS_CAPACITY_MIN_TIMEOUT");
|
assert_eq!(ENV_CAPACITY_MIN_TIMEOUT, "RUSTFS_CAPACITY_MIN_TIMEOUT");
|
||||||
assert_eq!(ENV_CAPACITY_MAX_TIMEOUT, "RUSTFS_CAPACITY_MAX_TIMEOUT");
|
assert_eq!(ENV_CAPACITY_MAX_TIMEOUT, "RUSTFS_CAPACITY_MAX_TIMEOUT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_capacity_default_values() {
|
||||||
|
assert_eq!(DEFAULT_SCHEDULED_UPDATE_INTERVAL_SECS, 600);
|
||||||
|
assert_eq!(DEFAULT_WRITE_TRIGGER_DELAY_SECS, 30);
|
||||||
|
assert_eq!(DEFAULT_WRITE_FREQUENCY_THRESHOLD, 20);
|
||||||
|
assert_eq!(DEFAULT_FAST_UPDATE_THRESHOLD_SECS, 120);
|
||||||
|
assert_eq!(DEFAULT_MAX_FILES_THRESHOLD, 200_000);
|
||||||
|
assert_eq!(DEFAULT_STAT_TIMEOUT_SECS, 3);
|
||||||
|
assert_eq!(DEFAULT_SAMPLE_RATE, 200);
|
||||||
|
assert_eq!(DEFAULT_CAPACITY_METRICS_INTERVAL_SECS, 600);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ pub const DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS: u64 = 20;
|
|||||||
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
|
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
|
||||||
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 30;
|
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
|
/// Total budget for one admin peer probe round, including any reconnect retry.
|
||||||
|
///
|
||||||
|
/// This is intentionally separate from the transport-level RPC timeout: admin
|
||||||
|
/// probes may retry once, but the retry must consume the same round budget.
|
||||||
|
pub const ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS: &str = "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS";
|
||||||
|
pub const DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 10;
|
||||||
|
pub const MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 60;
|
||||||
|
const _: () = assert!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS <= MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS);
|
||||||
|
|
||||||
// ── Client-side internode gRPC channel tuning (P0) ──
|
// ── Client-side internode gRPC channel tuning (P0) ──
|
||||||
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
|
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
|
||||||
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
|
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
|
||||||
@@ -312,6 +321,7 @@ mod tests {
|
|||||||
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5);
|
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5);
|
||||||
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 20);
|
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 20);
|
||||||
assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 30);
|
assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 30);
|
||||||
|
assert_eq!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS, 10);
|
||||||
assert_eq!(DEFAULT_INTERNODE_HTTP_TUNING_PROFILE, "legacy");
|
assert_eq!(DEFAULT_INTERNODE_HTTP_TUNING_PROFILE, "legacy");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +422,7 @@ mod tests {
|
|||||||
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
|
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
|
||||||
);
|
);
|
||||||
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
|
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
|
||||||
|
assert_eq!(ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS, "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS");
|
||||||
assert_eq!(ENV_INTERNODE_HTTP_TUNING_PROFILE, "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE");
|
assert_eq!(ENV_INTERNODE_HTTP_TUNING_PROFILE, "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE");
|
||||||
assert_eq!(ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST, "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST");
|
assert_eq!(ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST, "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST");
|
||||||
assert_eq!(ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS, "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS");
|
assert_eq!(ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS, "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS");
|
||||||
|
|||||||
@@ -137,6 +137,28 @@ pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
|
|||||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
||||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
||||||
|
|
||||||
|
/// Environment variable for remote tier TCP connect timeout in seconds.
|
||||||
|
pub const ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS";
|
||||||
|
/// Default remote tier TCP connect timeout in seconds.
|
||||||
|
pub const DEFAULT_TIER_REMOTE_CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||||
|
|
||||||
|
/// Environment variable for the remote tier request timeout in seconds.
|
||||||
|
///
|
||||||
|
/// This bounds upload/download request progress through response headers. The
|
||||||
|
/// default is intentionally large so multi-TiB transition uploads keep their
|
||||||
|
/// previous production budget while black-hole remotes no longer wait forever.
|
||||||
|
pub const ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS";
|
||||||
|
/// Default remote tier request timeout in seconds.
|
||||||
|
pub const DEFAULT_TIER_REMOTE_REQUEST_TIMEOUT_SECS: u64 = 24 * 60 * 60;
|
||||||
|
|
||||||
|
/// Environment variable for remote tier response-body idle timeout in seconds.
|
||||||
|
///
|
||||||
|
/// The timer is re-armed on every non-empty response-body chunk, so slow but
|
||||||
|
/// progressing remotes can continue while silent response bodies are cancelled.
|
||||||
|
pub const ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS";
|
||||||
|
/// Default remote tier response-body idle timeout in seconds.
|
||||||
|
pub const DEFAULT_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: u64 = 60;
|
||||||
|
|
||||||
/// Request the object-transaction fencing contract used by storage-owned
|
/// Request the object-transaction fencing contract used by storage-owned
|
||||||
/// cleanup receipts and lock-window optimizations.
|
/// cleanup receipts and lock-window optimizations.
|
||||||
///
|
///
|
||||||
@@ -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_WRITE);
|
||||||
const _: () = assert!(!DEFAULT_POOL_META_V3_FLEET_CONFIRMED);
|
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
|
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -297,7 +340,7 @@ const _: () = assert!(!DEFAULT_PUT_FOREGROUND_ADMISSION_ENABLE);
|
|||||||
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE";
|
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE";
|
||||||
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE: bool = true;
|
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE: bool = true;
|
||||||
|
|
||||||
/// Maximum large foreground PutObject requests admitted concurrently per process.
|
/// Maximum automatic foreground write requests admitted concurrently per process.
|
||||||
///
|
///
|
||||||
/// `0` derives a conservative default from the local disk-read scheduler cap,
|
/// `0` derives a conservative default from the local disk-read scheduler cap,
|
||||||
/// currently clamped to protect the commit path without making ordinary high
|
/// currently clamped to protect the commit path without making ordinary high
|
||||||
@@ -305,14 +348,24 @@ pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE: bool = true;
|
|||||||
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT";
|
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT";
|
||||||
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT: usize = 0;
|
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT: usize = 0;
|
||||||
|
|
||||||
/// Minimum object size that enters automatic large PutObject admission.
|
/// Minimum direct PutObject size that enters automatic foreground write admission.
|
||||||
///
|
///
|
||||||
/// Requests with an unknown size are treated as large because the write pressure
|
/// Requests with an unknown size are treated as large because the write pressure
|
||||||
/// cannot be bounded from headers.
|
/// cannot be bounded from headers.
|
||||||
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
|
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
|
||||||
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 32 * 1024 * 1024;
|
||||||
|
|
||||||
/// Time in milliseconds a large foreground PutObject waits for a permit.
|
/// Minimum UploadPart size that enters automatic foreground write admission.
|
||||||
|
///
|
||||||
|
/// Multipart pressure is often many moderate-sized parts rather than one very
|
||||||
|
/// large request. The default gates every multipart part through the same permit
|
||||||
|
/// pool as large/unknown-size PutObject while keeping small direct PUTs on the
|
||||||
|
/// legacy path.
|
||||||
|
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: &str =
|
||||||
|
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
|
||||||
|
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 0;
|
||||||
|
|
||||||
|
/// Time in milliseconds an automatic foreground write waits for a permit.
|
||||||
///
|
///
|
||||||
/// A short wait smooths transient bursts while still returning S3
|
/// A short wait smooths transient bursts while still returning S3
|
||||||
/// `SlowDown`/503 before body ingest when the node is already saturated.
|
/// `SlowDown`/503 before body ingest when the node is already saturated.
|
||||||
@@ -781,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]
|
#[test]
|
||||||
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
|
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
|
||||||
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
|
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
|
||||||
@@ -810,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_WRITE, "RUSTFS_POOL_META_V3_WRITE");
|
||||||
assert_eq!(super::ENV_POOL_META_V3_FLEET_CONFIRMED, "RUSTFS_POOL_META_V3_FLEET_CONFIRMED");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,11 +198,11 @@ pub const ENV_SCANNER_IDLE_MODE: &str = "RUSTFS_SCANNER_IDLE_MODE";
|
|||||||
/// Environment variable that controls scanner cache save timeout in seconds.
|
/// Environment variable that controls scanner cache save timeout in seconds.
|
||||||
/// The scanner enforces a minimum value of `1`.
|
/// The scanner enforces a minimum value of `1`.
|
||||||
/// - Unit: seconds (u64).
|
/// - Unit: seconds (u64).
|
||||||
/// - Example: `export RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS=30`
|
/// - Example: `export RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS=14`
|
||||||
pub const ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS: &str = "RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS";
|
pub const ENV_SCANNER_CACHE_SAVE_TIMEOUT_SECS: &str = "RUSTFS_SCANNER_CACHE_SAVE_TIMEOUT_SECS";
|
||||||
|
|
||||||
/// Default scanner cache save timeout in seconds.
|
/// Default scanner cache save timeout in seconds.
|
||||||
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 30;
|
pub const DEFAULT_SCANNER_CACHE_SAVE_TIMEOUT_SECS: u64 = 14;
|
||||||
|
|
||||||
/// Environment variable that caps concurrent scanner set tasks.
|
/// Environment variable that caps concurrent scanner set tasks.
|
||||||
/// A value of `0` keeps the existing topology-based concurrency.
|
/// A value of `0` keeps the existing topology-based concurrency.
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a",
|
|||||||
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
|
aws-sdk-sts = { workspace = true, default-features = false, features = ["default-https-client", "rt-tokio"] }
|
||||||
aws-config = { workspace = true }
|
aws-config = { workspace = true }
|
||||||
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
aws-smithy-http-client = { workspace = true, default-features = false, features = ["rustls-aws-lc"] }
|
||||||
async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
|
aws-smithy-types.workspace = true
|
||||||
|
async-compression = { workspace = true, features = ["tokio", "bzip2", "lz4", "xz"] }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
flate2.workspace = true
|
flate2.workspace = true
|
||||||
http.workspace = true
|
http.workspace = true
|
||||||
@@ -114,6 +115,7 @@ rustfs-signer.workspace = true
|
|||||||
# server's implementation: a shared helper could agree with a bug on both sides.
|
# server's implementation: a shared helper could agree with a bug on both sides.
|
||||||
data-encoding = { workspace = true }
|
data-encoding = { workspace = true }
|
||||||
hmac = { workspace = true }
|
hmac = { workspace = true }
|
||||||
|
minlz.workspace = true
|
||||||
sha1 = { workspace = true }
|
sha1 = { workspace = true }
|
||||||
serde_urlencoded = { workspace = true }
|
serde_urlencoded = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
|
|||||||
@@ -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) |
|
| **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) |
|
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
|
||||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||||
|
| **distributed 4×4** | [`src/distributed/`](src/distributed) | Storage-sensitive PR and nightly `e2e-distributed` lane: S3, object lock/WORM, versioning, bucket/site replication, quota, expand/decommission/rebalance, concurrency, chaos, 4-node upgrade of historical data and IAM AK/SK. Map: [`docs/testing/distributed-e2e.md`](../../docs/testing/distributed-e2e.md) |
|
||||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||||
|
|
||||||
@@ -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) |
|
| `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) |
|
| ILM / lifecycle (ignored) | `test-ilm-integration-serial` lane, `-j1` | **Active** (backlog#1148 ilm-1) |
|
||||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
| 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) |
|
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
||||||
|
| Distributed 4-node 4-disk (`e2e-distributed` profile) | `.github/workflows/e2e-distributed.yml` | **Active** (storage-sensitive PR / nightly / dispatch) |
|
||||||
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
||||||
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
||||||
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
|
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
|
||||||
@@ -191,6 +193,9 @@ cargo nextest run --profile e2e-smoke -p e2e_test
|
|||||||
cargo nextest run --profile e2e-full -p e2e_test
|
cargo nextest run --profile e2e-full -p e2e_test
|
||||||
# Cluster fault nightly lane
|
# Cluster fault nightly lane
|
||||||
cargo nextest run --profile e2e-nightly -p e2e_test
|
cargo nextest run --profile e2e-nightly -p e2e_test
|
||||||
|
# 4-node 4-disk distributed lane (S3 / lock / versioning / replication / decommission / chaos / upgrade)
|
||||||
|
# Upgrade cases need RUSTFS_UPGRADE_SOURCE_BINARY; without it they fail closed.
|
||||||
|
cargo nextest run --profile e2e-distributed -p e2e_test
|
||||||
# Replication nightly lane; awscurl is required for STS paths
|
# Replication nightly lane; awscurl is required for STS paths
|
||||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||||
# Fixed-port protocol nightly lane
|
# Fixed-port protocol nightly lane
|
||||||
@@ -233,8 +238,8 @@ spawn error. Install the pinned CI version before running their profiles.
|
|||||||
[`src/policy/README.md`](src/policy/README.md),
|
[`src/policy/README.md`](src/policy/README.md),
|
||||||
[`src/protocols/README.md`](src/protocols/README.md),
|
[`src/protocols/README.md`](src/protocols/README.md),
|
||||||
[`src/reliant/README.md`](src/reliant/README.md)
|
[`src/reliant/README.md`](src/reliant/README.md)
|
||||||
- Authoritative per-module counts:
|
- Per-module counts: `cargo nextest list -p e2e_test --profile <profile>`
|
||||||
[`docs/testing/e2e-suite-inventory.md`](../../docs/testing/e2e-suite-inventory.md)
|
(one-liner in [`docs/testing/README.md`](../../docs/testing/README.md))
|
||||||
- Test pyramid & flake policy: [`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`)
|
## 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
|
parallel-safe by construction (random port + isolated temp dir), which the
|
||||||
current subset is.
|
current subset is.
|
||||||
|
|
||||||
### Authoritative test inventory
|
### Test inventory
|
||||||
|
|
||||||
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
|
Per-module counts are not committed; list them with
|
||||||
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
|
`cargo nextest list -p e2e_test --profile <profile>` (the result is
|
||||||
moving e2e tests so acceptance numbers in the test-strategy issues
|
platform-dependent because some modules are linux-only; the `jq` one-liner is
|
||||||
(backlog#1147–#1155) stay auditable. When a profile membership change is
|
in `docs/testing/README.md`). When a profile membership change is
|
||||||
intentional, review its JSON listing before updating the matching
|
intentional, review its JSON listing before updating the matching
|
||||||
`.config/e2e-*-selection.txt` test-ID digest. Update only the platform that
|
`.config/e2e-*-selection.txt` test-ID digest. Update only the platform that
|
||||||
produced the listing:
|
produced the listing:
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
// Licensed under the Apache License, Version 2.0.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn git(root: &Path, args: &[&str]) -> Option<String> {
|
||||||
|
let output = Command::new("git").args(args).current_dir(root).output().ok()?;
|
||||||
|
output
|
||||||
|
.status
|
||||||
|
.success()
|
||||||
|
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit(name: &str, value: &str) {
|
||||||
|
let value = if value.contains(['\n', '\r']) { "unknown" } else { value };
|
||||||
|
println!("cargo:rustc-env=RUSTFS_E2E_BUILD_{name}={value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||||
|
let root = Path::new(&manifest).join("../..");
|
||||||
|
// Cover dependency/common sources as well as this crate. HEAD/ref/index
|
||||||
|
// changes must refresh identity even when no Rust source mtime changes.
|
||||||
|
for path in [
|
||||||
|
"crates",
|
||||||
|
"rustfs",
|
||||||
|
"Cargo.toml",
|
||||||
|
"Cargo.lock",
|
||||||
|
"rust-toolchain.toml",
|
||||||
|
".cargo",
|
||||||
|
".config",
|
||||||
|
] {
|
||||||
|
println!("cargo:rerun-if-changed={}", root.join(path).display());
|
||||||
|
}
|
||||||
|
let mut git_paths = vec!["HEAD".to_owned(), "index".to_owned(), "packed-refs".to_owned()];
|
||||||
|
if let Some(reference) = git(&root, &["symbolic-ref", "-q", "HEAD"]) {
|
||||||
|
git_paths.push(reference);
|
||||||
|
}
|
||||||
|
for path in git_paths {
|
||||||
|
if let Some(path) = git(&root, &["rev-parse", "--git-path", &path]) {
|
||||||
|
let path = Path::new(&path);
|
||||||
|
let path = if path.is_absolute() {
|
||||||
|
path.to_owned()
|
||||||
|
} else {
|
||||||
|
root.join(path)
|
||||||
|
};
|
||||||
|
if path.exists() {
|
||||||
|
println!("cargo:rerun-if-changed={}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
|
||||||
|
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=normal"]).is_none_or(|status| !status.is_empty());
|
||||||
|
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
|
||||||
|
let mut features = std::env::vars()
|
||||||
|
.filter_map(|(key, _)| {
|
||||||
|
key.strip_prefix("CARGO_FEATURE_")
|
||||||
|
.map(|name| name.to_ascii_lowercase().replace('_', "-"))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
features.sort();
|
||||||
|
emit("COMMIT", &revision);
|
||||||
|
emit("DIRTY", if dirty { "true" } else { "false" });
|
||||||
|
emit("LOCK", &lock);
|
||||||
|
emit("FEATURES", &features.join(","));
|
||||||
|
for name in ["TARGET", "PROFILE"] {
|
||||||
|
emit(name, &std::env::var(name).unwrap_or_else(|_| "unknown".to_owned()));
|
||||||
|
}
|
||||||
|
println!("cargo:rerun-if-env-changed=CARGO_ENCODED_RUSTFLAGS");
|
||||||
|
let flags = std::env::var("CARGO_ENCODED_RUSTFLAGS").unwrap_or_default();
|
||||||
|
let flags: String = flags.as_bytes().iter().map(|byte| format!("{byte:02x}")).collect();
|
||||||
|
emit("RUSTFLAGS_HEX", &flags);
|
||||||
|
}
|
||||||
@@ -31,14 +31,9 @@
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client, rustfs_binary_path};
|
use crate::common::{RustFSTestEnvironment, init_logging, rustfs_binary_path};
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::{Client, Config};
|
|
||||||
use http::header::HOST;
|
|
||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
|
||||||
use rustfs_signer::sign_v4;
|
|
||||||
use s3s::Body;
|
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
@@ -87,10 +82,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send a SigV4-signed request to `path` (optionally with a JSON `body`) and
|
/// Send a SigV4-signed request to `path` (optionally with a JSON `body`) and
|
||||||
/// return `(status, body)`. Uses the `UNSIGNED_PAYLOAD` content hash so a
|
/// return `(status, body)`.
|
||||||
/// request body can be attached without the caller pre-hashing it — the
|
///
|
||||||
/// server verifies the signature against the same sentinel, exactly as the
|
/// Thin wrapper over [`crate::common::admin_request`], kept local so the
|
||||||
/// AWS SDKs / MinIO client do for streaming/unsigned payloads.
|
/// call sites below keep their `Option<&str>` body shape.
|
||||||
async fn signed_request(
|
async fn signed_request(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
method: http::Method,
|
method: http::Method,
|
||||||
@@ -99,47 +94,13 @@ mod tests {
|
|||||||
access_key: &str,
|
access_key: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
) -> Result<(reqwest::StatusCode, String), Box<dyn Error + Send + Sync>> {
|
) -> Result<(reqwest::StatusCode, String), Box<dyn Error + Send + Sync>> {
|
||||||
let url = format!("{base_url}{path}");
|
crate::common::admin_request(base_url, method, path, body.map(str::to_string), access_key, secret_key).await
|
||||||
let uri = url.parse::<http::Uri>()?;
|
|
||||||
let authority = uri.authority().ok_or("missing authority")?.to_string();
|
|
||||||
let body_bytes = body.map(|b| b.as_bytes().to_vec()).unwrap_or_default();
|
|
||||||
|
|
||||||
// The signature is computed over `UNSIGNED_PAYLOAD`, so the body bytes do
|
|
||||||
// not participate in the SigV4 hash — sign over an empty body and attach
|
|
||||||
// the real payload to the wire request below.
|
|
||||||
let request = http::Request::builder()
|
|
||||||
.method(method.clone())
|
|
||||||
.uri(uri)
|
|
||||||
.header(HOST, authority)
|
|
||||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
|
||||||
let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1");
|
|
||||||
|
|
||||||
let client = local_http_client();
|
|
||||||
let mut rb = client.request(method, url.as_str());
|
|
||||||
for (name, value) in signed.headers() {
|
|
||||||
rb = rb.header(name, value);
|
|
||||||
}
|
|
||||||
if !body_bytes.is_empty() {
|
|
||||||
rb = rb.body(body_bytes);
|
|
||||||
}
|
|
||||||
let resp = rb.send().await?;
|
|
||||||
let status = resp.status();
|
|
||||||
let text = resp.text().await?;
|
|
||||||
Ok((status, text))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build an S3 client bound to explicit credentials (used to exercise the S3
|
/// Build an S3 client bound to explicit credentials (used to exercise the S3
|
||||||
/// data plane with rotated / stale root credentials).
|
/// data plane with rotated / stale root credentials).
|
||||||
fn s3_client_with(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
|
fn s3_client_with(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
|
||||||
let credentials = Credentials::new(access_key, secret_key, None, None, "sec4-admin-auth");
|
env.create_s3_client_with_credentials(access_key, secret_key)
|
||||||
let config = Config::builder()
|
|
||||||
.credentials_provider(credentials)
|
|
||||||
.region(Region::new("us-east-1"))
|
|
||||||
.endpoint_url(&env.url)
|
|
||||||
.force_path_style(true)
|
|
||||||
.behavior_version_latest()
|
|
||||||
.build();
|
|
||||||
Client::from_conf(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a non-admin IAM user via the admin `add-user` API using the root
|
/// Create a non-admin IAM user via the admin `add-user` API using the root
|
||||||
@@ -151,12 +112,7 @@ mod tests {
|
|||||||
access_key: &str,
|
access_key: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let path = format!("/rustfs/admin/v3/add-user?accessKey={access_key}");
|
crate::common::admin_create_user(env, access_key, secret_key).await
|
||||||
let body = serde_json::json!({ "secretKey": secret_key, "status": "enabled" }).to_string();
|
|
||||||
let (status, resp) =
|
|
||||||
signed_request(&env.url, http::Method::PUT, &path, Some(&body), &env.access_key, &env.secret_key).await?;
|
|
||||||
assert!(status.is_success(), "add-user should succeed (status={status}, body={resp})");
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A fully authenticated but non-admin credential must be rejected with
|
/// A fully authenticated but non-admin credential must be rejected with
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ mod tests {
|
|||||||
|
|
||||||
/// One signed admin request, returning the status and the raw body.
|
/// One signed admin request, returning the status and the raw body.
|
||||||
///
|
///
|
||||||
/// Signs with `UNSIGNED_PAYLOAD` so the body does not participate in the
|
/// Thin wrapper over [`crate::common::admin_request`], kept local so the
|
||||||
/// hash, matching how the other admin e2e tests drive these routes.
|
/// call sites below keep their `Option<&str>` body shape.
|
||||||
async fn signed_request(
|
async fn signed_request(
|
||||||
base_url: &str,
|
base_url: &str,
|
||||||
method: http::Method,
|
method: http::Method,
|
||||||
@@ -69,30 +69,7 @@ mod tests {
|
|||||||
access_key: &str,
|
access_key: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
) -> Result<(reqwest::StatusCode, String), Box<dyn Error + Send + Sync>> {
|
) -> Result<(reqwest::StatusCode, String), Box<dyn Error + Send + Sync>> {
|
||||||
let url = format!("{base_url}{path}");
|
crate::common::admin_request(base_url, method, path, body.map(str::to_string), access_key, secret_key).await
|
||||||
let uri = url.parse::<http::Uri>()?;
|
|
||||||
let authority = uri.authority().ok_or("missing authority")?.to_string();
|
|
||||||
let body_bytes = body.map(|b| b.as_bytes().to_vec()).unwrap_or_default();
|
|
||||||
|
|
||||||
let request = http::Request::builder()
|
|
||||||
.method(method.clone())
|
|
||||||
.uri(uri)
|
|
||||||
.header(HOST, authority)
|
|
||||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
|
||||||
let signed = sign_v4(request.body(Body::empty())?, 0, access_key, secret_key, "", "us-east-1");
|
|
||||||
|
|
||||||
let client = local_http_client();
|
|
||||||
let mut builder = client.request(method, url.as_str());
|
|
||||||
for (name, value) in signed.headers() {
|
|
||||||
builder = builder.header(name, value);
|
|
||||||
}
|
|
||||||
if !body_bytes.is_empty() {
|
|
||||||
builder = builder.body(body_bytes);
|
|
||||||
}
|
|
||||||
let response = builder.send().await?;
|
|
||||||
let status = response.status();
|
|
||||||
let text = response.text().await?;
|
|
||||||
Ok((status, text))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A SigV4-signed `AssumeRole` form POST, optionally carrying a second factor.
|
/// A SigV4-signed `AssumeRole` form POST, optionally carrying a second factor.
|
||||||
|
|||||||
@@ -15,39 +15,23 @@
|
|||||||
//! Regression test for Issue #1423
|
//! Regression test for Issue #1423
|
||||||
//! Verifies that Bucket Policies are honored for Authenticated Users.
|
//! Verifies that Bucket Policies are honored for Authenticated Users.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{AdminTransport, RustFSTestEnvironment, admin_create_user_via, init_logging};
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::{Client, Config};
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
/// This suite deliberately drives the admin API through the external `awscurl`
|
||||||
|
/// binary, so user creation pins `AdminTransport::Awscurl`.
|
||||||
async fn create_user(
|
async fn create_user(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let create_user_body = serde_json::json!({
|
admin_create_user_via(AdminTransport::Awscurl, &env.url, &env.access_key, &env.secret_key, username, password).await
|
||||||
"secretKey": password,
|
|
||||||
"status": "enabled"
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let create_user_url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, username);
|
|
||||||
crate::common::awscurl_put(&create_user_url, &create_user_body, &env.access_key, &env.secret_key).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
|
fn create_user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
|
||||||
let credentials = Credentials::new(access_key, secret_key, None, None, "test-user");
|
env.create_s3_client_with_credentials(access_key, secret_key)
|
||||||
let config = Config::builder()
|
|
||||||
.credentials_provider(credentials)
|
|
||||||
.region(Region::new("us-east-1"))
|
|
||||||
.endpoint_url(&env.url)
|
|
||||||
.force_path_style(true)
|
|
||||||
.behavior_version_latest()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
Client::from_conf(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -55,18 +55,20 @@ type ChaosResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
|||||||
/// A successful S3 GET only proves that a quorum can serve an object. Replacement
|
/// A successful S3 GET only proves that a quorum can serve an object. Replacement
|
||||||
/// tests need this lower-level record to prove that the rebuilt target holds the
|
/// tests need this lower-level record to prove that the rebuilt target holds the
|
||||||
/// `xl.meta` selected for a specific version and every `part.N` it declares.
|
/// `xl.meta` selected for a specific version and every `part.N` it declares.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
pub(crate) struct VersionShardCensus {
|
pub(crate) struct VersionShardCensus {
|
||||||
pub version_id: Option<String>,
|
pub version_id: Option<String>,
|
||||||
pub has_xl_meta: bool,
|
pub has_xl_meta: bool,
|
||||||
pub data_dir: Option<String>,
|
pub data_dir: Option<String>,
|
||||||
pub erasure_index: Option<usize>,
|
pub erasure_index: Option<usize>,
|
||||||
|
pub data_blocks: Option<usize>,
|
||||||
|
pub parity_blocks: Option<usize>,
|
||||||
pub expected_part_numbers: BTreeSet<usize>,
|
pub expected_part_numbers: BTreeSet<usize>,
|
||||||
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>,
|
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>,
|
||||||
pub inline_data_fingerprint: Option<PartShardFingerprint>,
|
pub inline_data_fingerprint: Option<PartShardFingerprint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||||
pub(crate) struct PartShardFingerprint {
|
pub(crate) struct PartShardFingerprint {
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub sha256: String,
|
pub sha256: String,
|
||||||
@@ -88,13 +90,15 @@ impl VersionShardCensus {
|
|||||||
&& manifest.is_complete()
|
&& manifest.is_complete()
|
||||||
&& self.data_dir == manifest.data_dir
|
&& self.data_dir == manifest.data_dir
|
||||||
&& self.erasure_index == manifest.erasure_index
|
&& self.erasure_index == manifest.erasure_index
|
||||||
|
&& self.data_blocks == manifest.data_blocks
|
||||||
|
&& self.parity_blocks == manifest.parity_blocks
|
||||||
&& self.expected_part_numbers == manifest.expected_part_numbers
|
&& self.expected_part_numbers == manifest.expected_part_numbers
|
||||||
&& self.present_part_fingerprints == manifest.present_part_fingerprints
|
&& self.present_part_fingerprints == manifest.present_part_fingerprints
|
||||||
&& self.inline_data_fingerprint == manifest.inline_data_fingerprint
|
&& self.inline_data_fingerprint == manifest.inline_data_fingerprint
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sha256_hex(data: &[u8]) -> String {
|
pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
||||||
let digest = Sha256::digest(data);
|
let digest = Sha256::digest(data);
|
||||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
}
|
}
|
||||||
@@ -313,6 +317,8 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
has_xl_meta: false,
|
has_xl_meta: false,
|
||||||
data_dir: None,
|
data_dir: None,
|
||||||
erasure_index: None,
|
erasure_index: None,
|
||||||
|
data_blocks: None,
|
||||||
|
parity_blocks: None,
|
||||||
expected_part_numbers: BTreeSet::new(),
|
expected_part_numbers: BTreeSet::new(),
|
||||||
present_part_fingerprints: BTreeMap::new(),
|
present_part_fingerprints: BTreeMap::new(),
|
||||||
inline_data_fingerprint: None,
|
inline_data_fingerprint: None,
|
||||||
@@ -360,6 +366,8 @@ pub(crate) fn census_object_version_on_disk(
|
|||||||
has_xl_meta: true,
|
has_xl_meta: true,
|
||||||
data_dir,
|
data_dir,
|
||||||
erasure_index,
|
erasure_index,
|
||||||
|
data_blocks: Some(file_info.erasure.data_blocks),
|
||||||
|
parity_blocks: Some(file_info.erasure.parity_blocks),
|
||||||
expected_part_numbers,
|
expected_part_numbers,
|
||||||
present_part_fingerprints,
|
present_part_fingerprints,
|
||||||
inline_data_fingerprint,
|
inline_data_fingerprint,
|
||||||
@@ -413,6 +421,8 @@ mod tests {
|
|||||||
has_xl_meta: true,
|
has_xl_meta: true,
|
||||||
data_dir: Some("data-dir".to_string()),
|
data_dir: Some("data-dir".to_string()),
|
||||||
erasure_index: Some(3),
|
erasure_index: Some(3),
|
||||||
|
data_blocks: Some(2),
|
||||||
|
parity_blocks: Some(2),
|
||||||
expected_part_numbers: BTreeSet::from([1]),
|
expected_part_numbers: BTreeSet::from([1]),
|
||||||
present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]),
|
present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]),
|
||||||
inline_data_fingerprint: None,
|
inline_data_fingerprint: None,
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ mod tests {
|
|||||||
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
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 aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
use md5::{Digest as Md5Digest, Md5};
|
use md5::{Digest as Md5Digest, Md5};
|
||||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||||
@@ -260,6 +263,117 @@ mod tests {
|
|||||||
info!("PASSED: HeadObject returns stored SHA256 digest");
|
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.
|
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
|
||||||
/// Uses part size >= 5MB (server minimum) for two parts.
|
/// Uses part size >= 5MB (server minimum) for two parts.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -485,6 +599,222 @@ mod tests {
|
|||||||
Some(full_checksum.as_str()),
|
Some(full_checksum.as_str()),
|
||||||
"Multipart object should report the same full-object CRC64NVME as direct upload"
|
"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
|
/// Integration test for the AWS 2026-04 additional checksum algorithms
|
||||||
|
|||||||
@@ -27,8 +27,10 @@
|
|||||||
//! Readiness is established by the harness's `start()` handshake (TCP reachability
|
//! Readiness is established by the harness's `start()` handshake (TCP reachability
|
||||||
//! plus an S3 `ListBuckets` poll) — there are no fixed sleeps.
|
//! plus an S3 `ListBuckets` poll) — there are no fixed sleeps.
|
||||||
//!
|
//!
|
||||||
//! Out of scope for this block (tracked separately): network fault injection
|
//! The volume-proxy smoke below also proves that the socket-level fault proxy
|
||||||
//! (toxiproxy / socket proxy) and 5GiB large-object budgets.
|
//! can be installed before startup without changing the client-facing node URL.
|
||||||
|
//! A full lock-plane partition matrix and 5GiB large-object budget remain
|
||||||
|
//! tracked separately.
|
||||||
|
|
||||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
||||||
|
|
||||||
@@ -76,6 +78,28 @@ async fn cluster_multidrive_single_pool_smoke() -> TestResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 4 nodes x 4 drives, single pool: exercise the maximum local erasure layout
|
||||||
|
/// supported by the cluster harness. This remains in the nightly lane because
|
||||||
|
/// it starts four real server processes and sixteen data directories.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cluster_four_node_four_drive_single_pool_smoke() -> TestResult {
|
||||||
|
crate::common::init_logging();
|
||||||
|
|
||||||
|
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(4, 4)).await?;
|
||||||
|
|
||||||
|
let volumes = cluster.rustfs_volumes_arg();
|
||||||
|
assert_eq!(volumes.split(' ').count(), 16, "expected 16 explicit endpoints, got: {volumes}");
|
||||||
|
assert!(!volumes.contains('{'), "single-pool layout must not use ellipses: {volumes}");
|
||||||
|
assert!(cluster.nodes.iter().all(|node| node.data_dirs.len() == 4));
|
||||||
|
|
||||||
|
cluster.start().await?;
|
||||||
|
cluster.create_test_bucket(BUCKET).await?;
|
||||||
|
|
||||||
|
let payload = vec![0x3Cu8; 1024 * 1024];
|
||||||
|
put_get_roundtrip(&cluster, "multidrive-4/object", &payload).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
|
/// Two single-node pools, 2 drives each: the multi-pool layout boots and
|
||||||
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
|
/// round-trips. Every pool is a distinct erasure pool (`pool_idx` 0 and 1).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -103,3 +127,27 @@ async fn cluster_two_pool_smoke() -> TestResult {
|
|||||||
put_get_roundtrip(&cluster, "twopool/object", &payload).await?;
|
put_get_roundtrip(&cluster, "twopool/object", &payload).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A real cluster smoke for the volume FaultProxy wiring. The proxy target is
|
||||||
|
/// not listening yet when it is created; cluster startup must still converge
|
||||||
|
/// once the target node starts, and peer disk/RPC traffic must traverse it.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cluster_volume_fault_proxy_pass_smoke() -> TestResult {
|
||||||
|
crate::common::init_logging();
|
||||||
|
|
||||||
|
let mut cluster = RustFSTestClusterEnvironment::with_topology(ClusterTopology::single_pool_multidrive(2, 2)).await?;
|
||||||
|
let proxy = cluster.start_volume_proxy_for_node(0).await?;
|
||||||
|
let proxied = proxy.local_addr().to_string();
|
||||||
|
assert!(cluster.rustfs_volumes_arg().contains(&proxied));
|
||||||
|
|
||||||
|
let result: TestResult = async {
|
||||||
|
cluster.start().await?;
|
||||||
|
cluster.create_test_bucket(BUCKET).await?;
|
||||||
|
let payload = vec![0x6Du8; 256 * 1024];
|
||||||
|
put_get_roundtrip(&cluster, "volume-proxy/object", &payload).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
|
proxy.shutdown().await;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|||||||
+387
-63
@@ -34,6 +34,7 @@ use serde_json;
|
|||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs as stdfs;
|
use std::fs as stdfs;
|
||||||
use std::io::ErrorKind;
|
use std::io::ErrorKind;
|
||||||
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
use std::sync::Once;
|
use std::sync::Once;
|
||||||
@@ -217,7 +218,37 @@ pub(crate) async fn signed_s3_request(
|
|||||||
access_key: &str,
|
access_key: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
signed_s3_request_with_session_token(method, url, body, content_type, access_key, secret_key, None).await
|
signed_s3_request_with_headers(method, url, body, content_type, access_key, secret_key, &http::HeaderMap::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn signed_s3_request_with_headers(
|
||||||
|
method: http::Method,
|
||||||
|
url: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
content_type: Option<&str>,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
extra_headers: &http::HeaderMap,
|
||||||
|
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
signed_s3_request_with_session_token(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
body,
|
||||||
|
content_type,
|
||||||
|
SigningCredentials {
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
session_token: None,
|
||||||
|
},
|
||||||
|
extra_headers,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SigningCredentials<'a> {
|
||||||
|
access_key: &'a str,
|
||||||
|
secret_key: &'a str,
|
||||||
|
session_token: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn signed_s3_request_with_session_token(
|
async fn signed_s3_request_with_session_token(
|
||||||
@@ -225,9 +256,8 @@ async fn signed_s3_request_with_session_token(
|
|||||||
url: &str,
|
url: &str,
|
||||||
body: Option<String>,
|
body: Option<String>,
|
||||||
content_type: Option<&str>,
|
content_type: Option<&str>,
|
||||||
access_key: &str,
|
credentials: SigningCredentials<'_>,
|
||||||
secret_key: &str,
|
extra_headers: &http::HeaderMap,
|
||||||
session_token: Option<&str>,
|
|
||||||
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<reqwest::Response, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let uri = url.parse::<http::Uri>()?;
|
let uri = url.parse::<http::Uri>()?;
|
||||||
let authority = uri.authority().ok_or("S3 URL missing authority")?.to_string();
|
let authority = uri.authority().ok_or("S3 URL missing authority")?.to_string();
|
||||||
@@ -239,14 +269,17 @@ async fn signed_s3_request_with_session_token(
|
|||||||
if let Some(content_type) = content_type {
|
if let Some(content_type) = content_type {
|
||||||
request = request.header(CONTENT_TYPE, content_type);
|
request = request.header(CONTENT_TYPE, content_type);
|
||||||
}
|
}
|
||||||
|
for (name, value) in extra_headers {
|
||||||
|
request = request.header(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
let content_length = i64::try_from(body.as_ref().map_or(0, String::len)).map_err(|_| "S3 request body is too large")?;
|
let content_length = i64::try_from(body.as_ref().map_or(0, String::len)).map_err(|_| "S3 request body is too large")?;
|
||||||
let signed = sign_v4(
|
let signed = sign_v4(
|
||||||
request.body(Body::empty())?,
|
request.body(Body::empty())?,
|
||||||
content_length,
|
content_length,
|
||||||
access_key,
|
credentials.access_key,
|
||||||
secret_key,
|
credentials.secret_key,
|
||||||
session_token.unwrap_or_default(),
|
credentials.session_token.unwrap_or_default(),
|
||||||
"us-east-1",
|
"us-east-1",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -283,8 +316,19 @@ pub(crate) async fn admin_request_with_session_token(
|
|||||||
) -> Result<(StatusCode, String), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(StatusCode, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let url = format!("{base_url}{path_and_query}");
|
let url = format!("{base_url}{path_and_query}");
|
||||||
let content_type = body.as_ref().map(|_| "application/json");
|
let content_type = body.as_ref().map(|_| "application/json");
|
||||||
let response =
|
let response = signed_s3_request_with_session_token(
|
||||||
signed_s3_request_with_session_token(method, &url, body, content_type, access_key, secret_key, session_token).await?;
|
method,
|
||||||
|
&url,
|
||||||
|
body,
|
||||||
|
content_type,
|
||||||
|
SigningCredentials {
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
session_token,
|
||||||
|
},
|
||||||
|
&http::HeaderMap::new(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await?;
|
let body = response.text().await?;
|
||||||
Ok((status, body))
|
Ok((status, body))
|
||||||
@@ -1171,6 +1215,9 @@ pub struct RustFSTestClusterEnvironment {
|
|||||||
pub node_extra_env: Vec<Vec<(String, String)>>,
|
pub node_extra_env: Vec<Vec<(String, String)>>,
|
||||||
pub node_capture_log_paths: Vec<Option<String>>,
|
pub node_capture_log_paths: Vec<Option<String>>,
|
||||||
pub topology: ClusterTopology,
|
pub topology: ClusterTopology,
|
||||||
|
/// Optional socket proxies used for the corresponding node's volume
|
||||||
|
/// endpoints. Proxies must be installed before [`Self::start`].
|
||||||
|
volume_proxy_addresses: Vec<Option<SocketAddr>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RustFSTestClusterEnvironment {
|
impl RustFSTestClusterEnvironment {
|
||||||
@@ -1262,6 +1309,7 @@ impl RustFSTestClusterEnvironment {
|
|||||||
extra_env.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
extra_env.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let node_count = topology.node_count;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
nodes,
|
nodes,
|
||||||
temp_dir,
|
temp_dir,
|
||||||
@@ -1271,6 +1319,7 @@ impl RustFSTestClusterEnvironment {
|
|||||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||||
node_capture_log_paths: vec![None; topology.node_count],
|
node_capture_log_paths: vec![None; topology.node_count],
|
||||||
topology,
|
topology,
|
||||||
|
volume_proxy_addresses: vec![None; node_count],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1338,6 +1387,34 @@ impl RustFSTestClusterEnvironment {
|
|||||||
self.build_volumes_arg()
|
self.build_volumes_arg()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start a socket proxy for one node's volume endpoints and route all
|
||||||
|
/// subsequent `RUSTFS_VOLUMES` references for that node through it.
|
||||||
|
///
|
||||||
|
/// Call this before [`Self::start`], then use the returned proxy's
|
||||||
|
/// [`crate::fault_proxy::FaultProxy::set_mode`] to inject latency,
|
||||||
|
/// blackhole, or one-way partition faults. The node's own listen address
|
||||||
|
/// remains direct, so S3 clients can still reach it while peer disk/RPC
|
||||||
|
/// traffic is steered through the proxy.
|
||||||
|
pub async fn start_volume_proxy_for_node(
|
||||||
|
&mut self,
|
||||||
|
node_idx: usize,
|
||||||
|
) -> Result<crate::fault_proxy::FaultProxy, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
self.ensure_node_index(node_idx)?;
|
||||||
|
if self.volume_proxy_addresses[node_idx].is_some() {
|
||||||
|
return Err(format!("a volume proxy is already configured for node {node_idx}").into());
|
||||||
|
}
|
||||||
|
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
|
||||||
|
let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
|
||||||
|
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
|
||||||
|
Ok(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn volume_address(&self, node_idx: usize) -> String {
|
||||||
|
self.volume_proxy_addresses[node_idx]
|
||||||
|
.map(|address| address.to_string())
|
||||||
|
.unwrap_or_else(|| self.nodes[node_idx].address.clone())
|
||||||
|
}
|
||||||
|
|
||||||
fn build_volumes_arg(&self) -> String {
|
fn build_volumes_arg(&self) -> String {
|
||||||
let pools = self.topology.normalized_pools();
|
let pools = self.topology.normalized_pools();
|
||||||
|
|
||||||
@@ -1346,7 +1423,11 @@ impl RustFSTestClusterEnvironment {
|
|||||||
return self
|
return self
|
||||||
.nodes
|
.nodes
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|n| n.data_dirs.iter().map(move |dir| format!("http://{}{}", n.address, dir)))
|
.enumerate()
|
||||||
|
.flat_map(|(node_idx, n)| {
|
||||||
|
let address = self.volume_address(node_idx);
|
||||||
|
n.data_dirs.iter().map(move |dir| format!("http://{}{}", address, dir))
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
@@ -1357,13 +1438,19 @@ impl RustFSTestClusterEnvironment {
|
|||||||
pools
|
pools
|
||||||
.iter()
|
.iter()
|
||||||
.map(|nodes| {
|
.map(|nodes| {
|
||||||
let node = &self.nodes[nodes[0]];
|
let node_idx = nodes[0];
|
||||||
|
let node = &self.nodes[node_idx];
|
||||||
let base = node
|
let base = node
|
||||||
.data_dirs
|
.data_dirs
|
||||||
.first()
|
.first()
|
||||||
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
|
.and_then(|d| d.rsplit_once('/').map(|(parent, _)| parent))
|
||||||
.unwrap_or(&node.data_dir);
|
.unwrap_or(&node.data_dir);
|
||||||
format!("http://{}{}/drive{{0...{}}}", node.address, base, self.topology.drives_per_node - 1)
|
format!(
|
||||||
|
"http://{}{}/drive{{0...{}}}",
|
||||||
|
self.volume_address(node_idx),
|
||||||
|
base,
|
||||||
|
self.topology.drives_per_node - 1
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" ")
|
.join(" ")
|
||||||
@@ -1382,31 +1469,18 @@ impl RustFSTestClusterEnvironment {
|
|||||||
/// times out, or cluster service readiness times out.
|
/// times out, or cluster service readiness times out.
|
||||||
pub async fn start(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn start(&mut self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let binary_path = rustfs_binary_path();
|
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();
|
let volumes_arg = self.build_volumes_arg();
|
||||||
|
|
||||||
for (i, node) in self.nodes.iter_mut().enumerate() {
|
for node_idx in 0..self.nodes.len() {
|
||||||
info!("Starting cluster node {} on {}", i, node.address);
|
self.spawn_node(node_idx, binary_path, &volumes_arg)?;
|
||||||
|
|
||||||
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 (i, node) in self.nodes.iter().enumerate() {
|
for (i, node) in self.nodes.iter().enumerate() {
|
||||||
@@ -1422,20 +1496,46 @@ impl RustFSTestClusterEnvironment {
|
|||||||
|
|
||||||
/// Start one node process using the cluster's existing volume layout.
|
/// 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>> {
|
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)?;
|
self.ensure_node_index(node_idx)?;
|
||||||
if self.nodes[node_idx].process.is_some() {
|
if self.nodes[node_idx].process.is_some() {
|
||||||
return Err(format!("cluster node {node_idx} is already running").into());
|
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 log_path = self.node_capture_log_paths[node_idx].clone();
|
||||||
let node = &mut self.nodes[node_idx];
|
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
|
command
|
||||||
.env("RUSTFS_VOLUMES", &volumes_arg)
|
.env("RUSTFS_VOLUMES", volumes_arg)
|
||||||
.env("RUSTFS_ADDRESS", &node.address)
|
.env("RUSTFS_ADDRESS", &node.address)
|
||||||
.env("RUSTFS_ACCESS_KEY", &self.access_key)
|
.env("RUSTFS_ACCESS_KEY", &self.access_key)
|
||||||
.env("RUSTFS_SECRET_KEY", &self.secret_key)
|
.env("RUSTFS_SECRET_KEY", &self.secret_key)
|
||||||
@@ -1452,9 +1552,6 @@ impl RustFSTestClusterEnvironment {
|
|||||||
|
|
||||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||||
node.process = Some(process);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1602,6 +1699,114 @@ impl RustFSTestClusterEnvironment {
|
|||||||
process.wait()?;
|
process.wait()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append a new single-node erasure pool to a stopped multi-pool cluster.
|
||||||
|
///
|
||||||
|
/// Used to simulate pool expansion on localhost: every pool already owns
|
||||||
|
/// exactly one node with `drives_per_node >= 2` (the only multi-pool layout
|
||||||
|
/// the single-host `RUSTFS_VOLUMES` syntax can express). The new node is
|
||||||
|
/// allocated a fresh port and empty drive directories; callers must
|
||||||
|
/// [`Self::start`] afterwards so every process picks up the extended
|
||||||
|
/// volumes argument. Existing data directories are left untouched.
|
||||||
|
pub async fn append_single_node_pool(&mut self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
if self.nodes.iter().any(|node| node.process.is_some()) {
|
||||||
|
return Err("stop the cluster before appending a pool".into());
|
||||||
|
}
|
||||||
|
if self.topology.drives_per_node < 2 {
|
||||||
|
return Err(
|
||||||
|
"append_single_node_pool requires drives_per_node >= 2 (the server parser rejects a single-drive ellipses pool)"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pools = self.topology.normalized_pools();
|
||||||
|
for (pool_idx, nodes) in pools.iter().enumerate() {
|
||||||
|
if nodes.len() != 1 {
|
||||||
|
return Err(format!(
|
||||||
|
"pool {pool_idx} spans {} nodes; append_single_node_pool requires one node per pool",
|
||||||
|
nodes.len()
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_idx = self.nodes.len();
|
||||||
|
let port = RustFSTestEnvironment::find_available_port().await?;
|
||||||
|
let address = format!("127.0.0.1:{port}");
|
||||||
|
let data_dirs: Vec<String> = (0..self.topology.drives_per_node)
|
||||||
|
.map(|drive| format!("{}/node{}/drive{}", self.temp_dir, new_idx, drive))
|
||||||
|
.collect();
|
||||||
|
for dir in &data_dirs {
|
||||||
|
fs::create_dir_all(dir).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.nodes.push(ClusterNode {
|
||||||
|
url: format!("http://{address}"),
|
||||||
|
address,
|
||||||
|
data_dir: data_dirs[0].clone(),
|
||||||
|
data_dirs,
|
||||||
|
pool_idx: pools.len(),
|
||||||
|
process: None,
|
||||||
|
});
|
||||||
|
pools.push(vec![new_idx]);
|
||||||
|
self.topology.node_count = self.nodes.len();
|
||||||
|
self.topology.pools = pools;
|
||||||
|
self.node_extra_env.push(Vec::new());
|
||||||
|
self.node_capture_log_paths.push(None);
|
||||||
|
self.volume_proxy_addresses.push(None);
|
||||||
|
|
||||||
|
if !self.extra_env.iter().any(|(key, _)| key == "RUSTFS_UNSAFE_BYPASS_DISK_CHECK") {
|
||||||
|
self.extra_env
|
||||||
|
.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(new_idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gracefully stop one cluster node and wait for its process to exit.
|
||||||
|
///
|
||||||
|
/// 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 {
|
impl Drop for RustFSTestClusterEnvironment {
|
||||||
@@ -1744,30 +1949,128 @@ pub(crate) async fn admin_create_user(
|
|||||||
username: &str,
|
username: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, username);
|
admin_create_user_via(AdminTransport::Signed, &env.url, &env.access_key, &env.secret_key, username, secret_key).await
|
||||||
let body = serde_json::json!({
|
}
|
||||||
"secretKey": secret_key,
|
|
||||||
"status": "enabled"
|
|
||||||
});
|
|
||||||
let response = signed_request(
|
|
||||||
http::Method::PUT,
|
|
||||||
&url,
|
|
||||||
&env.access_key,
|
|
||||||
&env.secret_key,
|
|
||||||
Some(body.to_string().into_bytes()),
|
|
||||||
Some("application/json"),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if response.status() != reqwest::StatusCode::OK {
|
/// Transport used by the shared admin-API helpers: in-process SigV4 signing
|
||||||
let status = response.status();
|
/// via [`signed_request`], or the external `awscurl` binary (an independent
|
||||||
let body = response.text().await.unwrap_or_default();
|
/// SigV4 implementation exercised by the awscurl-gated suites).
|
||||||
return Err(format!("create user failed: {status} {body}").into());
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum AdminTransport {
|
||||||
|
Signed,
|
||||||
|
Awscurl,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute an admin-API request against `base_url` with admin credentials over
|
||||||
|
/// the chosen transport, failing on any non-success response.
|
||||||
|
pub(crate) async fn admin_execute_at(
|
||||||
|
transport: AdminTransport,
|
||||||
|
method: http::Method,
|
||||||
|
base_url: &str,
|
||||||
|
admin_access_key: &str,
|
||||||
|
admin_secret_key: &str,
|
||||||
|
path_and_query: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let url = format!("{base_url}{path_and_query}");
|
||||||
|
match transport {
|
||||||
|
AdminTransport::Signed => {
|
||||||
|
let content_type = match body {
|
||||||
|
Some(body) if !body.is_empty() => Some("application/json"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let response = signed_request(
|
||||||
|
method.clone(),
|
||||||
|
&url,
|
||||||
|
admin_access_key,
|
||||||
|
admin_secret_key,
|
||||||
|
body.map(|body| body.as_bytes().to_vec()),
|
||||||
|
content_type,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let text = response.text().await.unwrap_or_default();
|
||||||
|
return Err(format!("{method} {path_and_query} failed: {status} {text}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AdminTransport::Awscurl => {
|
||||||
|
execute_awscurl(&url, method.as_str(), body, admin_access_key, admin_secret_key).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new IAM user via the admin API over the chosen transport.
|
||||||
|
pub(crate) async fn admin_create_user_via(
|
||||||
|
transport: AdminTransport,
|
||||||
|
base_url: &str,
|
||||||
|
admin_access_key: &str,
|
||||||
|
admin_secret_key: &str,
|
||||||
|
username: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let path = format!("/rustfs/admin/v3/add-user?accessKey={username}");
|
||||||
|
let body = serde_json::json!({"secretKey": secret_key, "status": "enabled"}).to_string();
|
||||||
|
admin_execute_at(
|
||||||
|
transport,
|
||||||
|
http::Method::PUT,
|
||||||
|
base_url,
|
||||||
|
admin_access_key,
|
||||||
|
admin_secret_key,
|
||||||
|
&path,
|
||||||
|
Some(&body),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a canned policy via the admin API over the chosen transport.
|
||||||
|
pub(crate) async fn admin_add_canned_policy_via(
|
||||||
|
transport: AdminTransport,
|
||||||
|
base_url: &str,
|
||||||
|
admin_access_key: &str,
|
||||||
|
admin_secret_key: &str,
|
||||||
|
policy_name: &str,
|
||||||
|
policy_json: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let path = format!("/rustfs/admin/v3/add-canned-policy?name={policy_name}");
|
||||||
|
admin_execute_at(
|
||||||
|
transport,
|
||||||
|
http::Method::PUT,
|
||||||
|
base_url,
|
||||||
|
admin_access_key,
|
||||||
|
admin_secret_key,
|
||||||
|
&path,
|
||||||
|
Some(policy_json),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attach a canned policy to a user via the admin API over the chosen transport.
|
||||||
|
pub(crate) async fn admin_attach_user_policy_via(
|
||||||
|
transport: AdminTransport,
|
||||||
|
base_url: &str,
|
||||||
|
admin_access_key: &str,
|
||||||
|
admin_secret_key: &str,
|
||||||
|
policy_name: &str,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let path = format!("/rustfs/admin/v3/set-user-or-group-policy?policyName={policy_name}&userOrGroup={username}&isGroup=false");
|
||||||
|
// `Some("")` preserves the historical wire shape on both transports: awscurl
|
||||||
|
// keeps sending `-d ''` and the signed path attaches an empty body with no
|
||||||
|
// content type.
|
||||||
|
admin_execute_at(
|
||||||
|
transport,
|
||||||
|
http::Method::PUT,
|
||||||
|
base_url,
|
||||||
|
admin_access_key,
|
||||||
|
admin_secret_key,
|
||||||
|
&path,
|
||||||
|
Some(""),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1859,7 +2162,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let multidrive = topology.drives_per_node > 1;
|
let multidrive = topology.drives_per_node > 1;
|
||||||
|
|
||||||
let nodes = (0..topology.node_count)
|
let nodes: Vec<ClusterNode> = (0..topology.node_count)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let address = format!("127.0.0.1:{}", 9000 + i);
|
let address = format!("127.0.0.1:{}", 9000 + i);
|
||||||
let data_dirs: Vec<String> = if multidrive {
|
let data_dirs: Vec<String> = if multidrive {
|
||||||
@@ -1880,6 +2183,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let node_count = nodes.len();
|
||||||
RustFSTestClusterEnvironment {
|
RustFSTestClusterEnvironment {
|
||||||
nodes,
|
nodes,
|
||||||
temp_dir,
|
temp_dir,
|
||||||
@@ -1889,6 +2193,7 @@ mod tests {
|
|||||||
node_extra_env: vec![Vec::new(); topology.node_count],
|
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||||
node_capture_log_paths: vec![None; topology.node_count],
|
node_capture_log_paths: vec![None; topology.node_count],
|
||||||
topology,
|
topology,
|
||||||
|
volume_proxy_addresses: vec![None; node_count],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1973,6 +2278,25 @@ mod tests {
|
|||||||
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
|
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn volume_proxy_rewrites_cluster_volume_endpoint() {
|
||||||
|
let mut env = RustFSTestClusterEnvironment::new(1)
|
||||||
|
.await
|
||||||
|
.expect("cluster environment should allocate a node");
|
||||||
|
let direct = env.nodes[0].address.clone();
|
||||||
|
let proxy = env
|
||||||
|
.start_volume_proxy_for_node(0)
|
||||||
|
.await
|
||||||
|
.expect("volume proxy should bind before the target server starts");
|
||||||
|
let proxied = proxy.local_addr().to_string();
|
||||||
|
let volumes = env.rustfs_volumes_arg();
|
||||||
|
|
||||||
|
assert!(volumes.contains(&proxied), "volumes must use the proxy address: {volumes}");
|
||||||
|
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
|
||||||
|
|
||||||
|
proxy.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn cluster_node_env_supports_per_node_overrides() {
|
fn cluster_node_env_supports_per_node_overrides() {
|
||||||
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||||
|
|||||||
@@ -35,11 +35,15 @@ where
|
|||||||
{
|
{
|
||||||
let mut last_usage = DataUsageInfo::default();
|
let mut last_usage = DataUsageInfo::default();
|
||||||
let mut last_query_error = None;
|
let mut last_query_error = None;
|
||||||
for _ in 0..45 {
|
for _ in 0..90 {
|
||||||
match get_data_usage_info(env).await {
|
match get_data_usage_info(env).await {
|
||||||
Ok(usage) => {
|
Ok(usage) => {
|
||||||
last_query_error = None;
|
last_query_error = None;
|
||||||
if usage.buckets_usage.contains_key(bucket) && predicate(&usage) {
|
if usage.is_complete_bucket_usage_snapshot()
|
||||||
|
&& usage.usage_snapshot_converged != Some(false)
|
||||||
|
&& usage.buckets_usage.contains_key(bucket)
|
||||||
|
&& predicate(&usage)
|
||||||
|
{
|
||||||
return Ok(usage);
|
return Ok(usage);
|
||||||
}
|
}
|
||||||
last_usage = usage;
|
last_usage = usage;
|
||||||
|
|||||||
@@ -0,0 +1,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(©_key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_object_bytes(writer, &bucket, ©_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
@@ -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(())
|
||||||
|
}
|
||||||
@@ -16,37 +16,29 @@
|
|||||||
//! session policy** (`Policy` parameter) via `awscurl --service sts` with explicit
|
//! session policy** (`Policy` parameter) via `awscurl --service sts` with explicit
|
||||||
//! `Content-Type: application/x-www-form-urlencoded` on `POST /`.
|
//! `Content-Type: application/x-www-form-urlencoded` on `POST /`.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, awscurl_delete, awscurl_post_sts_form_urlencoded, awscurl_put, init_logging};
|
use crate::common::{
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
AdminTransport, RustFSTestEnvironment, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via,
|
||||||
|
awscurl_delete, awscurl_post_sts_form_urlencoded, build_test_s3_config, init_logging,
|
||||||
|
};
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{Delete, ObjectIdentifier, Tag, Tagging};
|
use aws_sdk_s3::types::{Delete, ObjectIdentifier, Tag, Tagging};
|
||||||
use aws_sdk_s3::{Client, Config};
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
fn user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
|
fn user_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
|
||||||
let credentials = Credentials::new(access_key, secret_key, None, None, "e2e-existing-tag");
|
env.create_s3_client_with_credentials(access_key, secret_key)
|
||||||
let config = Config::builder()
|
|
||||||
.credentials_provider(credentials)
|
|
||||||
.region(Region::new("us-east-1"))
|
|
||||||
.endpoint_url(&env.url)
|
|
||||||
.force_path_style(true)
|
|
||||||
.behavior_version_latest()
|
|
||||||
.build();
|
|
||||||
Client::from_conf(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sts_session_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str, session_token: &str) -> Client {
|
fn sts_session_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str, session_token: &str) -> Client {
|
||||||
let credentials = Credentials::new(access_key, secret_key, Some(session_token.into()), None, "e2e-sts-session");
|
Client::from_conf(build_test_s3_config(
|
||||||
let config = Config::builder()
|
&env.url,
|
||||||
.credentials_provider(credentials)
|
access_key,
|
||||||
.region(Region::new("us-east-1"))
|
secret_key,
|
||||||
.endpoint_url(&env.url)
|
Some(session_token),
|
||||||
.force_path_style(true)
|
"e2e-sts-session",
|
||||||
.behavior_version_latest()
|
))
|
||||||
.build();
|
|
||||||
Client::from_conf(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
|
fn extract_xml_tag(xml: &str, tag: &str) -> Option<String> {
|
||||||
@@ -77,15 +69,16 @@ async fn assume_role_with_session_policy(
|
|||||||
parse_assume_role_credentials(&xml)
|
parse_assume_role_credentials(&xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This suite deliberately drives the admin API through the external `awscurl`
|
||||||
|
// binary (an independent SigV4 implementation), so the wrappers below pin
|
||||||
|
// `AdminTransport::Awscurl`.
|
||||||
|
|
||||||
async fn admin_create_user(
|
async fn admin_create_user(
|
||||||
env: &RustFSTestEnvironment,
|
env: &RustFSTestEnvironment,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let body = serde_json::json!({ "secretKey": password, "status": "enabled" }).to_string();
|
admin_create_user_via(AdminTransport::Awscurl, &env.url, &env.access_key, &env.secret_key, username, password).await
|
||||||
let url = format!("{}/rustfs/admin/v3/add-user?accessKey={}", env.url, username);
|
|
||||||
awscurl_put(&url, &body, &env.access_key, &env.secret_key).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_add_canned_policy(
|
async fn admin_add_canned_policy(
|
||||||
@@ -93,9 +86,15 @@ async fn admin_add_canned_policy(
|
|||||||
policy_name: &str,
|
policy_name: &str,
|
||||||
policy_json: &str,
|
policy_json: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let url = format!("{}/rustfs/admin/v3/add-canned-policy?name={}", env.url, policy_name);
|
admin_add_canned_policy_via(
|
||||||
awscurl_put(&url, policy_json, &env.access_key, &env.secret_key).await?;
|
AdminTransport::Awscurl,
|
||||||
Ok(())
|
&env.url,
|
||||||
|
&env.access_key,
|
||||||
|
&env.secret_key,
|
||||||
|
policy_name,
|
||||||
|
policy_json,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_attach_policy_to_user(
|
async fn admin_attach_policy_to_user(
|
||||||
@@ -103,12 +102,7 @@ async fn admin_attach_policy_to_user(
|
|||||||
policy_name: &str,
|
policy_name: &str,
|
||||||
username: &str,
|
username: &str,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let url = format!(
|
admin_attach_user_policy_via(AdminTransport::Awscurl, &env.url, &env.access_key, &env.secret_key, policy_name, username).await
|
||||||
"{}/rustfs/admin/v3/set-user-or-group-policy?policyName={}&userOrGroup={}&isGroup=false",
|
|
||||||
env.url, policy_name, username
|
|
||||||
);
|
|
||||||
awscurl_put(&url, "", &env.access_key, &env.secret_key).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_remove_user(env: &RustFSTestEnvironment, username: &str) {
|
async fn admin_remove_user(env: &RustFSTestEnvironment, username: &str) {
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
# Programmable fake S3 target
|
# 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.
|
`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
@@ -15,20 +15,11 @@
|
|||||||
//! E2E tests for group management (fixes #2028).
|
//! E2E tests for group management (fixes #2028).
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, admin_ok, admin_request, init_logging};
|
use crate::common::{RustFSTestEnvironment, admin_ok, admin_request, init_logging};
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::{Client, Config};
|
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
fn create_user_s3_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
|
fn create_user_s3_client(env: &RustFSTestEnvironment, access_key: &str, secret_key: &str) -> Client {
|
||||||
let credentials = Credentials::new(access_key, secret_key, None, None, "e2e-group-test");
|
env.create_s3_client_with_credentials(access_key, secret_key)
|
||||||
let config = Config::builder()
|
|
||||||
.credentials_provider(credentials)
|
|
||||||
.region(Region::new("us-east-1"))
|
|
||||||
.endpoint_url(&env.url)
|
|
||||||
.force_path_style(true)
|
|
||||||
.behavior_version_latest()
|
|
||||||
.build();
|
|
||||||
Client::from_conf(config)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user