mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-01 11:02:14 +00:00
Compare commits
476 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7805caa58 | |||
| b0bb0bbd3a | |||
| bc41e567a5 | |||
| d5c6ba99d5 | |||
| 62d44d10b8 | |||
| 8218248000 | |||
| fd36bdfb1a | |||
| 322ce21b9a | |||
| 35e4415ed9 | |||
| 4e34f97dd7 | |||
| 782c78e0ef | |||
| 8387528c9b | |||
| 4ce0e280f2 | |||
| 793c193a6b | |||
| 5a6e850c67 | |||
| 2d8ad5caee | |||
| 3d4f4bb86d | |||
| 2b31bda6d1 | |||
| 68e344bf03 | |||
| 48c2fcb62b | |||
| 428fde069d | |||
| 364168c0ba | |||
| 3f4f31129e | |||
| 6c99d4fe22 | |||
| f5348d5cc4 | |||
| e2b2bdcc34 | |||
| b965bd6eef | |||
| 1524ed891f | |||
| 7354a5663d | |||
| 790bdc0e63 | |||
| 707d062174 | |||
| 4b6b6f14bd | |||
| 9080ea8ea0 | |||
| 739efaaea1 | |||
| b6d4689c75 | |||
| 8763cd0c67 | |||
| fdac60b0e2 | |||
| 98b20b4231 | |||
| ffc9de72cb | |||
| c09d11ff3b | |||
| 792f2ef204 | |||
| 74019845c4 | |||
| 04c5921850 | |||
| db8039dece | |||
| 40eee6177a | |||
| 76b3c085b5 | |||
| 78d6918c52 | |||
| dd11145a26 | |||
| f718e72e24 | |||
| e06c9c02c6 | |||
| a2fe5d7d88 | |||
| cad8246ffb | |||
| b4901abd17 | |||
| 1d383e239d | |||
| 2e29c330a9 | |||
| 3921336b23 | |||
| 342ee1df78 | |||
| 40ef0db9cc | |||
| b457c6abcc | |||
| 7051a5ce41 | |||
| 1d3ba1eb8b | |||
| 8368017fb2 | |||
| 699ef14ddd | |||
| 704ea43da5 | |||
| 35a20622f1 | |||
| 7662b2436a | |||
| d4f2efa2ad | |||
| 19cdd806a2 | |||
| 6e5f330ff5 | |||
| e86d4cb579 | |||
| e08847d2b6 | |||
| 145d38133b | |||
| 30dc04c94b | |||
| ad7663afd1 | |||
| 6e6b38ad8e | |||
| 8601179c39 | |||
| b83c9c4663 | |||
| 67904a6c18 | |||
| 2e5cef513f | |||
| 2d4f77fd3b | |||
| 920705417c | |||
| b097c94c59 | |||
| 3991a1d73c | |||
| 422e0ad768 | |||
| 3a6212f597 | |||
| ba964c82c7 | |||
| d77439929c | |||
| abc5f2e818 | |||
| 719c0d6ef0 | |||
| 83f3a7320d | |||
| 2ed28f9c5f | |||
| 0247c48ce0 | |||
| 88fa3877c1 | |||
| 2dea4a9acf | |||
| 94ee597721 | |||
| 962c11e6db | |||
| ae11bcf2be | |||
| 09157485aa | |||
| e2257325a2 | |||
| c9397405ed | |||
| 55be5af661 | |||
| 3f20fbd77b | |||
| 3c0d315a9c | |||
| 83f21eaa64 | |||
| ec25d09495 | |||
| cc24ef173c | |||
| c195b18fb8 | |||
| d670023341 | |||
| 91597db9d2 | |||
| 225918f30e | |||
| f7c1b13c0f | |||
| f329d330df | |||
| e154e0e4a2 | |||
| f235e81755 | |||
| d42bc52f8b | |||
| d48870df97 | |||
| 453e3d0faa | |||
| b65210b1db | |||
| a7f035a8c3 | |||
| 87d97a5f48 | |||
| d9efd6b853 | |||
| 2801b2500d | |||
| 02f4dbeb68 | |||
| f7757e6437 | |||
| 1cb1b02b08 | |||
| 3fe74a5019 | |||
| 451cbc099b | |||
| cb62079ba6 | |||
| d39ffdb1cd | |||
| 7d698abc1f | |||
| a1a65ad65d | |||
| 358af6a8de | |||
| 90d1a15d13 | |||
| 2423ba8e3f | |||
| 294c79c156 | |||
| 3d80578abd | |||
| 957080bea5 | |||
| c1538cf1c3 | |||
| 5af56cbb02 | |||
| f99956eade | |||
| 775279b6fd | |||
| eb755e2b97 | |||
| 7f146fc5de | |||
| 5426237a49 | |||
| d7afa4e38e | |||
| a3f5a8eaf9 | |||
| 547c678eed | |||
| df945b275a | |||
| 2e78a49c95 | |||
| 7ad0e726db | |||
| 45c3386b68 | |||
| 7af92b4f54 | |||
| daa627ee0e | |||
| 5c3d3a8220 | |||
| 6bc5fc77b5 | |||
| e822fc1552 | |||
| 2f6115e058 | |||
| 882e1a71b1 | |||
| b432f31c2c | |||
| 6e0640444e | |||
| 4fb9b0dc7f | |||
| 2216f00cfd | |||
| fd2a87d47e | |||
| 1e10d752b9 | |||
| 362f6026ac | |||
| 5cedab09ab | |||
| d385cea7c6 | |||
| d5df66ac4f | |||
| 5a768d3a44 | |||
| 6d3ce90c0f | |||
| 0e42a3d1d9 | |||
| c3aac2279f | |||
| 300beff970 | |||
| 0a2370c024 | |||
| 0c9d721910 | |||
| f6c227628f | |||
| 0cbfa1ac90 | |||
| ab5aa54035 | |||
| 464bf45e15 | |||
| bc2d8e0c60 | |||
| 24cf2cdb78 | |||
| e076e8cc6e | |||
| 9d84056d7b | |||
| f566b382a0 | |||
| a909f2f27b | |||
| 5af997ce5f | |||
| 4bd8cc1369 | |||
| 3500f2e5ee | |||
| e0e2eb30a9 | |||
| 467fb0a15c | |||
| 0902538ceb | |||
| fec09968b9 | |||
| 4b09239ceb | |||
| cc3c39da5c | |||
| 07f6d5cda6 | |||
| e79337bb5c | |||
| 683ff52a7b | |||
| 63e57378d6 | |||
| b2a376c2d2 | |||
| 887868e7cd | |||
| 0ea7f17fd7 | |||
| 5532510e42 | |||
| fc6dfa891a | |||
| 994678cfcf | |||
| cee5009c57 | |||
| bb130e2655 | |||
| 0711a6f4fe | |||
| 0a25d25e68 | |||
| 009dd93788 | |||
| 7cacd1f558 | |||
| c3242f83ba | |||
| e0bd18bd50 | |||
| 7f0c42e2bb | |||
| 09a991e735 | |||
| 14c249c266 | |||
| 5acc52b7f9 | |||
| 26663e0d5d | |||
| caeaa4bf34 | |||
| 05f52beea3 | |||
| 25cf7922f5 | |||
| e6706bb94a | |||
| a609b92b3c | |||
| 4e353fb3c8 | |||
| 058f81c61f | |||
| b4e3c7117e | |||
| 23f4683f20 | |||
| a539b33583 | |||
| 4e63ee962f | |||
| 12b7f22fca | |||
| a047bbfcfb | |||
| 556f8ed62f | |||
| 02ad75e552 | |||
| 21c85481b8 | |||
| d5409845e2 | |||
| 7667b7aaf8 | |||
| 29b4a98e74 | |||
| fa7499ce1c | |||
| 1397a4e7ca | |||
| afaea2a3ec | |||
| 78dd2d40d3 | |||
| 342f9f94bc | |||
| d887e7e31d | |||
| 0eb9dd5bdc | |||
| 50c4dcca4f | |||
| bdf3f0484d | |||
| e897b2d7bb | |||
| 92f72c3912 | |||
| 6fa2d06731 | |||
| 18ff36c22d | |||
| 03af8e472b | |||
| 42fc840630 | |||
| 3fe935982f | |||
| 6d8e196f36 | |||
| 5c99ca1328 | |||
| 44d2c3bd34 | |||
| 7f19e9a465 | |||
| 92f83bfe15 | |||
| 21787a4742 | |||
| d874831cec | |||
| 6da69180d8 | |||
| 258b7d6f06 | |||
| 05886a2c3c | |||
| 99e1f5fbd2 | |||
| c984bc7251 | |||
| 233865d172 | |||
| c5eb1c69ba | |||
| 77b5e1b64c | |||
| 23a5db4012 | |||
| 516f7fecc1 | |||
| 1e95e6d311 | |||
| 3cbe3d6b94 | |||
| 61d4e04d65 | |||
| 6974963e20 | |||
| 7ab0955f8b | |||
| 2cf5fd6bfc | |||
| de2c337fae | |||
| 5988606e68 | |||
| e73ed4f2a1 | |||
| ffde6c43ee | |||
| f52dde87d1 | |||
| 8e83087ba4 | |||
| a63b79004c | |||
| 0364523dad | |||
| 2dc4d0b651 | |||
| 2ee111ad8b | |||
| 9ddb30139d | |||
| ffd1b94e1f | |||
| ce41adfa9b | |||
| 59361ed786 | |||
| dfb0a20048 | |||
| 7320d7fab2 | |||
| 28d19db9fc | |||
| e257573962 | |||
| 45b675c641 | |||
| 05caec0bd5 | |||
| eaa17e0441 | |||
| 2b6cc0ee08 | |||
| 938f7296f9 | |||
| 866ac5073d | |||
| 187a060919 | |||
| cda443bd81 | |||
| 44b1916103 | |||
| 9d1b10144f | |||
| d7f30fe0a2 | |||
| 20c4ea864a | |||
| d1c2c42c90 | |||
| fc43b149c5 | |||
| fa235e9018 | |||
| dd46de0945 | |||
| bf6f0e5e81 | |||
| beb807ae2b | |||
| d8426dc459 | |||
| f46ea6e14f | |||
| fa26b6730d | |||
| 7876319811 | |||
| ea417b6a32 | |||
| 4963412265 | |||
| 8ac618e6c2 | |||
| 1c88aa43c1 | |||
| a5a73610b6 | |||
| 9eaf5fc8e3 | |||
| 3132637294 | |||
| 358caa23cb | |||
| 6765aca3f9 | |||
| 4133fbe0fc | |||
| 6f6d8a4d3e | |||
| cb344a3c77 | |||
| 36e97aba26 | |||
| a2fc6e15df | |||
| 0269c47bc6 | |||
| d26adc29ca | |||
| 5131ba8271 | |||
| 14f31b797a | |||
| 9f61bad94f | |||
| 8e214104f3 | |||
| 6bab9e421b | |||
| d9e0a25174 | |||
| 1c8088d0b2 | |||
| ffcdab900a | |||
| b94bf874bf | |||
| 7d96ffd7fb | |||
| 027a749646 | |||
| 6c23b8506e | |||
| 8166561702 | |||
| 9866f68d86 | |||
| 0e8f1a187c | |||
| b32bd1f8a9 | |||
| c9848a6096 | |||
| 6e88ab2a25 | |||
| 05d4480f08 | |||
| abee09dad9 | |||
| a044d11443 | |||
| e1e6a8b020 | |||
| 5cfe4ccc7d | |||
| df2db15ce8 | |||
| 0321e9350d | |||
| cd9a2eecb1 | |||
| 8b09634e62 | |||
| 1ede77b1c1 | |||
| 666e251b78 | |||
| 65ba138c27 | |||
| 92ae19b340 | |||
| 4607c3be53 | |||
| e0bac66941 | |||
| 31dc78eab0 | |||
| daca7294c7 | |||
| 1655f3192e | |||
| 0adb3c5ea1 | |||
| 68e156a5c5 | |||
| 3f60cc743e | |||
| 35af4a611f | |||
| a8e7cce5e1 | |||
| 9469dfa5b8 | |||
| f1d2af698c | |||
| d5f8c6c044 | |||
| f5303bad95 | |||
| cb0d4ffa76 | |||
| 5b61b030a4 | |||
| 0fbb5ba87b | |||
| f6e8ce4639 | |||
| 937b311316 | |||
| 62c2f81afd | |||
| eed1e97967 | |||
| 97b618bc2b | |||
| 7805cf5ae6 | |||
| bb7bba3237 | |||
| 17f0bd2637 | |||
| 7f569b67cb | |||
| d13345dc65 | |||
| 79d745413e | |||
| 9f25858b05 | |||
| a4e7dd70a6 | |||
| d6d22afc6e | |||
| 75381d4ffe | |||
| f32597bdb0 | |||
| 1fac7a5871 | |||
| 0e2e01d060 | |||
| 4f133eb95f | |||
| 302dd42d38 | |||
| 48b2f3d6e3 | |||
| 376b90f61f | |||
| b6838b262f | |||
| 28fdcc87be | |||
| 35f3599992 | |||
| b44e82fef1 | |||
| 7ddaae397b | |||
| 9e4c5e949f | |||
| 7cc211ae17 | |||
| a27fe2f56c | |||
| bd978bed2d | |||
| fe67af3524 | |||
| 26573622bc | |||
| eeafc355d4 | |||
| 955577b66f | |||
| 1cff6f20c9 | |||
| 2abfdd8261 | |||
| 908ca548bb | |||
| c92e99ba95 | |||
| a774bc07da | |||
| 69f543568b | |||
| 2269896f5e | |||
| 67c4e3e60e | |||
| 4f0be83ea5 | |||
| db3b08b612 | |||
| 998c3f561c | |||
| f42fc54362 | |||
| 8ebedddfa1 | |||
| a73f4c345f | |||
| ebc0aa0365 | |||
| aec2ee9ec1 | |||
| 4290f390dd | |||
| 056ebcee38 | |||
| 18f0c161dd | |||
| 1ac0841f6f | |||
| 133499c2d5 | |||
| b0c6c4cbce | |||
| 21049401fa | |||
| 83d73b34f3 | |||
| f9e8440a04 | |||
| 7f5873dac8 | |||
| 3ed682be42 | |||
| 15f4e75870 | |||
| 4faea7fcbc | |||
| 9b197fc1c2 | |||
| 53728a03d3 | |||
| 04bfd48eb1 | |||
| 2c113542f8 | |||
| 825bf0e2d8 | |||
| 0346108ae4 | |||
| 79509aad2d | |||
| d7d880b37d | |||
| cf9e9c6fd5 | |||
| 361334ab08 | |||
| 889a45ad4d | |||
| 5ec124bf23 | |||
| 906805568b | |||
| 230e5fc31a | |||
| 40c089f31b | |||
| 569fa3ec87 | |||
| edfb3c134f | |||
| 314c17205e | |||
| 814682d6bb | |||
| 87128682b0 | |||
| 4589148f48 | |||
| accd312465 | |||
| 627c396649 | |||
| c818177b54 | |||
| ec47c20ced | |||
| 1e14c05cf0 | |||
| 7b2cc1f427 | |||
| 97edb2e5cf | |||
| e1fc4b12ea | |||
| e279a4f48a | |||
| 8ace340694 | |||
| 701c3eee5b | |||
| a9e3613cfd |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: adversarial-validation
|
name: adversarial-validation
|
||||||
description: Execute the Adversarial Validation policy from the root AGENTS.md — run the six reviewer roles (correctness, security, concurrency/durability, compatibility, performance, test coverage) with RustFS-specific attack probes. Use on every behavior-affecting code change, bug fix, or design proposal before declaring it done.
|
description: Execute the Adversarial Validation policy from the root AGENTS.md — run the seven reviewer roles (correctness, simplicity, security, concurrency/durability, compatibility, performance, test coverage) with RustFS-specific attack probes. Use on every behavior-affecting code change, bug fix, or design proposal before declaring it done.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Adversarial Validation Playbooks
|
# Adversarial Validation Playbooks
|
||||||
@@ -53,14 +53,22 @@ shipped bug or rule that earns each probe its place.
|
|||||||
- Exercise the zero/empty end of every new size or count parameter: zero-length object PUT then GET (body must be empty, not error), part count 0, empty Vec of disks/entries into aggregation functions, and env/config values of 0 (must clamp or reject, never divide-by-zero or 'scan nothing and report zero usage'). Anywhere the diff computes a ratio, capacity, or progress percentage, plug in 0 and the max value.
|
- Exercise the zero/empty end of every new size or count parameter: zero-length object PUT then GET (body must be empty, not error), part count 0, empty Vec of disks/entries into aggregation functions, and env/config values of 0 (must clamp or reject, never divide-by-zero or 'scan nothing and report zero usage'). Anywhere the diff computes a ratio, capacity, or progress percentage, plug in 0 and the max value.
|
||||||
- Where: crates/ecstore aggregation and scanner paths; crates/object-capacity; config/env parsing in touched crates
|
- Where: crates/ecstore aggregation and scanner paths; crates/object-capacity; config/env parsing in touched crates
|
||||||
- Evidence: Commits 787cc77a7 'clamp zero capacity env values to safe defaults' (#4559) and 32b1094ec 'resolve a symlinked scan root instead of silently counting zero' (#4564) — zero-as-silent-wrong-answer is a recurring repo bug class.
|
- Evidence: Commits 787cc77a7 'clamp zero capacity env values to safe defaults' (#4559) and 32b1094ec 'resolve a symlinked scan root instead of silently counting zero' (#4564) — zero-as-silent-wrong-answer is a recurring repo bug class.
|
||||||
- Smaller-diff attack: rewrite the diff's change mentally (or actually, in scratch) as the minimal in-place edit and compare. Flag as findings: a helper function with exactly one caller introduced by this diff; a file rewrite where a 3-line edit inside the existing control flow suffices; reshaped control flow in init/locking/metadata/quorum paths beyond what the fix requires; new string literals duplicating existing constants (grep the token first); #[path] module inclusion. If the smaller diff achieves identical behavior, report it with the concrete replacement.
|
|
||||||
- Where: Any diff; extra scrutiny for crates/ecstore, crates/lock, rustfs/src/storage where 'preserve the existing control-flow shape' is an explicit rule
|
|
||||||
- Evidence: AGENTS.md 'Change Style for Existing Logic' (one-off helper ban, preserve control-flow shape in distributed/locking/metadata paths, no #[path]) and 'Constant and String Usage'; Adversarial Validation section names the smaller-diff clause as a correctness-adversary finding.
|
|
||||||
- For any diff touching multipart or object commit paths, order the operations on paper and attack the failure point between them: kill the process (or return Err) after the commit rename but before cleanup, and after cleanup but before commit. Verify the earlier-failure case leaves the object readable and the later-failure case leaves no half-visible object; part meta files must never be deleted before the commit is durable.
|
- For any diff touching multipart or object commit paths, order the operations on paper and attack the failure point between them: kill the process (or return Err) after the commit rename but before cleanup, and after cleanup but before commit. Verify the earlier-failure case leaves the object readable and the later-failure case leaves no half-visible object; part meta files must never be deleted before the commit is durable.
|
||||||
- Where: crates/ecstore multipart commit/cleanup (set_disk/ops); rustfs/src/storage multipart handlers
|
- Where: crates/ecstore multipart commit/cleanup (set_disk/ops); rustfs/src/storage multipart handlers
|
||||||
- Evidence: Commit c77c5f047 'defer multipart part.N.meta cleanup until after commit' (#4548) — cleanup-before-commit ordering already caused a real data-loss window; the #4221 durability work shows fsync/ordering bugs are endemic here.
|
- Evidence: Commit c77c5f047 'defer multipart part.N.meta cleanup until after commit' (#4548) — cleanup-before-commit ordering already caused a real data-loss window; the #4221 durability work shows fsync/ordering bugs are endemic here.
|
||||||
|
|
||||||
Null report example: "Attacked quorum-1 error reduction, exact max-keys listing boundary, trailing-slash dir keys, nil-UUID tier versionId, mid-stream reconstruct error propagation, and a minimal-diff rewrite — no break found; diff is already the minimal in-place edit."
|
Null report example: "Attacked quorum-1 error reduction, exact max-keys listing boundary, trailing-slash dir keys, nil-UUID tier versionId, and mid-stream reconstruct error propagation — no break found."
|
||||||
|
|
||||||
|
### Simplicity adversary
|
||||||
|
|
||||||
|
- Smaller-diff attack: rewrite the diff's change mentally (or actually, in scratch) as the minimal in-place edit and compare. Flag as findings: a helper function with exactly one caller introduced by this diff; a file rewrite where a 3-line edit inside the existing control flow suffices; reshaped control flow in init/locking/metadata/quorum paths beyond what the fix requires; new string literals duplicating existing constants (grep the token first); #[path] module inclusion. If the smaller diff achieves identical behavior, report it with the concrete replacement.
|
||||||
|
- Where: Any diff; extra scrutiny for crates/ecstore, crates/lock, rustfs/src/storage where 'preserve the existing control-flow shape' is an explicit rule
|
||||||
|
- Evidence: AGENTS.md 'Change Style for Existing Logic' (one-off helper ban, preserve control-flow shape in distributed/locking/metadata paths, no #[path]) and 'Reuse Before You Write' (constants clause); the Adversarial Validation roles list charters the simplicity adversary with exactly this attack.
|
||||||
|
- Reuse-and-necessity attack: for each new helper the diff introduces, run `ls crates/utils/src crates/common/src` and `rg -i 'fn \w*<term>'` over those dirs plus the touched crate (snake_case signatures — a full-text single-word grep drowns, a multi-word phrase returns nothing). A reimplementation of an existing workspace utility, or of plain std/tokio behavior no wrapper refines, is a finding — but so is forced reuse with mismatched semantics (normalization such as `clean` resolving `.`/`..` against raw S3 keys, error type, backoff, durability gating). For each new defensive branch, demand the nameable trigger and flag re-validation of what a validated upstream layer on the SAME path already guarantees — excluding the Cross-Cutting Domain Invariant patterns (nil/empty/absent UUID, dual metadata keys, unversioned-tier versionId) and re-checks before destructive actions, which are load-bearing even when redundant on the happy path. For each new test, flag near-duplicates pinning the same code path AND poison-value class as an existing test — boundary companions (n==max vs max+1, absent vs empty vs nil UUID, MetaObject vs MetaDeleteMarker) are never near-duplicates; the test-coverage skeptic playbook below mandates them.
|
||||||
|
- Where: Any diff adding helpers, branches on decoded/peer data, or tests; helper checks against crates/utils, crates/common, and the touched crate
|
||||||
|
- Evidence: AGENTS.md 'Reuse Before You Write' and 'Necessary Code Only'; GHSA-f4vq-9ffr-m8m3 (normalization-asymmetry traversal — why forced reuse of normalizing helpers on raw keys is itself an attack); docs/operations/tier-ilm-debugging.md nil-versionId incident (why boundary re-checks are load-bearing).
|
||||||
|
|
||||||
|
Null report example: "Rewrote the diff as an in-place edit (no smaller equivalent exists), grepped both new helpers against crates/utils, crates/common, and the touched crate (no existing equivalent; call-site semantics checked), verified the two new defensive branches name concrete corrupt-input triggers, and checked the added tests against the existing suite (each pins a distinct poison-value class) — no break found."
|
||||||
|
|
||||||
### Security reviewer
|
### Security reviewer
|
||||||
|
|
||||||
@@ -76,6 +84,9 @@ Null report example: "Attacked quorum-1 error reduction, exact max-keys listing
|
|||||||
- For any secret/token/signature/password comparison in the diff, check it uses a constant-time compare (e.g. subtle/constant_time_eq), not == or early-return byte loops. Then check the failure-response paths: construct an invalid-user request and an invalid-secret request and confirm they are indistinguishable (same error, no early length short-circuit) so an attacker cannot enumerate valid users or time-side-channel the secret.
|
- For any secret/token/signature/password comparison in the diff, check it uses a constant-time compare (e.g. subtle/constant_time_eq), not == or early-return byte loops. Then check the failure-response paths: construct an invalid-user request and an invalid-secret request and confirm they are indistinguishable (same error, no early length short-circuit) so an attacker cannot enumerate valid users or time-side-channel the secret.
|
||||||
- Where: crates/protocols/ (FTPS/WebDAV/FormPost auth), crates/credentials/, rustfs/src/auth.rs, RPC signature verification
|
- Where: crates/protocols/ (FTPS/WebDAV/FormPost auth), crates/credentials/, rustfs/src/auth.rs, RPC signature verification
|
||||||
- Evidence: GHSA-3p3x-734c-h5vx (FTPS/WebDAV early-return string equality + distinguishable invalid-user vs invalid-password). Fix commits 3c3113619 (constant-time FTPS/WebDAV) and c41062f27 (constant-time FormPost signature). 3p3x was fixed by PR #4403.
|
- Evidence: GHSA-3p3x-734c-h5vx (FTPS/WebDAV early-return string equality + distinguishable invalid-user vs invalid-password). Fix commits 3c3113619 (constant-time FTPS/WebDAV) and c41062f27 (constant-time FormPost signature). 3p3x was fixed by PR #4403.
|
||||||
|
- If the diff parses or transports secret-bearing config (env vars, key files, connection strings), grep every error-construction and format site on that value's path (`format!` feeding `Error::other`/`configuration_error`/`panic!`/`expect`) for interpolation of the raw value or of variables named like secret material. Construct the likeliest misconfiguration: the operator supplies the bare secret without the expected `<name>:` prefix (or with a stray newline) — if the parse-failure hint echoes the input, the secret lands in startup logs. Error strings are log content; the hint may name the env var and expected format, never the value. If the diff re-implements an existing parse helper, diff the two error paths — the duplicate is where the leak hides.
|
||||||
|
- Where: rustfs/src/init.rs (env plumbing), crates/kms/src/config.rs, crates/credentials/, any from_env/parse on secret values; mechanical backstop in scripts/check_logging_guardrails.sh (secret-interpolation check)
|
||||||
|
- Evidence: PR #5222 introduced `got: {secret_str}` in build_static_kms_config's format-hint error — a bare base64 key (the secret itself) would have been echoed into startup logs; fixed by PR #5243. The parallel parse in KmsConfig::from_env already omitted the value: the leak lived only in the duplicated copy (AGENTS.md 'Reuse Before You Write').
|
||||||
- If the diff touches internode/RPC auth secret handling, trace whether the RPC HMAC secret can fall back to a public default (e.g. 'rustfsadmin', 'rustfs rpc') or be derived deterministically from the S3 root credentials. Construct the case where RUSTFS_RPC_SECRET is unset and confirm the code fails closed rather than silently using a default or a root-derived key. Verify RPC signing keys are independent random secrets, not reused across S3-root/RPC-HMAC/STS-JWT roles.
|
- If the diff touches internode/RPC auth secret handling, trace whether the RPC HMAC secret can fall back to a public default (e.g. 'rustfsadmin', 'rustfs rpc') or be derived deterministically from the S3 root credentials. Construct the case where RUSTFS_RPC_SECRET is unset and confirm the code fails closed rather than silently using a default or a root-derived key. Verify RPC signing keys are independent random secrets, not reused across S3-root/RPC-HMAC/STS-JWT roles.
|
||||||
- Where: crates/credentials/, crates/ecstore/src/rpc/, internode auth setup
|
- Where: crates/credentials/, crates/ecstore/src/rpc/, internode auth setup
|
||||||
- Evidence: GHSA-r5qv-rc46-hv8q (fell back to 'rustfsadmin'), GHSA-75fx/68cw (RPC secret derivable from root creds → forgeable signatures), GHSA-h956 (hard-coded 'rustfs rpc'), GHSA-m77q (STS JWT reused root secret). Fix commit 7b2055405 (fail closed when deriving RPC secret from default credentials, PR#4402).
|
- Evidence: GHSA-r5qv-rc46-hv8q (fell back to 'rustfsadmin'), GHSA-75fx/68cw (RPC secret derivable from root creds → forgeable signatures), GHSA-h956 (hard-coded 'rustfs rpc'), GHSA-m77q (STS JWT reused root secret). Fix commit 7b2055405 (fail closed when deriving RPC secret from default credentials, PR#4402).
|
||||||
@@ -243,7 +254,7 @@ Null report example: "Attacked the new rename_data commit-section work, durabili
|
|||||||
- If the diff writes internal object metadata, run the dual-key mutation: delete the `x-minio-internal-<suffix>` write (keeping only `x-rustfs-internal-`) and check whether any test fails. Because `get_bytes` prefers the RustFS key, every read-back test stays green while MinIO interop is silently broken — coverage must include an assertion that BOTH keys are present in the stored metadata map.
|
- If the diff writes internal object metadata, run the dual-key mutation: delete the `x-minio-internal-<suffix>` write (keeping only `x-rustfs-internal-`) and check whether any test fails. Because `get_bytes` prefers the RustFS key, every read-back test stays green while MinIO interop is silently broken — coverage must include an assertion that BOTH keys are present in the stored metadata map.
|
||||||
- Where: crates/utils/src/http/metadata_compat.rs and all its callers in crates/ecstore and rustfs/src/storage
|
- Where: crates/utils/src/http/metadata_compat.rs and all its callers in crates/ecstore and rustfs/src/storage
|
||||||
- Evidence: CLAUDE.md domain convention: metadata must be written under both x-rustfs-internal- and x-minio-internal- keys for MinIO interop; get_bytes prefers the RustFS key, making the MinIO-key half of the invariant invisible to read-back tests.
|
- Evidence: CLAUDE.md domain convention: metadata must be written under both x-rustfs-internal- and x-minio-internal- keys for MinIO interop; get_bytes prefers the RustFS key, making the MinIO-key half of the invariant invisible to read-back tests.
|
||||||
- For changed quorum/version/UUID logic, name the tests covering the specific poison values: quorum−1 disks, nil UUID, absent vs empty vs nil-serialized UUID bytes, and remote-tier version_id of None/"" (unversioned tier bucket → no versionId sent). Mutation check: remove a `.filter(|u| !u.is_nil())` guard from the diff and confirm a test fails; if none does, the nil-UUID class is uncovered.
|
- For changed quorum/version/UUID logic, name the tests covering the specific poison values: quorum−1 disks, nil UUID, absent vs empty vs nil-serialized UUID bytes, remote-tier version_id of None/"" (unversioned tier bucket → no versionId sent), and the same metadata read on both MetaObject and MetaDeleteMarker version types. Mutation check: remove a `.filter(|u| !u.is_nil())` guard from the diff and confirm a test fails; if none does, the nil-UUID class is uncovered.
|
||||||
- Where: crates/ecstore (tier recovery, heal, quorum paths), crates/filemeta, code reading UUIDs from xl.meta metadata
|
- Where: crates/ecstore (tier recovery, heal, quorum paths), crates/filemeta, code reading UUIDs from xl.meta metadata
|
||||||
- Evidence: Commit 726f3dc18 (#4552) fixed rejection of empty remote version_id in tier recovery. CLAUDE.md invariant: absent/empty/nil UUID all mean 'no value', not Uuid::nil(). docs/operations/tier-ilm-debugging.md: None/"" tier version means unversioned bucket. df9cbc4ed (#4427): unvalidated distribution values caused shuffle index panic — edge values reached production untested.
|
- Evidence: Commit 726f3dc18 (#4552) fixed rejection of empty remote version_id in tier recovery. CLAUDE.md invariant: absent/empty/nil UUID all mean 'no value', not Uuid::nil(). docs/operations/tier-ilm-debugging.md: None/"" tier version means unversioned bucket. df9cbc4ed (#4427): unvalidated distribution values caused shuffle index panic — edge values reached production untested.
|
||||||
- For any pagination/limit/truncation change, construct the exact-boundary test: result count == max (page exactly full), max+1, and a delimiter re-fold that lands precisely on the page boundary — assert both the item count AND the is_truncated/continuation marker. Off-by-one at the page boundary is a recurring shipped bug here.
|
- For any pagination/limit/truncation change, construct the exact-boundary test: result count == max (page exactly full), max+1, and a delimiter re-fold that lands precisely on the page boundary — assert both the item count AND the is_truncated/continuation marker. Off-by-one at the page boundary is a recurring shipped bug here.
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ never weaken a check to get green.
|
|||||||
|
|
||||||
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
|
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
|
||||||
|
|
||||||
Enforces `interface (admin, storage/ecfs, storage/s3_api) → app → infra`; no
|
Enforces `composition (server, startup/init) → interface (admin,
|
||||||
upward imports. Known legacy violations live in
|
storage/ecfs, storage/s3_api) → app → infra`; no upward imports. Server source
|
||||||
|
files are composition roots, while imports of their exported HTTP contracts
|
||||||
|
are classified as interface dependencies. Known legacy violations live in
|
||||||
`scripts/layer-dependency-baseline.txt`.
|
`scripts/layer-dependency-baseline.txt`.
|
||||||
|
|
||||||
|
Dedicated `*_test.rs` and `tests/` modules are outside this production guard.
|
||||||
|
Inline `#[cfg(test)]` imports remain checked under their source file's layer;
|
||||||
|
move architecture-crossing test scaffolding into a dedicated test module.
|
||||||
|
|
||||||
- **New violation**: restructure your change so the dependency points
|
- **New violation**: restructure your change so the dependency points
|
||||||
downward (move the shared type/function to the lower layer).
|
downward (move the shared type/function to the lower layer).
|
||||||
- **You legitimately removed a baseline entry**: run
|
- **You legitimately removed a baseline entry**: run
|
||||||
|
|||||||
@@ -43,16 +43,7 @@ Use this skill to review code changes consistently before merge, before release,
|
|||||||
|
|
||||||
#### Rust-specific checks (apply to all Rust changes)
|
#### Rust-specific checks (apply to all Rust changes)
|
||||||
|
|
||||||
- **unwrap/expect in production**: Search changed files for `.unwrap()` and `.expect(` outside test modules. Every `unwrap()` in production code must have a justification comment or be replaced with `?`.
|
Run the full checklist in [rust-code-quality](../rust-code-quality/SKILL.md) — the canonical Rust review checklist for the unwrap/casting/cloning/locking/recursion/error-type/serde/test rules and the reuse-and-necessity checks (duplicated helpers, defensive branches without a nameable trigger, redundant error wrapping). Do not restate those rules here; carry its P0–P3 ratings over unchanged and use this skill's output format.
|
||||||
- **Silent type truncation**: Search for `as u8/u16/u32/u64/usize/i8/i16/i32/i64/isize` casts. Every `as` cast must be justified; negative-to-unsigned and large-to-small are bugs by default. Use `try_into()` or explicit clamping.
|
|
||||||
- **Unnecessary cloning**: Check `.clone()` calls in loops, per-request paths, and on structs with >5 heap-allocated fields. Consider `Arc`, references, or `Cow<str>`.
|
|
||||||
- **Lock ordering**: If the change acquires multiple locks, verify the order matches all other call sites. Document the order in a comment.
|
|
||||||
- **Locks across .await**: Flag any `tokio::sync::RwLock`/`Mutex` guard held across an `.await` point without bounded hold time.
|
|
||||||
- **Recursion depth**: If the change adds or modifies a recursive function, verify it has a depth limit or uses iterative traversal with an explicit stack.
|
|
||||||
- **Error types**: Flag `Result<_, String>`, `Box<dyn Error>`, and missing `Error::source()` implementations in public APIs.
|
|
||||||
- **Test assertions**: Every test function must have at least one `assert!`. Flag tests that only call code without verifying results.
|
|
||||||
- **println/eprintln**: Search changed files for `println!`/`eprintln!` outside test modules. Production code must use `tracing` macros.
|
|
||||||
- **Serde safety**: Structs deserialized from untrusted input (S3 API, user config) should have `#[serde(deny_unknown_fields)]`.
|
|
||||||
|
|
||||||
### 4) Findings-first output
|
### 4) Findings-first output
|
||||||
- Order findings by severity:
|
- Order findings by severity:
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ rg -n 'println!\|eprintln!' <changed-files> | grep -v test
|
|||||||
|
|
||||||
# 6. Ordering::Relaxed usage (verify each is intentional)
|
# 6. Ordering::Relaxed usage (verify each is intentional)
|
||||||
rg -n 'Ordering::Relaxed' <changed-files>
|
rg -n 'Ordering::Relaxed' <changed-files>
|
||||||
|
|
||||||
|
# 7. Default substituted for a possibly-required value (judge each: is the value optional by domain?)
|
||||||
|
rg -n 'unwrap_or_default\(\)|unwrap_or\(' <changed-files>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual Review Checklist
|
## Manual Review Checklist
|
||||||
@@ -55,8 +58,8 @@ For every Rust code change, verify:
|
|||||||
- [ ] No `f64 as usize` without prior clamping
|
- [ ] No `f64 as usize` without prior clamping
|
||||||
|
|
||||||
### Concurrency
|
### Concurrency
|
||||||
- [ ] Lock acquisition order is documented when multiple locks are used
|
- [ ] Lock acquisition order is documented when multiple locks are used, and matches every other call site taking any overlapping subset (ABBA check)
|
||||||
- [ ] No `tokio::sync` write guards held across `.await` without bounded hold time
|
- [ ] No `tokio::sync` lock guard (read or write) held across `.await` without bounded hold time — long-lived read guards wedge writers (#4195)
|
||||||
- [ ] Concurrent counters use `compare_exchange` loops, not load-then-store
|
- [ ] Concurrent counters use `compare_exchange` loops, not load-then-store
|
||||||
- [ ] `std::sync::Mutex` in async context is held only briefly, never across `.await`
|
- [ ] `std::sync::Mutex` in async context is held only briefly, never across `.await`
|
||||||
|
|
||||||
@@ -84,12 +87,19 @@ For every Rust code change, verify:
|
|||||||
- [ ] No camelCase statics or Hungarian notation
|
- [ ] No camelCase statics or Hungarian notation
|
||||||
- [ ] New string literals don't duplicate existing constants
|
- [ ] New string literals don't duplicate existing constants
|
||||||
|
|
||||||
|
### Reuse and Necessity
|
||||||
|
- [ ] No new helper duplicating an existing workspace utility (`crates/utils`, `crates/common`, the touched crate) or plain std/tokio behavior no wrapper refines; reused helpers match the call site's semantics (normalization, error type, backoff, durability gating)
|
||||||
|
- [ ] No branch without a nameable concrete trigger; no re-validation of what a validated upstream layer on the same path already guarantees (Cross-Cutting Domain Invariant patterns and pre-destructive-action re-checks are load-bearing — keep them)
|
||||||
|
- [ ] Error context attached once where actionable, not re-wrapped at every hop; no typed→generic error conversion below aggregation/quorum layers
|
||||||
|
- [ ] No comments narrating the next line, restating a signature, or describing the change itself (invariant comments — lock ordering, `SAFETY`, unwrap justification — are not narration)
|
||||||
|
- [ ] No near-duplicate test pinning the same code path and poison-value class as an existing test (boundary companions — n==max vs max+1, absent/empty/nil UUID — are never near-duplicates)
|
||||||
|
|
||||||
## Severity Classification
|
## Severity Classification
|
||||||
|
|
||||||
- **P0 (Block merge)**: `unwrap()` in request hot path, silent truncation on user input, lock ordering violation, recursion without depth limit
|
- **P0 (Block merge)**: `unwrap()` in request hot path, silent truncation on user input, lock ordering violation, recursion without depth limit
|
||||||
- **P1 (Must fix)**: `Result<_, String>` in public API, unnecessary clone in hot path, `Box<dyn Error>` in trait method
|
- **P1 (Must fix)**: `Result<_, String>` in public API, unnecessary clone in hot path, `Box<dyn Error>` in trait method, `unwrap_or_default()` on a domain-required value (metadata, quorum, version id)
|
||||||
- **P2 (Should fix)**: Missing `assert!` in test, `println!` in production, missing `with_capacity`
|
- **P2 (Should fix)**: Missing `assert!` in test, `println!` in production, missing `with_capacity`, new helper duplicating an existing workspace utility, defensive branch with no nameable trigger (corrupt or stale persisted/peer data is always a nameable trigger for boundary-crossing values), near-duplicate test, redundant error re-wrapping
|
||||||
- **P3 (Nice to fix)**: Naming convention violation, missing doc comment, `as_ptr()` vs `Arc::ptr_eq`
|
- **P3 (Nice to fix)**: Naming convention violation, missing doc comment, `as_ptr()` vs `Arc::ptr_eq`, narrating comment
|
||||||
|
|
||||||
## Output Template
|
## Output Template
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
---
|
---
|
||||||
name: rustfs-release-publish
|
name: rustfs-release-publish
|
||||||
description: "End-to-end RustFS release pipeline: bump version files on main directly to the final target version, cut a preview tag on that commit, verify the CI build and release artifacts, run the downloaded binary locally and exercise the console, validate the server with the latest rc client, then publish the final tag on the SAME validated commit — never a new bump commit, never latest main. Use whenever the user wants to release/publish a RustFS version (发版/发布)."
|
description: "End-to-end RustFS release pipeline: first publish any merged-but-unreleased rustfs/console changes and wait for its latest Release asset, then bump RustFS version files on main directly to the final target, publish a visible GitHub prerelease from a preview tag without updating latest channels, validate it, and publish the final tag on the SAME commit. Use whenever the user wants to release/publish a RustFS version (发版/发布)."
|
||||||
---
|
---
|
||||||
# RustFS Release Publish (preview-validated pipeline)
|
# RustFS Release Publish (preview-validated pipeline)
|
||||||
|
|
||||||
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
|
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
|
||||||
|
|
||||||
Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and prerelease classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships.
|
Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. A preview tag creates a visible GitHub Release marked Prerelease and uploads versioned assets, but it never becomes GitHub Latest and never updates `*-latest`, `latest.json`, R2, Docker, or Helm channels. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and preview classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships.
|
||||||
|
|
||||||
Pipeline shape:
|
Pipeline shape:
|
||||||
|
|
||||||
```
|
```
|
||||||
bump version files to <target> (final version, ONE commit) -> merge
|
check console main against its latest Release
|
||||||
|
-> if ahead: publish console -> wait for Release asset + latest API
|
||||||
|
-> bump RustFS version files to <target> (final version, ONE commit) -> merge
|
||||||
-> tag <preview-tag> at that commit -> CI green
|
-> tag <preview-tag> at that commit -> CI green
|
||||||
-> verify release artifacts -> run binary locally + console checks
|
-> verify preview Release assets -> run binary locally + console checks
|
||||||
-> validate with latest rc client
|
-> validate with latest rc client
|
||||||
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
|
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
|
||||||
```
|
```
|
||||||
@@ -23,7 +25,7 @@ On validation failure: fix lands on main via normal PR (version files are alread
|
|||||||
## Required inputs
|
## Required inputs
|
||||||
|
|
||||||
- Final target version, for example `1.0.0-beta.10`.
|
- Final target version, for example `1.0.0-beta.10`.
|
||||||
- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '<target>-preview.*'` — and for stable targets `git tag -l '<target>-rc.*'` — after `git fetch --tags`).
|
- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '<target>-preview.*'` after `git fetch --tags`).
|
||||||
|
|
||||||
If the target version is missing or ambiguous, stop and ask before doing anything (see the semver gate below).
|
If the target version is missing or ambiguous, stop and ask before doing anything (see the semver gate below).
|
||||||
|
|
||||||
@@ -45,14 +47,18 @@ Rules:
|
|||||||
|
|
||||||
## Preview tag naming
|
## Preview tag naming
|
||||||
|
|
||||||
- Prerelease target (contains `alpha`/`beta`/`rc`): preview tag is `<target>-preview.N`, e.g. `1.0.0-beta.10-preview.3`. It contains `beta`, so `build.yml`'s substring-based classification marks it prerelease — safe.
|
- Use `<target>-preview.N` for every target, e.g. `1.0.0-beta.10-preview.3` or `1.1.0-preview.1`.
|
||||||
- **Stable** target (e.g. `1.1.0`): NEVER tag `1.1.0-preview.N` — `build.yml` marks a tag prerelease only if its name contains `alpha`, `beta`, or `rc`, so `1.1.0-preview.N` would be treated as a stable release and overwrite `latest.json` as stable. Use `1.1.0-rc.N` as the preview tag instead.
|
- The canonical suffix is exactly `-preview.<digits>`. `build.yml` recognizes it before alpha/beta/rc classification and routes it to the preview-only path; any other tag containing `-preview` fails closed instead of being treated as a release.
|
||||||
|
- A preview Release MUST be published with `isPrerelease=true` and `isLatest=false`. Any `*-latest` preview asset or preview-triggered `latest.json`, R2, Docker, or Helm publication is a pipeline failure.
|
||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
- Version files (Cargo.toml, Cargo.lock, README, flake.nix, Chart.yaml, rustfs.spec) are bumped ONCE, directly to `<target>`. Never write a `-preview.N` suffix into any version file. If `rustfs-release-version-bump` is ever asked for a `-preview` version, that is a pipeline bug — stop.
|
- Version files (Cargo.toml, Cargo.lock, README, flake.nix, Chart.yaml, rustfs.spec) are bumped ONCE, directly to `<target>`. Never write a `-preview.N` suffix into any version file. If `rustfs-release-version-bump` is ever asked for a `-preview` version, that is a pipeline bug — stop.
|
||||||
|
- Preview Release assets are versioned and intentionally visible on the Releases page. Do not label them Latest or use them to update any latest distribution channel.
|
||||||
- Tags have no `v` prefix. Always annotated: `git tag -a <tag> -m "Release <tag>"`.
|
- Tags have no `v` prefix. Always annotated: `git tag -a <tag> -m "Release <tag>"`.
|
||||||
- The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final.
|
- The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final.
|
||||||
|
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
|
||||||
|
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
|
||||||
- Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash.
|
- Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash.
|
||||||
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
|
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
|
||||||
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
|
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
|
||||||
@@ -63,6 +69,61 @@ Rules:
|
|||||||
- `gh auth status` works; confirm you can view `gh release list -L 3`.
|
- `gh auth status` works; confirm you can view `gh release list -L 3`.
|
||||||
- Confirm the exact final target version with the user if not explicit.
|
- Confirm the exact final target version with the user if not explicit.
|
||||||
|
|
||||||
|
### Console release gate
|
||||||
|
|
||||||
|
Complete this gate before changing any RustFS version file or creating any RustFS tag. RustFS `build.yml` downloads the asset returned by `repos/rustfs/console/releases/latest`, so a successful Console build alone is insufficient.
|
||||||
|
|
||||||
|
1. Read the latest published Console tag and compare it with Console `main`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONSOLE_REPO="rustfs/console"
|
||||||
|
CONSOLE_LATEST=$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)
|
||||||
|
gh api "repos/${CONSOLE_REPO}/compare/${CONSOLE_LATEST}...main" \
|
||||||
|
--jq '{status, ahead_by, behind_by, commits: [.commits[] | {sha, message: .commit.message}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ahead_by == 0`: no merged Console change is waiting for release. Still verify the current latest asset using step 4, then continue to Phase 1.
|
||||||
|
- `ahead_by > 0` and `behind_by == 0`: publish Console before continuing. Report the merged commits and select the next unused `vX.Y.Z` tag. Default to the next patch version when the changes are fixes or backward-compatible UI work; stop for confirmation if a minor/major bump is plausible.
|
||||||
|
- Any diverged history or `behind_by > 0`: stop and resolve the Console release baseline explicitly. Do not guess a range or publish RustFS.
|
||||||
|
|
||||||
|
2. Clone/fetch `rustfs/console` into a scratch directory and record its exact `main` commit. Before creating a tag, check for a `v*` tag or Release workflow already associated with that hash. If one is in progress, wait for it instead of creating another version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONSOLE_SCRATCH=$(mktemp -d)
|
||||||
|
gh repo clone "$CONSOLE_REPO" "$CONSOLE_SCRATCH/console"
|
||||||
|
git -C "$CONSOLE_SCRATCH/console" fetch origin main --tags
|
||||||
|
CONSOLE_HASH=$(git -C "$CONSOLE_SCRATCH/console" rev-parse origin/main)
|
||||||
|
git -C "$CONSOLE_SCRATCH/console" tag --points-at "$CONSOLE_HASH" 'v*'
|
||||||
|
gh run list -R "$CONSOLE_REPO" --workflow release.yml --commit "$CONSOLE_HASH" --limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
If no release exists or is running for `CONSOLE_HASH`, create the selected annotated tag at that exact hash and push it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "$CONSOLE_SCRATCH/console" tag -a "<console-tag>" -m "Release <console-tag>" "$CONSOLE_HASH"
|
||||||
|
git -C "$CONSOLE_SCRATCH/console" push origin "<console-tag>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Console tags include the `v` prefix. Pushing the tag triggers `.github/workflows/release.yml` (`🚀 Release`). Remove `CONSOLE_SCRATCH` after the gate completes.
|
||||||
|
|
||||||
|
3. Find the exact tag run and wait for completion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh run list -R "$CONSOLE_REPO" --workflow release.yml --branch "<console-tag>" --limit 1
|
||||||
|
gh run watch -R "$CONSOLE_REPO" "<console-run-id>" --exit-status
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Block until the published Release is non-draft, the latest endpoint returns the expected tag, and `rustfs-console-<console-tag>.zip` is uploaded, non-empty, and carries a `sha256:` digest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh release view -R "$CONSOLE_REPO" "<console-tag>" --json isDraft,isPrerelease,assets,url
|
||||||
|
test "$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)" = "<console-tag>"
|
||||||
|
test "$(gh api "repos/${CONSOLE_REPO}/releases/tags/<console-tag>" \
|
||||||
|
--jq '[.assets[] | select(.name == "rustfs-console-<console-tag>.zip" and .state == "uploaded" and .size > 0 and (.digest | startswith("sha256:")))] | length')" -eq 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Treat a missing/mismatched asset, digest, latest tag, or failed/cancelled workflow as BLOCKED. Do not start Phase 1 until the Console gate passes. Record `CONSOLE_TAG`, `CONSOLE_HASH`, Console run URL, and Release URL for the final report.
|
||||||
|
|
||||||
## Phase 1 — Version bump to the final target (once)
|
## Phase 1 — Version bump to the final target (once)
|
||||||
|
|
||||||
- If main's version files already read `<target>` (e.g. this is a restart after a failed preview), verify with `rg -n "<target>" Cargo.toml rustfs.spec helm/rustfs/Chart.yaml` and skip to Phase 2.
|
- If main's version files already read `<target>` (e.g. this is a restart after a failed preview), verify with `rg -n "<target>" Cargo.toml rustfs.spec helm/rustfs/Chart.yaml` and skip to Phase 2.
|
||||||
@@ -85,16 +146,17 @@ git push origin "<preview-tag>"
|
|||||||
|
|
||||||
Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`.
|
Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`.
|
||||||
|
|
||||||
|
The preview run builds versioned artifacts and publishes them in a GitHub prerelease. Its latest-channel, R2, Docker, and Helm jobs must be skipped. Those publication paths run only after the final tag is pushed.
|
||||||
|
|
||||||
On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first — it must contain the fix — and re-report it.
|
On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first — it must contain the fix — and re-report it.
|
||||||
|
|
||||||
## Phase 3 — CI and artifact verification
|
## Phase 3 — CI and preview Release verification
|
||||||
|
|
||||||
- Watch the tag build: `gh run list --workflow build.yml --limit 5` then `gh run watch <run-id>`. Every matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64) plus the release and latest.json jobs.
|
- Find and watch the tag build: `gh run list --workflow build.yml --branch "<preview-tag>" --limit 1` then `gh run watch <run-id>`. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64).
|
||||||
- Verify the GitHub release: `gh release view "<preview-tag>" --json isPrerelease,assets`
|
- Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped.
|
||||||
- `isPrerelease` must be `true`.
|
- Verify `gh release view "<preview-tag>" --json isPrerelease,assets,url`: `isPrerelease` must be `true`, and the Release must contain all 6 versioned platform zips, checksums, SBOM, and provenance with no `-latest` assets. Confirm `gh api repos/{owner}/{repo}/releases/latest --jq .tag_name` does not return `<preview-tag>`.
|
||||||
- Assets must include all 6 platform zips in both versioned (`rustfs-<platform>-v<tag>.zip`) and `-latest` forms, plus `SHA256SUMS`, `SHA512SUMS`, `rustfs-<tag>.sbom.cdx.json`, `rustfs-<tag>.provenance.json`.
|
- Record `PREVIOUS_DELIVERABLE`, selected from published Releases by `publishedAt` after excluding the current tag and every `-preview.N` tag. Verify `gh release view "<preview-tag>" --json body --jq .body` contains `## What's Changed` and, when `PREVIOUS_DELIVERABLE` exists, `**Full Changelog**: https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<preview-tag>`. For a repository with no previous deliverable, verify a Full Changelog link exists and record the GitHub baseline fallback.
|
||||||
- Verify the chained Docker run succeeded: `gh run list --workflow docker.yml --limit 3`.
|
- Confirm preview-triggered Docker and Helm jobs are skipped. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; Docker image construction and Helm publication are deferred to the final tag because the Dockerfiles consume GitHub Release assets.
|
||||||
- Checksum spot-check for the platform you will run locally: download the zip and `SHA256SUMS`, verify with `shasum -a 256 -c` (grep to one line).
|
|
||||||
|
|
||||||
## Phase 4 — Run the artifact locally, verify the console
|
## Phase 4 — Run the artifact locally, verify the console
|
||||||
|
|
||||||
@@ -157,13 +219,15 @@ git push origin "<target>"
|
|||||||
```
|
```
|
||||||
|
|
||||||
- CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports `<target>`.
|
- CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports `<target>`.
|
||||||
- Re-run the Phase 3 verification against the final tag: all matrix jobs green; `gh release view "<target>"` shows the full asset set; for a prerelease target `isPrerelease` is `true`, for a stable target it must be `false` and `latest.json` must be updated.
|
- Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view "<target>"` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to `<target>`. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target.
|
||||||
|
- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<target>` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`.
|
||||||
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
|
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
|
||||||
|
|
||||||
## Output contract
|
## Output contract
|
||||||
|
|
||||||
Always report:
|
Always report:
|
||||||
|
|
||||||
|
- Console gate result: previous/latest Console tags, whether merged changes required a release, `CONSOLE_HASH`, and Console run/Release URLs when a release was published.
|
||||||
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
|
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
|
||||||
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: CI run URLs, release URLs, console check results, the rc command matrix.
|
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: preview and final Release URLs, preview `isPrerelease`/`isLatest` state, final latest-channel state, console check results, and the rc command matrix.
|
||||||
- Any deviation from this pipeline and why the user approved it.
|
- Any deviation from this pipeline and why the user approved it.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Validated baseline: release pattern used in PR `#2957`.
|
|||||||
|
|
||||||
If target version is missing or ambiguous, stop and ask before editing.
|
If target version is missing or ambiguous, stop and ask before editing.
|
||||||
|
|
||||||
Reject any target version containing a `-preview.` suffix: preview identifiers are tag-only (see `rustfs-release-publish`) and must never be written into version files. If asked for one, stop and point to the release pipeline instead of editing.
|
Reject any target version containing `-preview`: preview identifiers are tag-only (see `rustfs-release-publish`) and must never be written into version files. If asked for one, stop and point to the release pipeline instead of editing.
|
||||||
|
|
||||||
## Read before editing
|
## Read before editing
|
||||||
|
|
||||||
|
|||||||
@@ -60,12 +60,14 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
|
|||||||
### IAM and service accounts
|
### IAM and service accounts
|
||||||
- Treat imported IAM payload fields as attacker-controlled: `parent`, `claims`, `accessKey`, `secretKey`, status, policy names, and groups.
|
- Treat imported IAM payload fields as attacker-controlled: `parent`, `claims`, `accessKey`, `secretKey`, status, policy names, and groups.
|
||||||
- For service account create/update/import, prove parent ownership or root/admin authority before writing credentials or claims; an action permission alone must not allow choosing root or another user as `target_user`.
|
- For service account create/update/import, prove parent ownership or root/admin authority before writing credentials or claims; an action permission alone must not allow choosing root or another user as `target_user`.
|
||||||
|
- Treat IAM export packages as credential disclosure surfaces; never include plaintext user or service-account secret keys unless the caller is allowed to recover those secrets and the export format is intentionally sealed.
|
||||||
- Do not let `deny_only` or "no explicit deny" become an allow decision that skips required allow checks.
|
- Do not let `deny_only` or "no explicit deny" become an allow decision that skips required allow checks.
|
||||||
- Test cross-user list/update/import flows with wrong, correct, self, parent, and root identities.
|
- Test cross-user list/update/import flows with wrong, correct, self, parent, and root identities.
|
||||||
|
|
||||||
### STS, OIDC, and federation flows
|
### STS, OIDC, and federation flows
|
||||||
- Every STS endpoint must have an explicit authentication story: SigV4 where required, OIDC token verification for web identity, and role/session policy validation before issuing credentials.
|
- Every STS endpoint must have an explicit authentication story: SigV4 where required, OIDC token verification for web identity, and role/session policy validation before issuing credentials.
|
||||||
- JWT session tokens must be signed and verified by a trusted issuer/key path, not by service-account-controlled material or a reused root secret.
|
- JWT session tokens must be signed and verified by a trusted issuer/key path, not by service-account-controlled material or a reused root secret.
|
||||||
|
- JWT verification must enforce required claims and expiration for every bearer token path; "allow missing exp" is never acceptable for user-presented credentials.
|
||||||
- Public OIDC bootstrap and callback routes must treat `Host`, `X-Forwarded-Proto`, redirect targets, `state`, and callback parameters as untrusted; credential-bearing redirects require a configured, allowlisted origin.
|
- Public OIDC bootstrap and callback routes must treat `Host`, `X-Forwarded-Proto`, redirect targets, `state`, and callback parameters as untrusted; credential-bearing redirects require a configured, allowlisted origin.
|
||||||
- OIDC discovery and validation URLs are SSRF sinks. Resolve and classify hostnames at connection time, reject rebinding to loopback/private/link-local ranges, and do not rely on literal string checks.
|
- OIDC discovery and validation URLs are SSRF sinks. Resolve and classify hostnames at connection time, reject rebinding to loopback/private/link-local ranges, and do not rely on literal string checks.
|
||||||
|
|
||||||
@@ -97,6 +99,8 @@ For the full pattern map, read [advisory-patterns.md](references/advisory-patter
|
|||||||
### Logging and debug output
|
### Logging and debug output
|
||||||
- Logs must never include access keys beyond safe identifiers, secret keys, session tokens, JWT claims, HMAC secrets, expected signatures, license secrets, or raw response bodies containing credentials.
|
- Logs must never include access keys beyond safe identifiers, secret keys, session tokens, JWT claims, HMAC secrets, expected signatures, license secrets, or raw response bodies containing credentials.
|
||||||
- Treat `Debug` implementations, `?value` tracing, merged config dumps, and dependency-level HTTP body logging as leak surfaces.
|
- Treat `Debug` implementations, `?value` tracing, merged config dumps, and dependency-level HTTP body logging as leak surfaces.
|
||||||
|
- Error and panic messages are log content: they propagate through `?` and get printed by `error!`/startup logging far from where they were constructed. Never interpolate a raw config or credential value into an error string.
|
||||||
|
- A value that fails secret-format parsing is usually the secret itself (e.g. a bare base64 key missing its `<name>:` prefix), so a parse-failure hint must name the env var or file and the expected format, never echo the input. Redacting `Debug` impls does not cover this channel.
|
||||||
- Add log-capture tests or targeted unit tests for redaction wrappers when changing credential structs or response bodies.
|
- Add log-capture tests or targeted unit tests for redaction wrappers when changing credential structs or response bodies.
|
||||||
|
|
||||||
### RPC, parsing, and panic safety
|
### RPC, parsing, and panic safety
|
||||||
@@ -137,7 +141,10 @@ Use these prompts while reviewing a diff:
|
|||||||
- Does a public/default/empty config change security behavior from fail-closed to fail-open?
|
- Does a public/default/empty config change security behavior from fail-closed to fail-open?
|
||||||
- Is any attacker-controlled value later used as a path, policy condition, credential identity, log field, URL, Origin, or response body?
|
- Is any attacker-controlled value later used as a path, policy condition, credential identity, log field, URL, Origin, or response body?
|
||||||
- Does this response contain stored replication, remote target, or service credentials that need redaction or stricter authorization?
|
- Does this response contain stored replication, remote target, or service credentials that need redaction or stricter authorization?
|
||||||
|
- Does any error constructor or `format!` interpolate a variable that can hold secret material, including a config parse error that echoes the raw input?
|
||||||
|
- Does an IAM export/import path expose or trust plaintext credential secrets beyond the caller's intended authority?
|
||||||
- Can this STS/OIDC path issue credentials without SigV4, trusted issuer validation, allowlisted redirects, or trusted-proxy host/scheme handling?
|
- Can this STS/OIDC path issue credentials without SigV4, trusted issuer validation, allowlisted redirects, or trusted-proxy host/scheme handling?
|
||||||
|
- Can a service-account or STS token omit `exp`, forge `sessionPolicy`, or use a principal-controlled key as signing authority?
|
||||||
- Does this outbound validation path resolve attacker-supplied hostnames and reject private, loopback, link-local, and rebound addresses at the actual connection boundary?
|
- Does this outbound validation path resolve attacker-supplied hostnames and reject private, loopback, link-local, and rebound addresses at the actual connection boundary?
|
||||||
- Is an archive entry, object key, or policy resource normalized differently between authorization and storage?
|
- Is an archive entry, object key, or policy resource normalized differently between authorization and storage?
|
||||||
- Is the same operation implemented in multiple paths, such as `CopyObject` vs `UploadPartCopy`, and do all paths enforce the same security contract?
|
- Is the same operation implemented in multiple paths, such as `CopyObject` vs `UploadPartCopy`, and do all paths enforce the same security contract?
|
||||||
|
|||||||
@@ -27,14 +27,15 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
|||||||
### IAM import, service accounts, and privilege boundaries
|
### IAM import, service accounts, and privilege boundaries
|
||||||
|
|
||||||
- `GHSA-566f-q62r-wcr8`: `ImportIam` accepted attacker-controlled service account `parent`, `claims`, `accessKey`, and `secretKey`, enabling persistent backdoor accounts under root. Lesson: imported IAM payloads are untrusted data and must be validated against privilege boundaries.
|
- `GHSA-566f-q62r-wcr8`: `ImportIam` accepted attacker-controlled service account `parent`, `claims`, `accessKey`, and `secretKey`, enabling persistent backdoor accounts under root. Lesson: imported IAM payloads are untrusted data and must be validated against privilege boundaries.
|
||||||
|
- `GHSA-3495-h8r9-gfqg`: `ExportIAM` wrote regular-user and service-account secret keys into exported ZIP data. Lesson: IAM export is a credential-disclosure boundary; redact, seal, or strictly justify every exported secret before treating export permission as safe.
|
||||||
- `GHSA-5354-r3w2-34m8`: `AddServiceAccount` checked `CreateServiceAccountAdminAction` but trusted caller-supplied `target_user`, allowing service accounts under the root parent. Lesson: service-account create paths must validate parent ownership or root/admin authority, not only the create action.
|
- `GHSA-5354-r3w2-34m8`: `AddServiceAccount` checked `CreateServiceAccountAdminAction` but trusted caller-supplied `target_user`, allowing service accounts under the root parent. Lesson: service-account create paths must validate parent ownership or root/admin authority, not only the create action.
|
||||||
- `GHSA-xgr5-qc6w-vcg9`: `deny_only=true` skipped allow checks and let restricted service accounts mint unrestricted children. Lesson: deny-only logic must never become implicit allow for privilege creation.
|
- `GHSA-xgr5-qc6w-vcg9`: `deny_only=true` skipped allow checks and let restricted service accounts mint unrestricted children. Lesson: deny-only logic must never become implicit allow for privilege creation.
|
||||||
- `GHSA-mm2q-qcmx-gw4w`: leaked service account access keys plus update-without-ownership formed an escalation chain. Lesson: service-account identifiers are security-sensitive because update APIs consume them.
|
- `GHSA-mm2q-qcmx-gw4w`: leaked service account access keys plus update-without-ownership formed an escalation chain. Lesson: service-account identifiers are security-sensitive because update APIs consume them.
|
||||||
|
|
||||||
### STS, OIDC, and federation flows
|
### STS, OIDC, and federation flows
|
||||||
|
|
||||||
- `GHSA-5qfg-mf7r-jp3w`: `AssumeRoleWithWebIdentity` was reachable without the required request authentication and could issue temporary credentials from crafted web identity input. Lesson: every STS route needs explicit SigV4 or trusted identity-provider validation before role assumption.
|
- `GHSA-5qfg-mf7r-jp3w` and `GHSA-3473-5353-xhwh`: `AssumeRoleWithWebIdentity` was reachable through unauthenticated `POST /` routing and could issue temporary credentials from crafted web identity input. Lesson: every STS route needs explicit SigV4 or trusted identity-provider validation before role assumption, and unauthenticated exemptions must be narrowed to the exact action with uniform failure responses.
|
||||||
- `GHSA-ccrv-v8v9-ch9q`: service-account-controlled material could self-sign JWT session tokens with forged policy claims. Lesson: session tokens must be signed by a trusted issuer/key path and validation must reject self-signed or principal-controlled tokens.
|
- `GHSA-ccrv-v8v9-ch9q` and `GHSA-48rf-7j3q-3hfv`: service-account-controlled material could self-sign JWT session tokens with forged policy claims, and missing `exp` was accepted for service-account tokens. Lesson: session tokens must be signed by a trusted issuer/key path, enforce required claims and expiration, and reject self-signed or principal-controlled tokens.
|
||||||
- `GHSA-9pjf-w3c2-m32r`, `GHSA-4x2q-cpx9-9h26`, and `GHSA-xvpm-p3f7-34c3`: public OIDC authorize/callback flows trusted request `Host` or forwarded scheme when building credential-bearing redirects. Lesson: OIDC redirects must use configured allowlisted origins and trusted-proxy handling; never derive the post-login credential destination from direct client headers.
|
- `GHSA-9pjf-w3c2-m32r`, `GHSA-4x2q-cpx9-9h26`, and `GHSA-xvpm-p3f7-34c3`: public OIDC authorize/callback flows trusted request `Host` or forwarded scheme when building credential-bearing redirects. Lesson: OIDC redirects must use configured allowlisted origins and trusted-proxy handling; never derive the post-login credential destination from direct client headers.
|
||||||
- `GHSA-m479-9x88-94w6`, `GHSA-frwq-mfqx-83p8`, `GHSA-q9q8-rf9r-fg9f`, and `GHSA-j5c2-hhf7-6gf5`: OIDC validation accepted attacker-controlled discovery URLs because hostname checks rejected only literal forbidden IPs, allowing DNS rebinding SSRF. Lesson: outbound federation URL validation must resolve and classify hostnames at the connection boundary and reject loopback, private, link-local, and rebound addresses.
|
- `GHSA-m479-9x88-94w6`, `GHSA-frwq-mfqx-83p8`, `GHSA-q9q8-rf9r-fg9f`, and `GHSA-j5c2-hhf7-6gf5`: OIDC validation accepted attacker-controlled discovery URLs because hostname checks rejected only literal forbidden IPs, allowing DNS rebinding SSRF. Lesson: outbound federation URL validation must resolve and classify hostnames at the connection boundary and reject loopback, private, link-local, and rebound addresses.
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
|||||||
|
|
||||||
### Secrets, defaults, and cryptographic misuse
|
### Secrets, defaults, and cryptographic misuse
|
||||||
|
|
||||||
- `GHSA-j59h-h7q5-q348`, `GHSA-3wm5-wpm5-hmfm`, `GHSA-6wc8-xm48-qhmx`, and `GHSA-9gf3-jx4p-4xxf`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, console, and token-signing surfaces. Lesson: root credentials must be operator-provided or generated per install; known defaults and warnings are not acceptable for network-reachable deployments.
|
- `GHSA-j59h-h7q5-q348`, `GHSA-3wm5-wpm5-hmfm`, `GHSA-6wc8-xm48-qhmx`, `GHSA-9gf3-jx4p-4xxf`, and `GHSA-63xc-c3w3-m2cf`: RustFS shipped known default root credentials that could authenticate to S3, admin APIs, IAM, KMS, console, and token-signing surfaces. Lesson: root credentials must be operator-provided or generated per install; known defaults and warnings are not acceptable for network-reachable deployments.
|
||||||
- `GHSA-h956-rh7x-ppgj`: gRPC used the hard-coded token `rustfs rpc` on both client and server. Lesson: source-visible shared tokens are authentication bypasses.
|
- `GHSA-h956-rh7x-ppgj`: gRPC used the hard-coded token `rustfs rpc` on both client and server. Lesson: source-visible shared tokens are authentication bypasses.
|
||||||
- `GHSA-r5qv-rc46-hv8q`: internode RPC HMAC secret fell back to the public default `rustfsadmin`. Lesson: RPC/internode auth must fail closed instead of silently using public defaults.
|
- `GHSA-r5qv-rc46-hv8q`: internode RPC HMAC secret fell back to the public default `rustfsadmin`. Lesson: RPC/internode auth must fail closed instead of silently using public defaults.
|
||||||
- `GHSA-75fx-qg6f-8rm7` and `GHSA-68cw-96m3-h2cf`: internode RPC secrets were derivable from known root credentials, making raw storage RPC signatures forgeable when explicit RPC secrets were unset. Lesson: RPC auth keys must be independent random secrets, never derived from S3 root credentials, and raw storage RPC should not share the public S3 listener without an internode-only boundary.
|
- `GHSA-75fx-qg6f-8rm7` and `GHSA-68cw-96m3-h2cf`: internode RPC secrets were derivable from known root credentials, making raw storage RPC signatures forgeable when explicit RPC secrets were unset. Lesson: RPC auth keys must be independent random secrets, never derived from S3 root credentials, and raw storage RPC should not share the public S3 listener without an internode-only boundary.
|
||||||
@@ -122,6 +123,7 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
|
|||||||
- 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.
|
||||||
- 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.
|
||||||
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
||||||
|
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
|
||||||
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
|
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
|
||||||
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
||||||
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together.
|
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together.
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ body-cache-whitelist-check: ## Check the body-cache eligibility gate stays a fai
|
|||||||
@echo "🧱 Checking body-cache whitelist guard..."
|
@echo "🧱 Checking body-cache whitelist guard..."
|
||||||
./scripts/check_body_cache_whitelist.sh
|
./scripts/check_body_cache_whitelist.sh
|
||||||
|
|
||||||
|
.PHONY: log-analyzer-rules-check
|
||||||
|
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||||
|
@echo "🩺 Checking log-analyzer rule anchors..."
|
||||||
|
./scripts/check_log_analyzer_rules.sh
|
||||||
|
|
||||||
.PHONY: compilation-check
|
.PHONY: compilation-check
|
||||||
compilation-check: core-deps ## Run compilation check
|
compilation-check: core-deps ## Run compilation check
|
||||||
@echo "🔨 Running compilation check..."
|
@echo "🔨 Running compilation check..."
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-gua
|
|||||||
@echo "✅ All pre-commit checks passed!"
|
@echo "✅ All pre-commit checks passed!"
|
||||||
|
|
||||||
.PHONY: pre-pr
|
.PHONY: pre-pr
|
||||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check doc-paths-check planning-docs-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check doc-paths-check planning-docs-check log-analyzer-rules-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||||
@echo "✅ All pre-PR checks passed!"
|
@echo "✅ All pre-PR checks passed!"
|
||||||
|
|
||||||
.PHONY: dev-check
|
.PHONY: dev-check
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ script-tests: ## Run shell script tests
|
|||||||
@echo "Running script tests..."
|
@echo "Running script tests..."
|
||||||
./scripts/test_build_rustfs_options.sh
|
./scripts/test_build_rustfs_options.sh
|
||||||
./scripts/test_entrypoint_credentials.sh
|
./scripts/test_entrypoint_credentials.sh
|
||||||
|
./scripts/test_internode_grpc_ab_bench.sh
|
||||||
|
./scripts/test_object_batch_bench_enhanced.sh
|
||||||
|
./scripts/test_exact_1mib_handoff_abba.sh
|
||||||
|
./scripts/test_pinned_paired_abba_bench.sh
|
||||||
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
|
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||||
|
python3 ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||||
|
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
|
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
|
||||||
|
|||||||
+35
-34
@@ -1,17 +1,14 @@
|
|||||||
# nextest configuration for RustFS.
|
# nextest configuration for RustFS.
|
||||||
#
|
#
|
||||||
# Serialize two known load-sensitive / global-state-sharing ecstore test groups
|
# Serialize the ecstore tests that share the process-wide disk registry or
|
||||||
# so the full parallel nextest suite stops producing spurious failures
|
# exercise a multi-disk commit handoff across nextest process boundaries.
|
||||||
# (backlog #937). These tests pass in isolation but flake under the loaded
|
|
||||||
# parallel run for two distinct reasons:
|
|
||||||
#
|
#
|
||||||
# * store::bucket::tests::bucket_delete_* share process/global state (disk
|
# * store::bucket::tests::bucket_delete_* share process/global state (disk
|
||||||
# registry, lock client) and race make_bucket into InsufficientWriteQuorum
|
# registry, lock client) and race make_bucket into InsufficientWriteQuorum
|
||||||
# when run concurrently with other ecstore tests.
|
# when run concurrently with other ecstore tests.
|
||||||
# * bucket_lifecycle_ops::tests::concurrent_resend_same_part_commits_one_generation
|
# * bucket_lifecycle_ops::tests::concurrent_resend_same_part_commits_one_generation
|
||||||
# asserts a lock-acquire correctness property whose serialized cross-disk
|
# uses the shared multipart fixture and a deterministic uploadId-lock
|
||||||
# commits exceed the (already max'd, 60s) acquire deadline only when the
|
# handoff, so it must not overlap another process mutating that fixture.
|
||||||
# suite saturates disk I/O.
|
|
||||||
#
|
#
|
||||||
# serial_test's #[serial] attribute does NOT serialize these across runs:
|
# serial_test's #[serial] attribute does NOT serialize these across runs:
|
||||||
# nextest executes each test in its own process, where the in-process
|
# nextest executes each test in its own process, where the in-process
|
||||||
@@ -39,10 +36,11 @@ ecstore-serial-flaky = { max-threads = 1 }
|
|||||||
# servers never run at once. ci-7's nightly picks these up via the e2e suite;
|
# servers never run at once. ci-7's nightly picks these up via the e2e suite;
|
||||||
# they are deliberately NOT in the fast PR `e2e-smoke` filter.
|
# they are deliberately NOT in the fast PR `e2e-smoke` filter.
|
||||||
e2e-reliability = { max-threads = 1 }
|
e2e-reliability = { max-threads = 1 }
|
||||||
|
e2e-inline-boundaries = { max-threads = 1 }
|
||||||
|
|
||||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||||
[[profile.default.overrides]]
|
[[profile.default.overrides]]
|
||||||
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(/^store::bucket::tests::bucket_delete_(mark_delete_marks|purge_removes|default_s3_delete)/))'
|
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# Serialize the multipart crash-consistency scenarios (dist-2, backlog#1150):
|
# Serialize the multipart crash-consistency scenarios (dist-2, backlog#1150):
|
||||||
@@ -54,6 +52,12 @@ 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 durable manual-transition checkpoint test across nextest's
|
||||||
|
# process boundary; it mutates bucket lifecycle metadata and is not quarantined.
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||||
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# Serialize the 4-disk reliability / degraded-read e2e tests (see the
|
# Serialize the 4-disk reliability / degraded-read e2e tests (see the
|
||||||
# e2e-reliability test-group note above). The matching ci-profile override is at
|
# e2e-reliability test-group note above). The matching ci-profile override is at
|
||||||
# the end of the file, after [profile.ci] is declared.
|
# the end of the file, after [profile.ci] is declared.
|
||||||
@@ -61,6 +65,10 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
||||||
test-group = 'e2e-reliability'
|
test-group = 'e2e-reliability'
|
||||||
|
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||||
|
test-group = 'e2e-inline-boundaries'
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -89,20 +97,18 @@ path = "junit.xml"
|
|||||||
# profile's own overrides list, not the default profile's).
|
# profile's own overrides list, not the default profile's).
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
# QUARANTINE: OPEN backlog#937 — concurrent_resend lock-acquire deadline flakes
|
|
||||||
# under saturated disk I/O in the full parallel suite.
|
|
||||||
[[profile.ci.overrides]]
|
|
||||||
filter = 'package(rustfs-ecstore) & test(concurrent_resend_same_part_commits_one_generation)'
|
|
||||||
test-group = 'ecstore-serial-flaky'
|
|
||||||
retries = 2
|
|
||||||
|
|
||||||
# QUARANTINE: OPEN backlog#937 — store::bucket::tests::bucket_delete_* race
|
# QUARANTINE: OPEN backlog#937 — store::bucket::tests::bucket_delete_* race
|
||||||
# make_bucket into InsufficientWriteQuorum via shared global state under load.
|
# make_bucket into InsufficientWriteQuorum via shared global state under load.
|
||||||
[[profile.ci.overrides]]
|
[[profile.ci.overrides]]
|
||||||
filter = 'package(rustfs-ecstore) & test(/^store::bucket::tests::bucket_delete_(mark_delete_marks|purge_removes|default_s3_delete)/)'
|
filter = 'package(rustfs-ecstore) & test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/)'
|
||||||
test-group = 'ecstore-serial-flaky'
|
test-group = 'ecstore-serial-flaky'
|
||||||
retries = 2
|
retries = 2
|
||||||
|
|
||||||
|
# Keep the deterministic multipart handoff isolated across nextest processes.
|
||||||
|
[[profile.ci.overrides]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(concurrent_resend_same_part_commits_one_generation)'
|
||||||
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# QUARANTINE: OPEN rustfs#4690 — walk_dir stall-budget accounting test depends
|
# QUARANTINE: OPEN rustfs#4690 — walk_dir stall-budget accounting test depends
|
||||||
# on producer/consumer timing windows that stretch past the budget on loaded
|
# on producer/consumer timing windows that stretch past the budget on loaded
|
||||||
# CI runners (regression test for rustfs#4644; failed on a zero-Rust-diff PR).
|
# CI runners (regression test for rustfs#4644; failed on a zero-Rust-diff PR).
|
||||||
@@ -125,6 +131,12 @@ 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 durable manual-transition checkpoint test under the ci profile
|
||||||
|
# too. No retries: failures stay visible.
|
||||||
|
[[profile.ci.overrides]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(manual_transition_page_checkpoint_persists_durable_job_progress)'
|
||||||
|
test-group = 'ecstore-serial-flaky'
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
|
# e2e-smoke profile — PR smoke subset of the e2e_test crate (backlog#1149 ci-4)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -156,7 +168,7 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
# the nightly profile derives its set as "the replication module MINUS this
|
# the nightly profile derives its set as "the replication module MINUS this
|
||||||
# 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. Count invariant: 20 here + 27 nightly = 47 total
|
# regexes byte-identical. Count invariant: 20 here + 28 nightly = 48 total
|
||||||
# (authority: `cargo nextest list`; docs/testing/e2e-suite-inventory.md).
|
# (authority: `cargo nextest list`; docs/testing/e2e-suite-inventory.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
|
||||||
@@ -192,7 +204,7 @@ test-group = 'ecstore-serial-flaky'
|
|||||||
[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|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_source_invalid_date|content_encoding|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud)_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|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|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(/^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::/)
|
||||||
@@ -211,7 +223,7 @@ fail-fast = false
|
|||||||
# and poll until source and target converge; two replicate over HTTPS, two
|
# and poll until source and target converge; two replicate over HTTPS, two
|
||||||
# pin active SSE failure contracts, and one guards event/history observers.
|
# pin active SSE failure contracts, and one guards event/history observers.
|
||||||
# The SSE-S3 contract remains ignored under backlog#1291.
|
# The SSE-S3 contract remains ignored under backlog#1291.
|
||||||
# * 11 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
# * 12 `_real_dual_node` site-replication tests — each spawns TWO full rustfs
|
||||||
# servers and drives the cross-process site-replication control plane.
|
# servers and drives the cross-process site-replication control plane.
|
||||||
# * 1 `_real_three_node` site-replication test.
|
# * 1 `_real_three_node` site-replication test.
|
||||||
# * 1 `_real_single_node` service-account round-trip test.
|
# * 1 `_real_single_node` service-account round-trip test.
|
||||||
@@ -286,31 +298,16 @@ path = "junit.xml"
|
|||||||
# ci-profile quarantine (docs/testing/README.md): every entry MUST cite one
|
# ci-profile quarantine (docs/testing/README.md): every entry MUST cite one
|
||||||
# OPEN issue, and the fixing PR MUST delete the exclusion. The passing
|
# OPEN issue, and the fixing PR MUST delete the exclusion. The passing
|
||||||
# negative-path siblings of each family stay in as regression guards.
|
# negative-path siblings of each family stay in as regression guards.
|
||||||
# * rustfs#4842 — extract/snowball expand pipeline 500s (mtime=0
|
|
||||||
# OffsetDateTime deserialization + same-path failures).
|
|
||||||
# * rustfs#4843 — over-limit archive entry paths hard-reject the whole
|
# * rustfs#4843 — over-limit archive entry paths hard-reject the whole
|
||||||
# archive even under ignore-errors semantics.
|
# archive even under ignore-errors semantics.
|
||||||
# * rustfs#4844 — anonymous POST-object with SSE-S3 / bucket-default SSE
|
|
||||||
# returns 500.
|
|
||||||
# * rustfs#4845 — 403 on allowed anonymous POST object-lock fields and on
|
|
||||||
# the list metadata=true extension.
|
|
||||||
# * rustfs#4846 — distributed-lock quorum tests misclassify as timeout
|
|
||||||
# under parallel load (multi-node in-process clusters; natural home is
|
|
||||||
# ci-7's nightly cluster lane).
|
|
||||||
[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|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||||
& !test(/^replication_extension_test::/)
|
& !test(/^replication_extension_test::/)
|
||||||
& !test(/^multipart_auth_test::test_signed_put_object_extract_(accepts_compat_header|expands_tar_entries_with_prefix_headers|expands_tar_gz_archive|expands_tbz2_archive|expands_tgz_archive|expands_txz_archive|expands_tzst_archive|normalizes_prefix_header_value|preserves_directory_markers_by_default|preserves_object_lock_legal_hold|preserves_object_lock_retention|preserves_pax_metadata_and_version_id|preserves_request_metadata_on_extracted_objects|preserves_sse_c|preserves_sse_s3_and_redirect|preserves_storage_class|uses_bucket_default_sse_s3)$/)
|
|
||||||
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(prefers_exact_minio_prefix_over_suffix_fallback|supports_minio_prefix_and_directory_markers)$/)
|
|
||||||
& !test(/^multipart_auth_test::test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled$/)
|
& !test(/^multipart_auth_test::test_signed_put_object_extract_skips_invalid_entry_when_ignore_errors_enabled$/)
|
||||||
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(ignores_invalid_entries_when_requested|supports_standard_headers_with_combined_extract_options)$/)
|
& !test(/^snowball_auto_extract_test::tests::snowball_auto_extract_(ignores_invalid_entries_when_requested|supports_standard_headers_with_combined_extract_options)$/)
|
||||||
& !test(/^multipart_auth_test::test_anonymous_post_object_(accepts_sse_s3|rejects_sse_s3_missing_from_policy_conditions|uses_bucket_default_sse_kms|uses_bucket_default_sse_s3)$/)
|
|
||||||
& !test(/^multipart_auth_test::test_anonymous_post_object_(accepts_object_lock_legal_hold_field|accepts_object_lock_retention_fields)$/)
|
|
||||||
& !test(/^list_object(s_v2|_versions)_metadata_extension_test::/)
|
|
||||||
& !test(/^reliant::lock::test_distributed_lock_(2_nodes_grpc_read_survives_failed_node|4_nodes_grpc_read_write_quorum_split_with_two_failed_nodes)$/)
|
|
||||||
"""
|
"""
|
||||||
fail-fast = false
|
fail-fast = false
|
||||||
|
|
||||||
@@ -325,3 +322,7 @@ path = "junit.xml"
|
|||||||
[[profile.e2e-full.overrides]]
|
[[profile.e2e-full.overrides]]
|
||||||
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
filter = 'package(e2e_test) & test(/^(reliability_disk_fault|degraded_read_eof_regression)_test::/)'
|
||||||
test-group = 'e2e-reliability'
|
test-group = 'e2e-reliability'
|
||||||
|
|
||||||
|
[[profile.e2e-full.overrides]]
|
||||||
|
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||||
|
test-group = 'e2e-inline-boundaries'
|
||||||
|
|||||||
@@ -60,6 +60,16 @@ The file `prometheus-rules/rustfs-get-optimization-alerts.yaml` contains pre-con
|
|||||||
| `CodecStreamingFallbackSpike` | Warning | Codec streaming fallback > 10x baseline for 10m |
|
| `CodecStreamingFallbackSpike` | Warning | Codec streaming fallback > 10x baseline for 10m |
|
||||||
| `IoQueueSaturation` | Warning | IO queue utilization > 90% for 5m |
|
| `IoQueueSaturation` | Warning | IO queue utilization > 90% for 5m |
|
||||||
|
|
||||||
|
The file `prometheus-rules/rustfs-kms-alerts.yml` contains alerting rules for the KMS backend operation metrics. Thresholds are conservative defaults pending staging baseline calibration; response procedures live in `docs/operations/kms-observability-runbook.md`, and the matching dashboard is `deploy/observability/grafana/rustfs-kms-observability.json`.
|
||||||
|
|
||||||
|
| Alert | Severity | Condition |
|
||||||
|
|-------|----------|-----------|
|
||||||
|
| `KmsBackendFatalErrors` | Critical | Fatal (non-retryable) attempt failures > 0 for 5m |
|
||||||
|
| `KmsBackendHighErrorRate` | Critical | Non-success operation ratio > 5% for 10m (with traffic guard) |
|
||||||
|
| `KmsBackendP99LatencyHigh` | Warning | Operation p99 duration (incl. retries) > 2s for 10m |
|
||||||
|
| `KmsBackendAttemptFailureSpike` | Warning | Attempt failure rate > 0.5/s for 10m |
|
||||||
|
| `KmsBackendRetryBudgetExhausted` | Warning | budget_exhausted / deadline_exceeded outcomes > 0.05/s for 10m |
|
||||||
|
|
||||||
### Enabling Alert Rules
|
### Enabling Alert Rules
|
||||||
|
|
||||||
Add the alert rules file to your Prometheus configuration:
|
Add the alert rules file to your Prometheus configuration:
|
||||||
|
|||||||
@@ -60,6 +60,16 @@
|
|||||||
| `CodecStreamingFallbackSpike` | 警告 | Codec streaming 回退 > 10x 基线,持续 10 分钟 |
|
| `CodecStreamingFallbackSpike` | 警告 | Codec streaming 回退 > 10x 基线,持续 10 分钟 |
|
||||||
| `IoQueueSaturation` | 警告 | IO 队列利用率 > 90%,持续 5 分钟 |
|
| `IoQueueSaturation` | 警告 | IO 队列利用率 > 90%,持续 5 分钟 |
|
||||||
|
|
||||||
|
文件 `prometheus-rules/rustfs-kms-alerts.yml` 包含 KMS 后端操作指标的告警规则。阈值为保守默认值,待 staging 基线校准;响应流程见 `docs/operations/kms-observability-runbook.md`,配套仪表盘为 `deploy/observability/grafana/rustfs-kms-observability.json`。
|
||||||
|
|
||||||
|
| 告警 | 级别 | 条件 |
|
||||||
|
|------|------|------|
|
||||||
|
| `KmsBackendFatalErrors` | 严重 | fatal(不可重试)尝试失败 > 0,持续 5 分钟 |
|
||||||
|
| `KmsBackendHighErrorRate` | 严重 | 非 success 操作占比 > 5%,持续 10 分钟(含流量下限保护) |
|
||||||
|
| `KmsBackendP99LatencyHigh` | 警告 | 操作 p99 耗时(含重试)> 2s,持续 10 分钟 |
|
||||||
|
| `KmsBackendAttemptFailureSpike` | 警告 | 尝试失败率 > 0.5/s,持续 10 分钟 |
|
||||||
|
| `KmsBackendRetryBudgetExhausted` | 警告 | budget_exhausted / deadline_exceeded 结果 > 0.05/s,持续 10 分钟 |
|
||||||
|
|
||||||
### 启用告警规则
|
### 启用告警规则
|
||||||
|
|
||||||
在 Prometheus 配置中添加告警规则文件:
|
在 Prometheus 配置中添加告警规则文件:
|
||||||
|
|||||||
@@ -1744,7 +1744,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (bucket) (rustfs_bucket_api_objects_total{job=~\"$job\", bucket=~\"$bucket\"})",
|
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_objects_count{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||||
"legendFormat": "{{bucket}}",
|
"legendFormat": "{{bucket}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
@@ -1844,7 +1844,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (bucket) (rustfs_bucket_api_usage_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
|
"expr": "max by (job, bucket) (rustfs_cluster_usage_buckets_total_bytes{job=~\"$job\", bucket=~\"$bucket\"})",
|
||||||
"legendFormat": "{{bucket}}",
|
"legendFormat": "{{bucket}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
@@ -11583,7 +11583,7 @@
|
|||||||
"text": "All",
|
"text": "All",
|
||||||
"value": "$__all"
|
"value": "$__all"
|
||||||
},
|
},
|
||||||
"definition": "label_values(rustfs_bucket_api_objects_total,bucket)",
|
"definition": "label_values(rustfs_cluster_usage_buckets_objects_count,bucket)",
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"label": "Bucket",
|
"label": "Bucket",
|
||||||
"multi": true,
|
"multi": true,
|
||||||
@@ -11591,7 +11591,7 @@
|
|||||||
"options": [],
|
"options": [],
|
||||||
"query": {
|
"query": {
|
||||||
"qryType": 1,
|
"qryType": 1,
|
||||||
"query": "label_values(rustfs_bucket_api_objects_total,bucket)",
|
"query": "label_values(rustfs_cluster_usage_buckets_objects_count,bucket)",
|
||||||
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||||
},
|
},
|
||||||
"refresh": 2,
|
"refresh": 2,
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# RustFS KMS backend — Prometheus alerting rules
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# Metric source: the KMS operation-policy choke point in
|
||||||
|
# crates/kms/src/policy.rs. All label values are static enum strings
|
||||||
|
# (operation, op_class, outcome, error_class); key identifiers, key material,
|
||||||
|
# and tokens never appear in labels.
|
||||||
|
#
|
||||||
|
# Response procedures: docs/operations/kms-observability-runbook.md
|
||||||
|
#
|
||||||
|
# IMPORTANT — threshold status: every numeric threshold below is a
|
||||||
|
# conservative default chosen without a production baseline. Calibrate against
|
||||||
|
# a staging baseline before relying on these alerts for paging, and prefer
|
||||||
|
# loosening over tightening until the baseline exists. Formal SLO targets are
|
||||||
|
# deliberately not encoded here (see rustfs/backlog#1584).
|
||||||
|
#
|
||||||
|
# NOTE: prometheus.yml loads /etc/prometheus/rules/*.yml — keep the .yml
|
||||||
|
# extension or the file is silently ignored by the docker-compose stack.
|
||||||
|
#
|
||||||
|
# Validate: promtool check rules rustfs-kms-alerts.yml
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
groups:
|
||||||
|
# ==========================================================================
|
||||||
|
# Critical alerts — immediate action required
|
||||||
|
# ==========================================================================
|
||||||
|
- name: rustfs-kms-critical
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. KmsBackendFatalErrors
|
||||||
|
# Any attempt failure classified as fatal (non-retryable): auth
|
||||||
|
# or permission errors, malformed requests, missing keys. The
|
||||||
|
# policy never retries these, so even a low rate means real
|
||||||
|
# operations are failing right now.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendFatalErrors
|
||||||
|
expr: |
|
||||||
|
sum by (operation) (rate(rustfs_kms_backend_attempt_failures_total{error_class="fatal"}[5m])) > 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend fatal errors on operation {{ $labels.operation }}"
|
||||||
|
description: >-
|
||||||
|
Attempt failures classified as fatal are occurring at
|
||||||
|
{{ $value | printf "%.3f" }}/s on operation
|
||||||
|
{{ $labels.operation }}. Fatal failures are not retried:
|
||||||
|
each one is a KMS backend call that failed permanently
|
||||||
|
(authentication, permissions, malformed request, or a
|
||||||
|
missing key/version).
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendfatalerrors"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. KmsBackendHighErrorRate
|
||||||
|
# Sustained share of operations terminating without success
|
||||||
|
# (fatal, budget_exhausted, deadline_exceeded). The cancelled
|
||||||
|
# outcome is excluded because shutdowns legitimately produce it.
|
||||||
|
# The traffic guard keeps a single failure on a near-idle
|
||||||
|
# cluster from firing the alert.
|
||||||
|
# Threshold: 5% for 10m — conservative default, calibrate
|
||||||
|
# against a staging baseline.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendHighErrorRate
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
sum(rate(rustfs_kms_backend_operations_total{outcome!~"success|cancelled"}[5m]))
|
||||||
|
/
|
||||||
|
clamp_min(sum(rate(rustfs_kms_backend_operations_total[5m])), 1e-9)
|
||||||
|
) > 0.05
|
||||||
|
and
|
||||||
|
sum(rate(rustfs_kms_backend_operations_total[5m])) > 0.02
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend non-success ratio above 5% for 10m"
|
||||||
|
description: >-
|
||||||
|
{{ $value | humanizePercentage }} of KMS backend operations
|
||||||
|
are terminating in fatal, budget_exhausted, or
|
||||||
|
deadline_exceeded. Object encryption and decryption paths
|
||||||
|
depending on the KMS are degraded or failing.
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendhigherrorrate"
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Warning alerts — investigation needed
|
||||||
|
# ==========================================================================
|
||||||
|
- name: rustfs-kms-warning
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. KmsBackendP99LatencyHigh
|
||||||
|
# p99 wall-clock duration of whole operations (attempts plus
|
||||||
|
# backoff) is sustained above 2 seconds. Because the histogram
|
||||||
|
# includes retries, a high p99 usually means the retry policy
|
||||||
|
# is absorbing backend failures, not that every call is slow.
|
||||||
|
# Threshold: 2s for 10m — conservative default, calibrate
|
||||||
|
# against a staging baseline.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendP99LatencyHigh
|
||||||
|
expr: |
|
||||||
|
histogram_quantile(0.99,
|
||||||
|
sum by (le) (rate(rustfs_kms_backend_operation_duration_seconds_bucket[5m]))
|
||||||
|
) > 2
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend operation p99 latency above 2s for 10m"
|
||||||
|
description: >-
|
||||||
|
The 99th-percentile KMS backend operation duration is
|
||||||
|
{{ $value | humanizeDuration }}, including retries and
|
||||||
|
backoff. Encryption and decryption latency is leaking into
|
||||||
|
S3 request latency.
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendp99latencyhigh"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 4. KmsBackendAttemptFailureSpike
|
||||||
|
# Aggregate attempt-failure rate (all error classes) sustained
|
||||||
|
# above an absolute floor. An absolute threshold is used instead
|
||||||
|
# of an offset-1d baseline ratio because fresh deployments have
|
||||||
|
# no baseline and an empty offset vector would keep a ratio
|
||||||
|
# alert from ever firing; switch to a baseline-relative form
|
||||||
|
# (see rustfs-get-optimization-alerts.yaml for the pattern)
|
||||||
|
# once a stable staging baseline exists.
|
||||||
|
# Threshold: 0.5/s for 10m — conservative default, calibrate
|
||||||
|
# against a staging baseline.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendAttemptFailureSpike
|
||||||
|
expr: |
|
||||||
|
sum(rate(rustfs_kms_backend_attempt_failures_total[5m])) > 0.5
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend attempt failures above 0.5/s for 10m"
|
||||||
|
description: >-
|
||||||
|
KMS backend attempts are failing at
|
||||||
|
{{ $value | printf "%.2f" }}/s across all error classes.
|
||||||
|
The retry policy may still be masking these from callers —
|
||||||
|
check the error-class breakdown before it stops absorbing
|
||||||
|
them.
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendattemptfailurespike"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 5. KmsBackendRetryBudgetExhausted
|
||||||
|
# Operations are running out of retry budget (budget_exhausted)
|
||||||
|
# or operation deadline (deadline_exceeded). These surface to
|
||||||
|
# callers as failed KMS operations even though every individual
|
||||||
|
# failure was retryable — the backend is unhealthy for longer
|
||||||
|
# than the policy can bridge.
|
||||||
|
# Threshold: 0.05/s for 10m — conservative default, calibrate
|
||||||
|
# against a staging baseline.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
- alert: KmsBackendRetryBudgetExhausted
|
||||||
|
expr: |
|
||||||
|
sum by (outcome) (rate(rustfs_kms_backend_operations_total{outcome=~"budget_exhausted|deadline_exceeded"}[5m])) > 0.05
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
component: kms
|
||||||
|
annotations:
|
||||||
|
summary: "KMS backend operations exhausting retry budget ({{ $labels.outcome }})"
|
||||||
|
description: >-
|
||||||
|
KMS backend operations are terminating as
|
||||||
|
{{ $labels.outcome }} at {{ $value | printf "%.3f" }}/s.
|
||||||
|
Retryable failures are outlasting the retry budget, so
|
||||||
|
callers are seeing hard failures.
|
||||||
|
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendretrybudgetexhausted"
|
||||||
@@ -18,6 +18,7 @@ set -eu
|
|||||||
ACCESS_KEY="${RUSTFS_SITE_REPL_ACCESS_KEY:-rustfsadmin}"
|
ACCESS_KEY="${RUSTFS_SITE_REPL_ACCESS_KEY:-rustfsadmin}"
|
||||||
SECRET_KEY="${RUSTFS_SITE_REPL_SECRET_KEY:-rustfsadmin}"
|
SECRET_KEY="${RUSTFS_SITE_REPL_SECRET_KEY:-rustfsadmin}"
|
||||||
BUCKET="${RUSTFS_SITE_REPL_FLOW_BUCKET:-site-repl-flow-check}"
|
BUCKET="${RUSTFS_SITE_REPL_FLOW_BUCKET:-site-repl-flow-check}"
|
||||||
|
DELETE_BUCKET="${RUSTFS_SITE_REPL_DELETE_BUCKET:-site-repl-delete-$(date +%Y%m%d-%H%M%S)-$$}"
|
||||||
PREFIX="${RUSTFS_SITE_REPL_FLOW_PREFIX:-flow-$(date +%Y%m%d-%H%M%S)}"
|
PREFIX="${RUSTFS_SITE_REPL_FLOW_PREFIX:-flow-$(date +%Y%m%d-%H%M%S)}"
|
||||||
WAIT_ATTEMPTS="${RUSTFS_SITE_REPL_WAIT_ATTEMPTS:-90}"
|
WAIT_ATTEMPTS="${RUSTFS_SITE_REPL_WAIT_ATTEMPTS:-90}"
|
||||||
WAIT_SLEEP_SECONDS="${RUSTFS_SITE_REPL_WAIT_SLEEP_SECONDS:-2}"
|
WAIT_SLEEP_SECONDS="${RUSTFS_SITE_REPL_WAIT_SLEEP_SECONDS:-2}"
|
||||||
@@ -85,17 +86,39 @@ wait_for_object() {
|
|||||||
|
|
||||||
wait_for_bucket() {
|
wait_for_bucket() {
|
||||||
site="$1"
|
site="$1"
|
||||||
|
bucket="${2:-$BUCKET}"
|
||||||
attempt=1
|
attempt=1
|
||||||
|
|
||||||
while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do
|
while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do
|
||||||
if mc stat "$site/$BUCKET" >/dev/null 2>&1; then
|
if mc stat "$site/$bucket" >/dev/null 2>&1; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
sleep "$WAIT_SLEEP_SECONDS"
|
sleep "$WAIT_SLEEP_SECONDS"
|
||||||
attempt=$((attempt + 1))
|
attempt=$((attempt + 1))
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "bucket was not replicated in time: $site/$BUCKET" >&2
|
echo "bucket was not replicated in time: $site/$bucket" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_bucket_delete() {
|
||||||
|
site="$1"
|
||||||
|
bucket="$2"
|
||||||
|
attempt=1
|
||||||
|
|
||||||
|
while [ "$attempt" -le "$WAIT_ATTEMPTS" ]; do
|
||||||
|
if result="$(mc stat --json "$site/$bucket" 2>&1)"; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
case "$result" in
|
||||||
|
*NoSuchBucket*) return 0 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
sleep "$WAIT_SLEEP_SECONDS"
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "bucket deletion was not replicated in time: $site/$bucket" >&2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +209,20 @@ EOF
|
|||||||
echo "verified replicated downloads for $object_name"
|
echo "verified replicated downloads for $object_name"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo "creating empty bucket for replicated delete check: $DELETE_BUCKET"
|
||||||
|
mc mb "site1/$DELETE_BUCKET" >/dev/null
|
||||||
|
|
||||||
|
for site in site1 site2 site3; do
|
||||||
|
wait_for_bucket "$site" "$DELETE_BUCKET"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "deleting empty bucket on site1: $DELETE_BUCKET"
|
||||||
|
mc rb "site1/$DELETE_BUCKET" >/dev/null
|
||||||
|
|
||||||
|
for site in site1 site2 site3; do
|
||||||
|
wait_for_bucket_delete "$site" "$DELETE_BUCKET"
|
||||||
|
done
|
||||||
|
|
||||||
echo "site replication object flow check passed"
|
echo "site replication object flow check passed"
|
||||||
echo "bucket: $BUCKET"
|
echo "bucket: $BUCKET"
|
||||||
echo "prefix: $PREFIX"
|
echo "prefix: $PREFIX"
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ inputs:
|
|||||||
required: false
|
required: false
|
||||||
default: "rustfs-deps"
|
default: "rustfs-deps"
|
||||||
cache-save-if:
|
cache-save-if:
|
||||||
description: "Condition for saving cache"
|
description: >-
|
||||||
|
Whether to save the cache. The fail-safe default is 'false': a caller that
|
||||||
|
wants to populate a cache must opt in explicitly, so a forgotten input
|
||||||
|
costs a cold cache (minutes) rather than silently consuming the
|
||||||
|
repository-wide 10GB Actions cache quota and evicting other lanes.
|
||||||
required: false
|
required: false
|
||||||
default: "true"
|
default: "false"
|
||||||
install-cross-tools:
|
install-cross-tools:
|
||||||
description: "Install cross-compilation tools"
|
description: "Install cross-compilation tools"
|
||||||
required: false
|
required: false
|
||||||
@@ -36,28 +40,43 @@ inputs:
|
|||||||
description: "Target architecture to add"
|
description: "Target architecture to add"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
github-token:
|
install-build-packaging-tools:
|
||||||
description: "GitHub token for API access"
|
description: >-
|
||||||
|
Install musl-tools/zip/unzip, needed for musl linking and release
|
||||||
|
packaging. Off for CI test lanes, which use none of them.
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: "true"
|
||||||
|
install-test-tools:
|
||||||
|
description: >-
|
||||||
|
Install cargo-nextest and the rustfmt/clippy components. Off for release
|
||||||
|
and audit lanes, which run no tests and no lints.
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
|
# protobuf-compiler is deliberately absent: the setup-protoc step below
|
||||||
|
# installs 34.1 into the tool cache and prepends it to PATH, so the apt
|
||||||
|
# build (older, and never version-matched) was shadowed on every run and
|
||||||
|
# simply never used.
|
||||||
- name: Install system dependencies (Ubuntu)
|
- name: Install system dependencies (Ubuntu)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y \
|
sudo apt-get install -y \
|
||||||
musl-tools \
|
|
||||||
build-essential \
|
build-essential \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
ripgrep \
|
ripgrep
|
||||||
unzip \
|
|
||||||
zip \
|
# musl-gcc is needed by the native musl release leg, and zip/unzip by the
|
||||||
protobuf-compiler
|
# release packaging steps. No CI test lane touches any of them.
|
||||||
|
- name: Install packaging and cross-linking dependencies (Ubuntu)
|
||||||
|
if: runner.os == 'Linux' && inputs.install-build-packaging-tools == 'true'
|
||||||
|
shell: bash
|
||||||
|
run: sudo apt-get install -y musl-tools zip unzip
|
||||||
|
|
||||||
- name: Install protoc
|
- name: Install protoc
|
||||||
uses: rustfs/setup-protoc@a3705324d8f9bf5b6c3573fb6cf8ae421db55dd6 # v3.0.1
|
uses: rustfs/setup-protoc@a3705324d8f9bf5b6c3573fb6cf8ae421db55dd6 # v3.0.1
|
||||||
@@ -75,7 +94,7 @@ runs:
|
|||||||
with:
|
with:
|
||||||
toolchain: ${{ inputs.rust-version }}
|
toolchain: ${{ inputs.rust-version }}
|
||||||
targets: ${{ inputs.target }}
|
targets: ${{ inputs.target }}
|
||||||
components: rustfmt, clippy
|
components: ${{ inputs.install-test-tools == 'true' && 'rustfmt, clippy' || '' }}
|
||||||
|
|
||||||
- name: Install Zig
|
- name: Install Zig
|
||||||
if: inputs.install-cross-tools == 'true'
|
if: inputs.install-cross-tools == 'true'
|
||||||
@@ -86,12 +105,24 @@ runs:
|
|||||||
uses: taiki-e/install-action@a21ae4029b089b9ddc45704028756f51ab8abe48 # cargo-zigbuild
|
uses: taiki-e/install-action@a21ae4029b089b9ddc45704028756f51ab8abe48 # cargo-zigbuild
|
||||||
|
|
||||||
- name: Install cargo-nextest
|
- name: Install cargo-nextest
|
||||||
|
if: inputs.install-test-tools == 'true'
|
||||||
uses: taiki-e/install-action@96c7780c1d8a2b8723e12031def873a434d39d8d # nextest
|
uses: taiki-e/install-action@96c7780c1d8a2b8723e12031def873a434d39d8d # nextest
|
||||||
|
|
||||||
- name: Setup Rust cache
|
- name: Setup Rust cache
|
||||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
with:
|
with:
|
||||||
cache-all-crates: true
|
# false is rust-cache's own default. With true, cleanup.ts returns
|
||||||
|
# *before* pruning ~/.cargo/registry/src, and config.ts archives the
|
||||||
|
# whole registry — so every cache carried the unpacked source tree of
|
||||||
|
# every dependency, not just "a few extra crates".
|
||||||
|
#
|
||||||
|
# No coverage is lost: getPackages runs `cargo metadata --all-features`,
|
||||||
|
# a strict superset of any single lane's feature closure, and -sys crates
|
||||||
|
# are explicitly exempted from pruning (their src timestamps would
|
||||||
|
# otherwise trigger rebuilds). Anything pruned is re-unpacked from the
|
||||||
|
# .crate files still in registry/cache, whose mtimes crates.io
|
||||||
|
# normalises, so cargo fingerprints stay valid.
|
||||||
|
cache-all-crates: false
|
||||||
cache-on-failure: true
|
cache-on-failure: true
|
||||||
shared-key: ${{ inputs.cache-shared-key }}
|
shared-key: ${{ inputs.cache-shared-key }}
|
||||||
save-if: ${{ inputs.cache-save-if }}
|
save-if: ${{ inputs.cache-save-if }}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
+2
-2
@@ -15,8 +15,8 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
document:
|
document:
|
||||||
version: v1
|
version: v2
|
||||||
url: https://github.com/rustfs/cla/blob/main/cla/v1.md
|
url: https://github.com/rustfs/cla/blob/main/cla/v2.md
|
||||||
|
|
||||||
signing:
|
signing:
|
||||||
mode: comment
|
mode: comment
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ documentation impact. Use N/A when there is no expected impact.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Thank you for your contribution! Please ensure your PR follows the community standards ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)). If this is your first contribution, review the [CLA document](https://github.com/rustfs/cla/blob/main/cla/v1.md) and sign it by commenting `I have read and agree to the CLA.` on the PR.
|
Thank you for your contribution! Please ensure your PR follows the community standards ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)). If this is your first contribution, review the [CLA document](https://github.com/rustfs/cla/blob/main/cla/v2.md) and sign it by commenting `I have read and agree to the CLA.` on the PR.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -45,8 +46,11 @@ jobs:
|
|||||||
name: Architecture Migration Rules
|
name: Architecture Migration Rules
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Install ripgrep
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ on:
|
|||||||
- 'deny.toml'
|
- 'deny.toml'
|
||||||
- '.github/actions/**'
|
- '.github/actions/**'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
|
- 'scripts/release/create_or_update_release.sh'
|
||||||
|
- 'scripts/security/check_preview_release_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 ]
|
||||||
@@ -33,9 +35,16 @@ on:
|
|||||||
- 'deny.toml'
|
- 'deny.toml'
|
||||||
- '.github/actions/**'
|
- '.github/actions/**'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
|
- 'scripts/release/create_or_update_release.sh'
|
||||||
|
- 'scripts/security/check_preview_release_workflow.sh'
|
||||||
- 'scripts/security/check_workflow_pins.sh'
|
- 'scripts/security/check_workflow_pins.sh'
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 3 * * 0' # Weekly on Sunday 03:00 UTC (staggered after the midnight ci/build crons)
|
# Daily, not weekly. This schedule exists to catch RustSec advisories
|
||||||
|
# published against an unchanged dependency tree; at weekly cadence a new
|
||||||
|
# advisory could sit unnoticed for seven days. The check list is unchanged —
|
||||||
|
# splitting it into a light daily advisories-only run and a weekly full run
|
||||||
|
# would create runs where sources/bans/licenses go unverified.
|
||||||
|
- cron: '0 3 * * *' # Daily 03:00 UTC (staggered after the midnight ci/build crons)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -55,6 +64,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -70,11 +80,32 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
|
||||||
- name: Setup Rust environment
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
with:
|
with:
|
||||||
cache-shared-key: rustfs-cargo-deny
|
persist-credentials: false
|
||||||
|
|
||||||
|
# cargo-deny compiles nothing, so the full setup composite (apt packages,
|
||||||
|
# protoc, flatc, nextest, rustfmt/clippy) was pure overhead here. It does
|
||||||
|
# still need a real cargo: `cargo deny check` runs `cargo metadata`, and
|
||||||
|
# Cargo.toml pins datafusion and s3s as git dependencies, which must be
|
||||||
|
# materialised into ~/.cargo/git — a cold clone is hundreds of MB, so the
|
||||||
|
# cache stays.
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||||
|
|
||||||
|
# Was relying on the composite's default, which used to be "true": every
|
||||||
|
# PR touching Cargo.toml/Cargo.lock saved a second, PR-scoped copy of this
|
||||||
|
# cache and pushed the main-scoped lanes out of the 10GB quota. The
|
||||||
|
# default is now "false", but state it explicitly — see
|
||||||
|
# scripts/security/check_cache_save_if.sh.
|
||||||
|
- name: Setup Rust cache
|
||||||
|
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||||
|
with:
|
||||||
|
# Same reasoning as the setup composite: true archives every
|
||||||
|
# dependency's unpacked source tree.
|
||||||
|
cache-all-crates: false
|
||||||
|
cache-on-failure: true
|
||||||
|
shared-key: rustfs-cargo-deny
|
||||||
|
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Install cargo-deny
|
- name: Install cargo-deny
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||||
@@ -92,13 +123,28 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Report unpinned GitHub Actions
|
- name: Report unpinned GitHub Actions
|
||||||
run: ./scripts/security/check_workflow_pins.sh --enforce
|
run: ./scripts/security/check_workflow_pins.sh --enforce
|
||||||
|
|
||||||
|
- name: Check setup cache-save-if is explicit
|
||||||
|
run: ./scripts/security/check_cache_save_if.sh
|
||||||
|
|
||||||
|
- name: Check every job declares a timeout
|
||||||
|
run: ./scripts/security/check_job_timeouts.sh
|
||||||
|
|
||||||
|
- name: Check checkouts clear their credentials
|
||||||
|
run: ./scripts/security/check_persist_credentials.sh
|
||||||
|
|
||||||
|
- name: Check preview release workflow policy
|
||||||
|
run: ./scripts/security/check_preview_release_workflow.sh
|
||||||
|
|
||||||
dependency-review:
|
dependency-review:
|
||||||
name: Dependency Review
|
name: Dependency Review
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
if: github.event_name == 'pull_request' && github.event.action != 'closed'
|
if: github.event_name == 'pull_request' && github.event.action != 'closed'
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -106,6 +152,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Dependency Review
|
- name: Dependency Review
|
||||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5
|
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5
|
||||||
@@ -118,3 +166,28 @@ jobs:
|
|||||||
# conscious re-review of the license/provenance claim (backlog#1181).
|
# conscious re-review of the license/provenance claim (backlog#1181).
|
||||||
allow-dependencies-licenses: pkg:cargo/rustfs-uring@0.1.0
|
allow-dependencies-licenses: pkg:cargo/rustfs-uring@0.1.0
|
||||||
comment-summary-in-pr: always
|
comment-summary-in-pr: always
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
# dependency-review is deliberately excluded: it only runs on pull_request,
|
||||||
|
# so it can never contribute a failure to a scheduled run.
|
||||||
|
needs: [cargo-deny, workflow-pin-report]
|
||||||
|
# A scheduled cargo-deny failure usually means the dependency tree just
|
||||||
|
# matched a newly published advisory — the single most important signal this
|
||||||
|
# workflow produces, and until now it was only visible to whoever happened to
|
||||||
|
# open the Actions tab. Same ci-8 mechanism coverage.yml and
|
||||||
|
# e2e-replication-nightly.yml already use.
|
||||||
|
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 }}
|
||||||
|
|||||||
+89
-85
@@ -50,12 +50,18 @@ on:
|
|||||||
- "**/*.svg"
|
- "**/*.svg"
|
||||||
- ".gitignore"
|
- ".gitignore"
|
||||||
- ".dockerignore"
|
- ".dockerignore"
|
||||||
|
- "flake.lock"
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 1 * * 0" # Weekly on Sunday 01:00 UTC (staggered after the ci.yml midnight cron)
|
- cron: "0 1 * * 0" # Weekly on Sunday 01:00 UTC (staggered after the ci.yml midnight cron)
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
build_docker:
|
build_docker:
|
||||||
description: "Build and push Docker images after binary build"
|
# Advisory only. docker.yml triggers on workflow_run and its job-level
|
||||||
|
# condition requires the triggering event to be a tag push, so a manual
|
||||||
|
# dispatch of this workflow never produces images regardless of this
|
||||||
|
# value. Kept because the summary step reports it; wiring it up would
|
||||||
|
# mean teaching docker.yml's version parser a second event shape.
|
||||||
|
description: "Build and push Docker images after binary build (ignored: dispatch runs never reach docker.yml)"
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -83,6 +89,7 @@ jobs:
|
|||||||
build-check:
|
build-check:
|
||||||
name: Build Strategy Check
|
name: Build Strategy Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
should_build: ${{ steps.check.outputs.should_build }}
|
should_build: ${{ steps.check.outputs.should_build }}
|
||||||
build_type: ${{ steps.check.outputs.build_type }}
|
build_type: ${{ steps.check.outputs.build_type }}
|
||||||
@@ -92,6 +99,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Determine build strategy
|
- name: Determine build strategy
|
||||||
id: check
|
id: check
|
||||||
@@ -107,13 +116,21 @@ jobs:
|
|||||||
|
|
||||||
# Determine build type based on trigger
|
# Determine build type based on trigger
|
||||||
if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then
|
if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then
|
||||||
# Tag push - release or prerelease
|
# Tag push - preview, release, or prerelease
|
||||||
should_build=true
|
should_build=true
|
||||||
tag_name="${GITHUB_REF#refs/tags/}"
|
tag_name="${GITHUB_REF#refs/tags/}"
|
||||||
version="${tag_name}"
|
version="${tag_name}"
|
||||||
|
|
||||||
# Check if this is a prerelease
|
# Preview tags publish a GitHub prerelease for validation, but
|
||||||
if [[ "$tag_name" == *"alpha"* ]] || [[ "$tag_name" == *"beta"* ]] || [[ "$tag_name" == *"rc"* ]]; then
|
# must not update any latest channel.
|
||||||
|
if [[ "$tag_name" =~ -preview\.[0-9]+$ ]]; then
|
||||||
|
build_type="preview"
|
||||||
|
is_prerelease=true
|
||||||
|
echo "🔍 Preview build detected: $tag_name"
|
||||||
|
elif [[ "$tag_name" == *"-preview"* ]]; then
|
||||||
|
echo "❌ Invalid preview tag: $tag_name (expected suffix: -preview.<number>)" >&2
|
||||||
|
exit 1
|
||||||
|
elif [[ "$tag_name" == *"alpha"* ]] || [[ "$tag_name" == *"beta"* ]] || [[ "$tag_name" == *"rc"* ]]; then
|
||||||
build_type="prerelease"
|
build_type="prerelease"
|
||||||
is_prerelease=true
|
is_prerelease=true
|
||||||
echo "🚀 Prerelease build detected: $tag_name"
|
echo "🚀 Prerelease build detected: $tag_name"
|
||||||
@@ -156,6 +173,7 @@ jobs:
|
|||||||
name: Prepare Platform Matrix
|
name: Prepare Platform Matrix
|
||||||
needs: build-check
|
needs: build-check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
matrix: ${{ steps.select.outputs.matrix }}
|
matrix: ${{ steps.select.outputs.matrix }}
|
||||||
selected: ${{ steps.select.outputs.selected }}
|
selected: ${{ steps.select.outputs.selected }}
|
||||||
@@ -163,10 +181,14 @@ jobs:
|
|||||||
- name: Select target platforms
|
- name: Select target platforms
|
||||||
id: select
|
id: select
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
# via env, not interpolation: a dispatch input is free-form text and
|
||||||
|
# would otherwise be pasted into the script for bash to evaluate.
|
||||||
|
RAW_PLATFORMS: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.platforms || 'all' }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
selected="${{ github.event_name == 'workflow_dispatch' && github.event.inputs.platforms || 'all' }}"
|
selected="$RAW_PLATFORMS"
|
||||||
selected="$(echo "${selected}" | tr -d '[:space:]')"
|
selected="$(echo "${selected}" | tr -d '[:space:]')"
|
||||||
if [[ -z "${selected}" ]]; then
|
if [[ -z "${selected}" ]]; then
|
||||||
selected="all"
|
selected="all"
|
||||||
@@ -237,6 +259,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
@@ -245,9 +268,17 @@ jobs:
|
|||||||
rust-version: stable
|
rust-version: stable
|
||||||
target: ${{ matrix.target }}
|
target: ${{ matrix.target }}
|
||||||
cache-shared-key: build-${{ matrix.target }}
|
cache-shared-key: build-${{ matrix.target }}
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
# main only. A cache saved on refs/tags/X is scoped to that tag: no
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }}
|
# other tag, no main run and no PR can restore it, so every release
|
||||||
|
# cycle wrote up to 12 entries of 1-2GB (preview tag plus final tag,
|
||||||
|
# six legs each) that nobody could read, evicting the hot lanes from
|
||||||
|
# the repo-wide 10GB quota. Tag builds still restore the main-scoped
|
||||||
|
# cache, since default-branch caches are readable from every ref.
|
||||||
|
# The one real cost: re-running a failed leg of the same tag no longer
|
||||||
|
# finds that tag's own warm cache and falls back to main's.
|
||||||
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
install-cross-tools: ${{ matrix.cross }}
|
install-cross-tools: ${{ matrix.cross }}
|
||||||
|
install-test-tools: 'false'
|
||||||
|
|
||||||
- name: Download static console assets
|
- name: Download static console assets
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -694,9 +725,14 @@ jobs:
|
|||||||
needs: [ build-check, build-rustfs ]
|
needs: [ build-check, build-rustfs ]
|
||||||
if: always() && needs.build-check.outputs.should_build == 'true'
|
if: always() && needs.build-check.outputs.should_build == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Build completion summary
|
- name: Build completion summary
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
# dispatch input via env: free-form text must not be pasted into the
|
||||||
|
# script for bash to evaluate.
|
||||||
|
INPUT_BUILD_DOCKER: ${{ github.event.inputs.build_docker }}
|
||||||
run: |
|
run: |
|
||||||
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
||||||
VERSION="${{ needs.build-check.outputs.version }}"
|
VERSION="${{ needs.build-check.outputs.version }}"
|
||||||
@@ -714,6 +750,10 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
case "$BUILD_TYPE" in
|
case "$BUILD_TYPE" in
|
||||||
|
"preview")
|
||||||
|
echo "🔍 Preview artifacts are published in a GitHub prerelease"
|
||||||
|
echo "⏭️ Preview releases do not update latest channels"
|
||||||
|
;;
|
||||||
"development")
|
"development")
|
||||||
echo "🛠️ Development build artifacts have been uploaded to OSS dev directory"
|
echo "🛠️ Development build artifacts have been uploaded to OSS dev directory"
|
||||||
echo "⚠️ This is a development build - not suitable for production use"
|
echo "⚠️ This is a development build - not suitable for production use"
|
||||||
@@ -732,7 +772,9 @@ jobs:
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🐳 Docker Images:"
|
echo "🐳 Docker Images:"
|
||||||
if [[ "${{ github.event.inputs.build_docker }}" == "false" ]]; then
|
if [[ "$BUILD_TYPE" == "preview" ]]; then
|
||||||
|
echo "⏭️ Preview tags do not publish Docker images"
|
||||||
|
elif [[ "$INPUT_BUILD_DOCKER" == "false" ]]; then
|
||||||
echo "⏭️ Docker image build was skipped (binary only build)"
|
echo "⏭️ Docker image build was skipped (binary only build)"
|
||||||
elif [[ "$BUILD_STATUS" == "success" ]]; then
|
elif [[ "$BUILD_STATUS" == "success" ]]; then
|
||||||
echo "🔄 Docker images will be built and pushed automatically via workflow_run event"
|
echo "🔄 Docker images will be built and pushed automatically via workflow_run event"
|
||||||
@@ -740,12 +782,13 @@ jobs:
|
|||||||
echo "❌ Docker image build will be skipped due to build failure"
|
echo "❌ Docker image build will be skipped due to build failure"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create GitHub Release (only for tag pushes)
|
# Create GitHub Release for every valid release tag, including previews
|
||||||
create-release:
|
create-release:
|
||||||
name: Create GitHub Release
|
name: Create GitHub Release
|
||||||
needs: [ build-check, build-rustfs ]
|
needs: [ build-check, build-rustfs ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
outputs:
|
outputs:
|
||||||
@@ -755,6 +798,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
persist-credentials: false
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
@@ -767,9 +811,12 @@ jobs:
|
|||||||
VERSION="${{ needs.build-check.outputs.version }}"
|
VERSION="${{ needs.build-check.outputs.version }}"
|
||||||
IS_PRERELEASE="${{ needs.build-check.outputs.is_prerelease }}"
|
IS_PRERELEASE="${{ needs.build-check.outputs.is_prerelease }}"
|
||||||
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
||||||
|
TARGET_COMMITISH=$(git rev-parse --verify "refs/tags/${TAG}^{commit}")
|
||||||
|
|
||||||
# Determine release type for title
|
# Determine release type for title
|
||||||
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
if [[ "$BUILD_TYPE" == "preview" ]]; then
|
||||||
|
RELEASE_TYPE="preview"
|
||||||
|
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||||
if [[ "$TAG" == *"alpha"* ]]; then
|
if [[ "$TAG" == *"alpha"* ]]; then
|
||||||
RELEASE_TYPE="alpha"
|
RELEASE_TYPE="alpha"
|
||||||
elif [[ "$TAG" == *"beta"* ]]; then
|
elif [[ "$TAG" == *"beta"* ]]; then
|
||||||
@@ -783,61 +830,34 @@ jobs:
|
|||||||
RELEASE_TYPE="release"
|
RELEASE_TYPE="release"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if release already exists
|
# Create release title
|
||||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
echo "Release $TAG already exists"
|
TITLE="RustFS $VERSION (${RELEASE_TYPE})"
|
||||||
RELEASE_ID=$(gh release view "$TAG" --json databaseId --jq '.databaseId')
|
|
||||||
RELEASE_URL=$(gh release view "$TAG" --json url --jq '.url')
|
|
||||||
else
|
else
|
||||||
# Get release notes from tag message
|
TITLE="RustFS $VERSION"
|
||||||
RELEASE_NOTES=$(git tag -l --format='%(contents)' "${TAG}")
|
|
||||||
if [[ -z "$RELEASE_NOTES" || "$RELEASE_NOTES" =~ ^[[:space:]]*$ ]]; then
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
RELEASE_NOTES="Pre-release ${VERSION} (${RELEASE_TYPE})"
|
|
||||||
else
|
|
||||||
RELEASE_NOTES="Release ${VERSION}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create release title
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
TITLE="RustFS $VERSION (${RELEASE_TYPE})"
|
|
||||||
else
|
|
||||||
TITLE="RustFS $VERSION"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create the release
|
|
||||||
PRERELEASE_FLAG=""
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
PRERELEASE_FLAG="--prerelease"
|
|
||||||
fi
|
|
||||||
|
|
||||||
gh release create "$TAG" \
|
|
||||||
--title "$TITLE" \
|
|
||||||
--notes "$RELEASE_NOTES" \
|
|
||||||
$PRERELEASE_FLAG \
|
|
||||||
--draft
|
|
||||||
|
|
||||||
RELEASE_ID=$(gh release view "$TAG" --json databaseId --jq '.databaseId')
|
|
||||||
RELEASE_URL=$(gh release view "$TAG" --json url --jq '.url')
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
./scripts/release/create_or_update_release.sh \
|
||||||
echo "release_url=$RELEASE_URL" >> "$GITHUB_OUTPUT"
|
"$TAG" \
|
||||||
echo "Created release: $RELEASE_URL"
|
"$TARGET_COMMITISH" \
|
||||||
|
"$TITLE" \
|
||||||
|
"$IS_PRERELEASE"
|
||||||
|
|
||||||
# Prepare and upload release assets
|
# Prepare and upload release assets
|
||||||
upload-release-assets:
|
upload-release-assets:
|
||||||
name: Upload Release Assets
|
name: Upload Release Assets
|
||||||
needs: [ build-check, build-rustfs, create-release ]
|
needs: [ build-check, build-rustfs, create-release ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
actions: read
|
actions: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download all build artifacts
|
- name: Download all build artifacts
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
@@ -920,9 +940,10 @@ jobs:
|
|||||||
# the pointed-to version is a prerelease.
|
# the pointed-to version is a prerelease.
|
||||||
update-latest-version:
|
update-latest-version:
|
||||||
name: Update Latest Version
|
name: Update Latest Version
|
||||||
needs: [ build-check, upload-release-assets ]
|
needs: [ build-check, publish-release ]
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Update latest.json
|
- name: Update latest.json
|
||||||
env:
|
env:
|
||||||
@@ -980,51 +1001,34 @@ jobs:
|
|||||||
publish-release:
|
publish-release:
|
||||||
name: Publish Release
|
name: Publish Release
|
||||||
needs: [ build-check, create-release, upload-release-assets ]
|
needs: [ build-check, create-release, upload-release-assets ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Publish release
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
|
|
||||||
- name: Update release notes and publish
|
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ needs.build-check.outputs.version }}"
|
TAG="${{ needs.build-check.outputs.version }}"
|
||||||
VERSION="${{ needs.build-check.outputs.version }}"
|
|
||||||
IS_PRERELEASE="${{ needs.build-check.outputs.is_prerelease }}"
|
|
||||||
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
||||||
|
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||||
|
|
||||||
# Determine release type
|
# Publish the release and correct its channel state on retries.
|
||||||
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
# Only a stable final release may become GitHub Latest.
|
||||||
if [[ "$TAG" == *"alpha"* ]]; then
|
if [[ "$BUILD_TYPE" == "release" ]]; then
|
||||||
RELEASE_TYPE="alpha"
|
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
|
||||||
elif [[ "$TAG" == *"beta"* ]]; then
|
-F draft=false \
|
||||||
RELEASE_TYPE="beta"
|
-F prerelease=false \
|
||||||
elif [[ "$TAG" == *"rc"* ]]; then
|
-f make_latest=true >/dev/null
|
||||||
RELEASE_TYPE="rc"
|
|
||||||
else
|
|
||||||
RELEASE_TYPE="prerelease"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
RELEASE_TYPE="release"
|
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
|
||||||
|
-F draft=false \
|
||||||
|
-F prerelease=true \
|
||||||
|
-f make_latest=false >/dev/null
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get original release notes from tag
|
|
||||||
ORIGINAL_NOTES=$(git tag -l --format='%(contents)' "${TAG}")
|
|
||||||
if [[ -z "$ORIGINAL_NOTES" || "$ORIGINAL_NOTES" =~ ^[[:space:]]*$ ]]; then
|
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
|
||||||
ORIGINAL_NOTES="Pre-release ${VERSION} (${RELEASE_TYPE})"
|
|
||||||
else
|
|
||||||
ORIGINAL_NOTES="Release ${VERSION}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Publish the release (remove draft status)
|
|
||||||
gh release edit "$TAG" --draft=false
|
|
||||||
|
|
||||||
echo "🎉 Released $TAG successfully!"
|
echo "🎉 Released $TAG successfully!"
|
||||||
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
# Sole writer of the Rust dependency caches that ci.yml restores.
|
||||||
|
#
|
||||||
|
# Why this is a separate workflow rather than steps inside ci.yml: ci.yml's
|
||||||
|
# concurrency group cancels in-progress runs on main pushes, and merges land far
|
||||||
|
# faster than its 70-minute pipeline. Measured over 15 consecutive main pushes:
|
||||||
|
# 12 cancelled, 2 failed, 0 succeeded. A cancelled run never reaches
|
||||||
|
# Swatinem/rust-cache's post step (cache-on-failure does not cover cancellation),
|
||||||
|
# so the writer lanes were saving nothing and every PR paid a cold restore —
|
||||||
|
# 11.8-20.9 minutes of "Setup Rust environment" against 0.7-3.4 warm.
|
||||||
|
#
|
||||||
|
# Splitting cache writing out of the test pipeline lets ci.yml keep cancelling
|
||||||
|
# superseded runs (which is correct — nobody needs test results for a commit
|
||||||
|
# that is already three merges behind) while the caches still get written.
|
||||||
|
#
|
||||||
|
# The group below deliberately does NOT cancel in progress. GitHub keeps at most
|
||||||
|
# one running plus one pending run per group, so a burst of merges collapses
|
||||||
|
# into "current run finishes, newest queued run follows" rather than a pile-up.
|
||||||
|
# That also bounds this workflow to one self-hosted runner at a time.
|
||||||
|
#
|
||||||
|
# Each job below owns exactly one shared-key and is the only place that sets
|
||||||
|
# cache-save-if to anything but 'false' for it; every lane in ci.yml reads.
|
||||||
|
# scripts/security/check_cache_save_if.sh keeps the declarations explicit.
|
||||||
|
#
|
||||||
|
# The builds are supersets of what the reading lanes compile, because a reader
|
||||||
|
# restores only what the writer saved. Feature resolution matters here: a lane
|
||||||
|
# built with e2e-test-hooks resolves dependency features differently, which
|
||||||
|
# changes -Cmetadata, so the plain build does not cover it. See
|
||||||
|
# rustfs/backlog#1600.
|
||||||
|
|
||||||
|
name: Cache Warm
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
# Mirrors ci.yml's push paths-ignore: if a commit cannot change what ci.yml
|
||||||
|
# compiles, it cannot change what ci.yml needs restored either.
|
||||||
|
paths-ignore:
|
||||||
|
- "**.md"
|
||||||
|
- "docs/**"
|
||||||
|
- "deploy/**"
|
||||||
|
- "scripts/dev_*.sh"
|
||||||
|
- "scripts/probe.sh"
|
||||||
|
- "LICENSE*"
|
||||||
|
- ".gitignore"
|
||||||
|
- ".dockerignore"
|
||||||
|
- "README*"
|
||||||
|
- "**/*.png"
|
||||||
|
- "**/*.jpg"
|
||||||
|
- "**/*.svg"
|
||||||
|
- ".github/workflows/build.yml"
|
||||||
|
- ".github/workflows/docker.yml"
|
||||||
|
- ".github/workflows/audit.yml"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
emit_timings:
|
||||||
|
description: >-
|
||||||
|
Also emit cargo --timings for the ci-dev build and upload it. Used to
|
||||||
|
decide whether sccache is worth adopting (rustfs/backlog#1601 gate).
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: cache-warm
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Readers: test-and-lint, test-ilm-integration-serial, build-rustfs-debug-binary,
|
||||||
|
# e2e-tests, e2e-full.
|
||||||
|
warm-ci-dev:
|
||||||
|
name: Warm ci-dev
|
||||||
|
runs-on: sm-standard-4
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
cache-shared-key: ci-dev
|
||||||
|
cache-save-if: 'true'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
# rustfs/backlog#1601 gate. sccache can only cache compilation units whose
|
||||||
|
# --emit includes link, so it covers workspace rlibs and nothing else:
|
||||||
|
# clippy is metadata-only, and the ~100 test binaries, the rustfs bin and
|
||||||
|
# every build script invoke the system linker. Before spending a bucket,
|
||||||
|
# credentials and a supply-chain boundary on it, measure how much of the
|
||||||
|
# build is actually rlib codegen.
|
||||||
|
#
|
||||||
|
# Read from the report: workspace lib codegen as a share of the build, and
|
||||||
|
# s3select-query's own rlib as a share. The plan adopts sccache only above
|
||||||
|
# 50% and 25% respectively; if linking dominates instead, the answer is
|
||||||
|
# mold/lld plus split-debuginfo, which is exactly the part sccache cannot
|
||||||
|
# touch. Off by default — this doubles the ci-dev build.
|
||||||
|
- name: Build ci-dev superset (with --timings)
|
||||||
|
if: inputs.emit_timings
|
||||||
|
env:
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
run: cargo build --workspace --all-targets --timings
|
||||||
|
|
||||||
|
- name: Upload cargo timings report
|
||||||
|
if: inputs.emit_timings
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: cargo-timings-ci-dev
|
||||||
|
path: target/cargo-timings/
|
||||||
|
retention-days: 30
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# --all-targets covers the test binaries nextest builds, including
|
||||||
|
# e2e_test, which test-and-lint's own run excludes. The second build adds
|
||||||
|
# the e2e-test-hooks feature resolution that build-rustfs-debug-binary uses
|
||||||
|
# and that no lint lane enables.
|
||||||
|
- name: Build ci-dev superset
|
||||||
|
env:
|
||||||
|
# Same limit ci.yml puts on its nextest step: this builds the same
|
||||||
|
# ~100 workspace test binaries, and three concurrent links saturate the
|
||||||
|
# self-hosted runner's overlay I/O and can wedge Cargo (#5394).
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
run: |
|
||||||
|
cargo build --workspace --all-targets
|
||||||
|
cargo build -p rustfs --bins --features e2e-test-hooks
|
||||||
|
|
||||||
|
# Runs before rust-cache's post step, so these are the sizes it is about
|
||||||
|
# to archive. Reported so the cache-all-crates decision stays evidence-led:
|
||||||
|
# registry/src is what that flag prunes, registry/cache is what the pruned
|
||||||
|
# sources are re-unpacked from. See rustfs/backlog#1600.
|
||||||
|
- name: Report cache input sizes
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "### Cache input sizes (ci-dev)"
|
||||||
|
echo '```'
|
||||||
|
du -sh ~/.cargo/registry/src ~/.cargo/registry/cache ~/.cargo/registry/index \
|
||||||
|
~/.cargo/git target 2>/dev/null || true
|
||||||
|
echo '```'
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
# Readers: test-and-lint-rio-v2, build-rustfs-debug-binary-rio-v2.
|
||||||
|
warm-ci-feat-rio:
|
||||||
|
name: Warm ci-feat-rio
|
||||||
|
runs-on: sm-standard-4
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
cache-shared-key: ci-feat-rio
|
||||||
|
cache-save-if: 'true'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Build ci-feat-rio superset
|
||||||
|
run: |
|
||||||
|
cargo build -p rustfs -p rustfs-ecstore --all-targets --features rio-v2
|
||||||
|
cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
||||||
|
|
||||||
|
# Readers: the swift and sftp legs of test-and-lint-protocols. Built in
|
||||||
|
# sequence rather than as `--features swift,sftp`, which is a combination no
|
||||||
|
# lane actually compiles; running both leaves the union in target/.
|
||||||
|
warm-ci-feat-proto:
|
||||||
|
name: Warm ci-feat-proto
|
||||||
|
runs-on: sm-standard-4
|
||||||
|
timeout-minutes: 90
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
cache-shared-key: ci-feat-proto
|
||||||
|
cache-save-if: 'true'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Build ci-feat-proto superset
|
||||||
|
run: |
|
||||||
|
cargo build -p rustfs -p rustfs-protocols --all-targets --features swift
|
||||||
|
cargo build -p rustfs -p rustfs-protocols --all-targets --features sftp
|
||||||
|
|
||||||
|
# Reader: uring-integration. Runs on ubuntu-latest to match it: rust-cache's
|
||||||
|
# key covers runner.os and arch but not the runner label or image, so a cache
|
||||||
|
# written on sm-standard-4 would be restored by the hosted runner as if it
|
||||||
|
# belonged to it.
|
||||||
|
warm-ci-uring:
|
||||||
|
name: Warm ci-uring
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 60
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup Rust environment
|
||||||
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
rust-version: stable
|
||||||
|
cache-shared-key: ci-uring
|
||||||
|
cache-save-if: 'true'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
|
||||||
|
|
||||||
|
- name: Build ci-uring superset
|
||||||
|
run: cargo build -p rustfs-ecstore --all-targets
|
||||||
@@ -12,18 +12,24 @@
|
|||||||
# 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 the required "Test and Lint" status check.
|
# Companion to ci.yml for required status checks.
|
||||||
#
|
#
|
||||||
# ci.yml skips docs-only pull requests via paths-ignore, but the branch
|
# ci.yml skips docs-only pull requests via paths-ignore, but the branch ruleset
|
||||||
# ruleset requires a check named "Test and Lint" — without this workflow a
|
# requires a check named "Test and Lint" — without this workflow a docs-only PR
|
||||||
# docs-only PR would wait on that check forever. This workflow triggers on
|
# would wait on it forever. This workflow triggers on exactly the paths ci.yml
|
||||||
# exactly the paths ci.yml ignores and reports an instant success under the
|
# ignores and reports success under the same job name. Mixed PRs trigger both
|
||||||
# same job name. Mixed PRs trigger both workflows and the real check still
|
# workflows and the real check still gates: a required check with any failing
|
||||||
# gates: a required check with any failing run blocks the merge.
|
# 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
|
# 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
|
# Keep the paths list below in sync with the pull_request paths-ignore list
|
||||||
# in ci.yml.
|
# 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)
|
||||||
|
|
||||||
@@ -47,17 +53,82 @@ on:
|
|||||||
- ".github/workflows/build.yml"
|
- ".github/workflows/build.yml"
|
||||||
- ".github/workflows/docker.yml"
|
- ".github/workflows/docker.yml"
|
||||||
- ".github/workflows/audit.yml"
|
- ".github/workflows/audit.yml"
|
||||||
|
- "flake.lock"
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-and-lint:
|
# Deliberately NOT a bare `echo`. Once "Quick Checks" becomes a required
|
||||||
name: Test and Lint
|
# check, ci.yml gates every expensive job behind it, so a mixed PR reports
|
||||||
|
# two check runs with this name: the real one (45-51s) and this companion.
|
||||||
|
# GitHub has no written contract for how it picks between same-named
|
||||||
|
# required check runs ("latest wins" vs "any failure blocks"), so instead of
|
||||||
|
# relying on ordering we make both runs execute the same commands against
|
||||||
|
# the same merge ref — their conclusions are then necessarily identical and
|
||||||
|
# the choice does not matter. Keep these steps byte-identical to the
|
||||||
|
# quick-checks job in ci.yml (a guard script that asserts this, and the paths
|
||||||
|
# sync below, is tracked in rustfs/backlog#1603).
|
||||||
|
#
|
||||||
|
# For a genuinely docs-only PR this adds no strictness (no code changed, so
|
||||||
|
# fmt and the guards always pass) and costs ~50s of ubuntu-latest.
|
||||||
|
quick-checks:
|
||||||
|
name: Quick Checks
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install ripgrep
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||||
|
|
||||||
|
- 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 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 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:
|
||||||
|
name: Test and Lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
# Docs-only PRs skip the full code CI, but they are exactly where a
|
# Docs-only PRs skip the full code CI, but they are exactly where a
|
||||||
# planning-type document could be slipped in (git add -f bypasses
|
# planning-type document could be slipped in (git add -f bypasses
|
||||||
|
|||||||
+260
-60
@@ -33,6 +33,7 @@ on:
|
|||||||
- ".github/workflows/build.yml"
|
- ".github/workflows/build.yml"
|
||||||
- ".github/workflows/docker.yml"
|
- ".github/workflows/docker.yml"
|
||||||
- ".github/workflows/audit.yml"
|
- ".github/workflows/audit.yml"
|
||||||
|
- "flake.lock"
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
@@ -54,6 +55,7 @@ on:
|
|||||||
- ".github/workflows/build.yml"
|
- ".github/workflows/build.yml"
|
||||||
- ".github/workflows/docker.yml"
|
- ".github/workflows/docker.yml"
|
||||||
- ".github/workflows/audit.yml"
|
- ".github/workflows/audit.yml"
|
||||||
|
- "flake.lock"
|
||||||
merge_group:
|
merge_group:
|
||||||
types: [ checks_requested ]
|
types: [ checks_requested ]
|
||||||
schedule:
|
schedule:
|
||||||
@@ -81,6 +83,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -89,13 +92,20 @@ jobs:
|
|||||||
name: Typos
|
name: Typos
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- 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
|
# Fast, compile-free checks that fail early so contributors get feedback in
|
||||||
# ~1 minute instead of waiting for the full test job.
|
# ~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'
|
||||||
@@ -104,6 +114,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install ripgrep
|
- name: Install ripgrep
|
||||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||||
@@ -137,44 +149,134 @@ jobs:
|
|||||||
- name: Check no planning docs committed
|
- name: Check no planning docs committed
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
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
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 90
|
||||||
|
# Both lines are required. Job-level `permissions` replaces the workflow
|
||||||
|
# block rather than merging with it, so declaring only `actions: write`
|
||||||
|
# would drop `contents: read` and break this job's checkout and the
|
||||||
|
# repo-token the setup action hands to setup-protoc.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: write
|
||||||
env:
|
env:
|
||||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
# This job's token can cancel runs and delete Actions caches. Checkout
|
||||||
|
# otherwise writes it into .git/config, where a PR's own build.rs or
|
||||||
|
# proc-macro could read it back out.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-test
|
# Every lane in this workflow reads its cache and none writes it.
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
# cache-warm.yml is the sole writer for all four keys: this workflow
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
# cancels superseded runs on main, and a cancelled run never reaches
|
||||||
|
# rust-cache's post step, so writing from here saved nothing (12 of 15
|
||||||
|
# consecutive main-push runs were cancelled). See rustfs/backlog#1600.
|
||||||
|
cache-shared-key: ci-dev
|
||||||
|
cache-save-if: 'false'
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
|
- name: Prepare test evidence
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts/test-and-lint
|
||||||
|
{
|
||||||
|
echo "run_id=${GITHUB_RUN_ID}"
|
||||||
|
echo "job=${GITHUB_JOB}"
|
||||||
|
echo "runner=${RUNNER_NAME}"
|
||||||
|
echo "started_at=$(date --utc --iso-8601=seconds)"
|
||||||
|
} > artifacts/test-and-lint/run-metadata.txt
|
||||||
|
|
||||||
# Clippy runs before the test pass: lint failures are the most common
|
# Clippy runs before the test pass: lint failures are the most common
|
||||||
# CI-only breakage and should surface in minutes, not after 20+ minutes
|
# CI-only breakage and should surface in minutes, not after 20+ minutes
|
||||||
# of tests.
|
# of tests.
|
||||||
|
# Sampled too: clippy is the natural control arm for any CARGO_BUILD_JOBS
|
||||||
|
# experiment, since --all-targets is check-only for workspace members and
|
||||||
|
# never links the ~100 test binaries the limit exists to throttle.
|
||||||
- name: Run clippy lints
|
- name: Run clippy lints
|
||||||
run: cargo clippy --all-targets -- -D warnings
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run --profile ci --all --exclude e2e_test
|
./scripts/ci/resource_sampler.sh start clippy
|
||||||
cargo test --all --doc
|
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Upload test junit report
|
- name: Run nextest tests
|
||||||
|
env:
|
||||||
|
# Three concurrent workspace test links saturate the self-hosted
|
||||||
|
# runner's overlay I/O and can wedge Cargo until the 75m timeout.
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts/test-and-lint
|
||||||
|
./scripts/ci/resource_sampler.sh start nextest
|
||||||
|
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||||
|
set +e
|
||||||
|
NEXTEST_HIDE_PROGRESS_BAR=1 timeout --verbose --signal=TERM --kill-after=30s 75m \
|
||||||
|
cargo nextest run --profile ci --all --exclude e2e_test \
|
||||||
|
--status-level all --final-status-level all \
|
||||||
|
2>&1 | tee artifacts/test-and-lint/nextest.log
|
||||||
|
status=${PIPESTATUS[0]}
|
||||||
|
{
|
||||||
|
echo "command=cargo nextest run --profile ci --all --exclude e2e_test"
|
||||||
|
echo "exit_status=${status}"
|
||||||
|
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||||
|
echo
|
||||||
|
echo "Remaining test-related processes:"
|
||||||
|
pgrep -af 'cargo|nextest|target/.*/deps/' || true
|
||||||
|
echo
|
||||||
|
echo "Kernel OOM / kill events:"
|
||||||
|
dmesg -T 2>/dev/null | grep -iE 'oom|out of memory|killed process' | tail -20 || true
|
||||||
|
} > artifacts/test-and-lint/nextest-diagnostics.txt
|
||||||
|
exit "${status}"
|
||||||
|
|
||||||
|
- name: Run documentation tests
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts/test-and-lint
|
||||||
|
set +e
|
||||||
|
timeout --verbose --signal=TERM --kill-after=30s 15m \
|
||||||
|
cargo test --all --doc \
|
||||||
|
2>&1 | tee artifacts/test-and-lint/doctest.log
|
||||||
|
status=${PIPESTATUS[0]}
|
||||||
|
{
|
||||||
|
echo "command=cargo test --all --doc"
|
||||||
|
echo "exit_status=${status}"
|
||||||
|
echo "finished_at=$(date --utc --iso-8601=seconds)"
|
||||||
|
echo
|
||||||
|
echo "Remaining test-related processes:"
|
||||||
|
pgrep -af 'cargo|rustdoc|target/.*/deps/' || true
|
||||||
|
} > artifacts/test-and-lint/doctest-diagnostics.txt
|
||||||
|
exit "${status}"
|
||||||
|
|
||||||
|
- name: Upload test reports and diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: junit-test-and-lint-${{ github.run_number }}
|
name: junit-test-and-lint-${{ github.run_number }}
|
||||||
path: target/nextest/ci/junit.xml
|
path: |
|
||||||
|
target/nextest/ci/junit.xml
|
||||||
|
artifacts/test-and-lint
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
if-no-files-found: ignore
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# rustfs/backlog#1289: fail if a seed rule's log anchor no longer exists
|
||||||
|
# verbatim in the source tree (log message drifted without updating the
|
||||||
|
# rule). Placed here where the workspace — including the la-dump-anchors
|
||||||
|
# bin — is already built by the clippy/test steps above.
|
||||||
|
- name: Check log-analyzer rule anchors
|
||||||
|
run: ./scripts/check_log_analyzer_rules.sh
|
||||||
|
|
||||||
# Explicit gate for migration-critical suites. These tests already ran in
|
# Explicit gate for migration-critical suites. These tests already ran in
|
||||||
# the full nextest pass above; a single filtered nextest invocation keeps
|
# the full nextest pass above; a single filtered nextest invocation keeps
|
||||||
@@ -192,6 +294,48 @@ jobs:
|
|||||||
- name: Run rebalance/decommission migration proofs
|
- name: Run rebalance/decommission migration proofs
|
||||||
run: ./scripts/check_migration_gate_count.sh
|
run: ./scripts/check_migration_gate_count.sh
|
||||||
|
|
||||||
|
# Early stop. Once this job has failed the PR cannot merge, so the sibling
|
||||||
|
# lanes are burning runners on a result nobody can act on: on run
|
||||||
|
# 30674613104 three lanes had already failed while Test and Lint and the
|
||||||
|
# rio-v2 variant kept going past 70 minutes.
|
||||||
|
#
|
||||||
|
# Only this job may cancel. The lanes that are NOT required checks
|
||||||
|
# (protocols, ILM, e2e, s3-tests) must never hold that power: a flake in
|
||||||
|
# one of them would turn the required "Test and Lint" into `cancelled`,
|
||||||
|
# which blocks the merge. Today a maintainer can merge with sftp red, and
|
||||||
|
# that has to stay true.
|
||||||
|
#
|
||||||
|
# These steps run last so the `if: always()` artifact upload above still
|
||||||
|
# captures logs and diagnostics before the run goes away.
|
||||||
|
- name: Annotate early-stop reason
|
||||||
|
if: failure() && github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
echo "## CI early-stop" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; cancelling run ${GITHUB_RUN_ID} to free runners." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
echo "Sibling jobs showing **cancelled** were stopped by this job, not by their own failure." >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
# curl rather than `gh`: every existing `gh` call in this repo runs on
|
||||||
|
# ubuntu-latest, and the sm-standard-* images are custom and trimmed (they
|
||||||
|
# ship no C toolchain, see the e2e job below), so `gh` is not known to
|
||||||
|
# exist here.
|
||||||
|
#
|
||||||
|
# Fork PRs are excluded explicitly instead of relying on the error path:
|
||||||
|
# their GITHUB_TOKEN is forced read-only and job-level permissions cannot
|
||||||
|
# raise it, so the call would always 403. Skipping keeps their logs clean.
|
||||||
|
- name: Cancel run on failure (same-repo PR only)
|
||||||
|
if: >-
|
||||||
|
failure() && github.event_name == 'pull_request'
|
||||||
|
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||||
|
-H "Accept: application/vnd.github+json" \
|
||||||
|
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||||
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel" || true
|
||||||
|
|
||||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||||
@@ -205,6 +349,7 @@ jobs:
|
|||||||
test-ilm-integration-serial:
|
test-ilm-integration-serial:
|
||||||
name: ILM Integration (serial)
|
name: ILM Integration (serial)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
env:
|
env:
|
||||||
@@ -212,42 +357,39 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-ilm-serial
|
cache-shared-key: ci-dev
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
# The #4877 restore self-deadlock is fixed in this PR, which re-enabled
|
# test_transition_and_restore_flows was re-enabled by rustfs/backlog#1303:
|
||||||
# test_multipart_restore_preserves_parts_and_etag. The remaining exclusions
|
# its "missing xl.meta on disk2" was a test-util bug (open_disk hardcoded
|
||||||
# each hit a DIFFERENT, independent issue (all tracked under
|
# disk_index 0), not an EC metadata-distribution issue.
|
||||||
# rustfs/backlog#1148; they keep #[ignore] with a backlog reference):
|
# restore_object_usecase_reports_ongoing_conflict_and_completion was
|
||||||
|
# re-enabled by backlog#1304 (restore accepts serialize on a short CAS
|
||||||
|
# guard; the copy-back no longer holds the #4877 whole-copy-back lock,
|
||||||
|
# so the mid-restore ongoing read and fast 409 rejection it asserts are
|
||||||
|
# the implemented contract). The remaining exclusions each hit a
|
||||||
|
# DIFFERENT, independent issue (all tracked under rustfs/backlog#1148;
|
||||||
|
# they keep #[ignore] with a backlog reference):
|
||||||
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
|
# - test_noncurrent_{expiry,transition}_still_works_after_immediate_compensation_transition:
|
||||||
# noncurrent transition/expiry after an immediate compensation transition.
|
# noncurrent transition/expiry after an immediate compensation transition.
|
||||||
# - test_transition_and_restore_flows: transition metadata is missing on
|
|
||||||
# one drive (assert_transition_meta_consistent: "missing xl.meta ... on
|
|
||||||
# disk2") - an EC metadata-distribution issue, not the restore lock.
|
|
||||||
# - test_restore_chain_local_read_expiry_keeps_remote_and_allows_re_restore:
|
|
||||||
# DeleteRestoredAction sets opts.transition.expire_restored, but no
|
|
||||||
# delete path reads that flag, so cleanup deletes the whole object
|
|
||||||
# instead of only the local restored copy (ObjectNotFound afterwards).
|
|
||||||
# The expire_restored delete semantics are unimplemented.
|
|
||||||
# - restore_object_usecase_reports_ongoing_conflict_and_completion: asserts
|
|
||||||
# a concurrent get_object_info observes ongoing-request=true mid-restore,
|
|
||||||
# which #4877's read-vs-restore serialization rules out (see backlog#1148
|
|
||||||
# ilm-8 criterion 1 - an API-semantics decision, not a bug).
|
|
||||||
- name: Run ignored ILM integration tests serially
|
- name: Run ignored ILM integration tests serially
|
||||||
run: |
|
run: |
|
||||||
cargo nextest run -j1 --run-ignored ignored-only \
|
cargo nextest run -j1 --run-ignored ignored-only \
|
||||||
-p rustfs-scanner -p rustfs \
|
-p rustfs-scanner -p rustfs \
|
||||||
-E '(binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))) and not (test(test_transition_and_restore_flows) or test(test_noncurrent_expiry_still_works_after_immediate_compensation_transition) or test(test_noncurrent_transition_still_works_after_immediate_compensation_transition) or test(restore_object_usecase_reports_ongoing_conflict_and_completion) or test(test_restore_chain_local_read_expiry_keeps_remote_and_allows_re_restore))'
|
-E '(binary(lifecycle_integration_test) or (package(rustfs) and test(lifecycle_transition_api_test))) and not (test(test_noncurrent_expiry_still_works_after_immediate_compensation_transition) or test(test_noncurrent_transition_still_works_after_immediate_compensation_transition))'
|
||||||
|
|
||||||
test-and-lint-rio-v2:
|
test-and-lint-rio-v2:
|
||||||
name: Test and Lint (rio-v2)
|
name: Test and Lint (rio-v2)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
@@ -255,14 +397,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-test-rio-v2
|
cache-shared-key: ci-feat-rio
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Run rio-v2 clippy lints
|
- name: Run rio-v2 clippy lints
|
||||||
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
|
run: cargo clippy -p rustfs -p rustfs-ecstore --all-targets --features rio-v2 -- -D warnings
|
||||||
@@ -275,10 +419,17 @@ jobs:
|
|||||||
test-and-lint-protocols:
|
test-and-lint-protocols:
|
||||||
name: "Test and Lint (${{ matrix.features.name }})"
|
name: "Test and Lint (${{ matrix.features.name }})"
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
# On a PR, one failing protocol leg is enough to know the PR is not ready,
|
||||||
|
# so stop the sibling leg instead of paying another ~40 minutes for it.
|
||||||
|
# Everywhere else (main pushes, the merge queue, the weekly schedule) keep
|
||||||
|
# the full signal: there we want to know whether swift AND sftp are broken,
|
||||||
|
# not just whichever failed first. This is the only part of the early-stop
|
||||||
|
# work that also covers fork PRs, since it needs no token.
|
||||||
|
fail-fast: ${{ github.event_name == 'pull_request' }}
|
||||||
matrix:
|
matrix:
|
||||||
features:
|
features:
|
||||||
- name: swift
|
- name: swift
|
||||||
@@ -290,14 +441,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-test-${{ matrix.features.name }}
|
cache-shared-key: ci-feat-proto
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Run clippy with ${{ matrix.features.name }}
|
- name: Run clippy with ${{ matrix.features.name }}
|
||||||
run: |
|
run: |
|
||||||
@@ -310,6 +463,7 @@ jobs:
|
|||||||
build-rustfs-debug-binary:
|
build-rustfs-debug-binary:
|
||||||
name: Build RustFS Debug Binary
|
name: Build RustFS Debug Binary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
@@ -317,17 +471,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-rustfs-debug-binary
|
cache-shared-key: ci-dev
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: 'false'
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build debug binary
|
- name: Build debug binary
|
||||||
run: cargo build -p rustfs --bins
|
run: cargo build -p rustfs --bins --features e2e-test-hooks
|
||||||
|
|
||||||
- name: Upload debug binary
|
- name: Upload debug binary
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
@@ -340,6 +496,7 @@ jobs:
|
|||||||
build-rustfs-debug-binary-rio-v2:
|
build-rustfs-debug-binary-rio-v2:
|
||||||
name: Build RustFS Debug Binary (rio-v2)
|
name: Build RustFS Debug Binary (rio-v2)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
@@ -347,17 +504,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-rustfs-debug-binary-rio-v2
|
cache-shared-key: ci-feat-rio
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: 'false'
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build debug binary with rio-v2
|
- name: Build debug binary with rio-v2
|
||||||
run: cargo build -p rustfs --bins --features rio-v2
|
run: cargo build -p rustfs --bins --features rio-v2,e2e-test-hooks
|
||||||
|
|
||||||
- name: Upload debug binary
|
- name: Upload debug binary
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
@@ -369,6 +528,14 @@ jobs:
|
|||||||
|
|
||||||
uring-integration:
|
uring-integration:
|
||||||
name: io_uring Integration (real)
|
name: io_uring Integration (real)
|
||||||
|
# The pull_request trigger includes `closed` purely so the concurrency
|
||||||
|
# group cancels in-flight runs of a closed PR; every other job opts out of
|
||||||
|
# that run with this guard (or is skipped through its `needs` chain). This
|
||||||
|
# job had neither, so each closed/merged PR really ran the whole io_uring
|
||||||
|
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
||||||
|
# 30662728539) and kept the cancellation run in progress for minutes.
|
||||||
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
needs: [ quick-checks ]
|
||||||
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
||||||
# a container, applies no seccomp filter that would block io_uring_setup — so
|
# a container, applies no seccomp filter that would block io_uring_setup — so
|
||||||
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
||||||
@@ -379,17 +546,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
|
# Keeps its own key rather than joining ci-dev. rust-cache's key is
|
||||||
|
# built from runner.os/arch plus rustc and lockfile fingerprints — it
|
||||||
|
# does NOT include the runner label or image. ubuntu-latest and
|
||||||
|
# sm-standard-4 are therefore indistinguishable to it, so sharing a key
|
||||||
|
# would let two different system images overwrite each other's
|
||||||
|
# artifacts, and would make a 2-core hosted runner unpack ci-dev's ~3GB
|
||||||
|
# instead of this lane's ~1.3GB. cache-warm.yml warms this key on
|
||||||
|
# ubuntu-latest for the same reason.
|
||||||
cache-shared-key: ci-uring
|
cache-shared-key: ci-uring
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Install build dependencies
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y protobuf-compiler
|
|
||||||
|
|
||||||
# ext4 supports O_DIRECT; the runner's default TMPDIR may sit on tmpfs or
|
# ext4 supports O_DIRECT; the runner's default TMPDIR may sit on tmpfs or
|
||||||
# overlayfs, where open(O_DIRECT) returns EINVAL/EOPNOTSUPP and the native
|
# overlayfs, where open(O_DIRECT) returns EINVAL/EOPNOTSUPP and the native
|
||||||
@@ -416,7 +590,17 @@ jobs:
|
|||||||
RUSTFS_IO_URING_READ_ENABLE: "true"
|
RUSTFS_IO_URING_READ_ENABLE: "true"
|
||||||
RUSTFS_URING_TESTS_MUST_RUN: "1"
|
RUSTFS_URING_TESTS_MUST_RUN: "1"
|
||||||
TMPDIR: /mnt/rustfs-odirect
|
TMPDIR: /mnt/rustfs-odirect
|
||||||
run: cargo test -p rustfs-ecstore uring_ -- --test-threads=1 --nocapture
|
# --lib narrows what gets compiled, not what gets run: every selected
|
||||||
|
# test lives in the lib target. The 7 integration binaries under
|
||||||
|
# crates/ecstore/tests/ each reported "running 0 tests" here, so they
|
||||||
|
# were compiled and linked for nothing.
|
||||||
|
#
|
||||||
|
# The `uring_` filter must stay exactly as it is. libtest matches on
|
||||||
|
# substring, so it also selects names containing `during_` — 6 of the 18
|
||||||
|
# selected tests are such incidental matches. Narrowing the filter to
|
||||||
|
# `io_uring` would silently drop them, which is a coverage change.
|
||||||
|
# scripts/check_uring_lane_lib_only.sh guards the --lib precondition.
|
||||||
|
run: cargo test -p rustfs-ecstore --lib uring_ -- --test-threads=1 --nocapture
|
||||||
|
|
||||||
e2e-tests:
|
e2e-tests:
|
||||||
name: End-to-End Tests
|
name: End-to-End Tests
|
||||||
@@ -426,6 +610,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
# Full setup with dependency caching: the smoke-suite step below
|
# Full setup with dependency caching: the smoke-suite step below
|
||||||
# compiles the e2e_test crate, which pulls in most of the workspace.
|
# compiles the e2e_test crate, which pulls in most of the workspace.
|
||||||
@@ -434,9 +620,9 @@ jobs:
|
|||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-e2e
|
cache-shared-key: ci-dev
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
# Download after the cache restore so the freshly built binary from the
|
# Download after the cache restore so the freshly built binary from the
|
||||||
# build job always wins over anything restored into target/debug.
|
# build job always wins over anything restored into target/debug.
|
||||||
@@ -510,14 +696,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-e2e
|
cache-shared-key: ci-dev
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
cache-save-if: 'false'
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
# Download after the cache restore so the freshly built binary from the
|
# Download after the cache restore so the freshly built binary from the
|
||||||
# build job always wins over anything restored into target/debug.
|
# build job always wins over anything restored into target/debug.
|
||||||
@@ -553,6 +741,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Clean up previous test run
|
- name: Clean up previous test run
|
||||||
run: |
|
run: |
|
||||||
@@ -607,6 +797,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download debug binary
|
- name: Download debug binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
@@ -667,12 +859,20 @@ jobs:
|
|||||||
# evaluates ILM within ~2s of the due time, well inside the poll window.
|
# evaluates ILM within ~2s of the due time, well inside the poll window.
|
||||||
s3-lifecycle-behavior-tests:
|
s3-lifecycle-behavior-tests:
|
||||||
name: S3 Lifecycle Behavior Tests
|
name: S3 Lifecycle Behavior Tests
|
||||||
needs: [ build-rustfs-debug-binary ]
|
# Also gated on e2e-tests, matching s3-implemented-tests: when the e2e smoke
|
||||||
|
# suite is already red this lane cannot tell us anything new, and it holds a
|
||||||
|
# sm-standard-4 for up to 30 minutes doing so. Both lanes only download the
|
||||||
|
# prebuilt debug binary (no cargo build), and s3-implemented-tests — which
|
||||||
|
# already waits on e2e-tests — finishes later anyway, so a green PR's total
|
||||||
|
# wall clock is unchanged.
|
||||||
|
needs: [ build-rustfs-debug-binary, e2e-tests ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download debug binary
|
- name: Download debug binary
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
|
|||||||
@@ -22,11 +22,18 @@ on:
|
|||||||
issue_comment:
|
issue_comment:
|
||||||
types: [created, edited]
|
types: [created, edited]
|
||||||
|
|
||||||
|
# Least privilege at the top, widened per job below. This workflow runs on
|
||||||
|
# pull_request_target and issue_comment, so it holds full secrets on every fork
|
||||||
|
# PR and on any comment anyone writes — the one place in this repository where a
|
||||||
|
# compromised action would be handed a repo-write token. It does not check out
|
||||||
|
# or execute PR code, so there is no pwn-request path today, but the blast
|
||||||
|
# radius should not depend on that staying true.
|
||||||
|
#
|
||||||
|
# contents: write in particular was never used: the signature records are
|
||||||
|
# written to rustfs/cla through the scoped app token created below, and nothing
|
||||||
|
# here writes to this repository's contents.
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
pull-requests: write
|
|
||||||
issues: write
|
|
||||||
checks: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
|
||||||
@@ -36,14 +43,26 @@ jobs:
|
|||||||
cancel-closed-pr-runs:
|
cancel-closed-pr-runs:
|
||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request_target' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request_target' && github.event.action == 'closed'
|
||||||
|
# Echoes one line; the run exists only so the concurrency group cancels the
|
||||||
|
# in-flight run of a closed PR.
|
||||||
|
permissions: {}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
|
|
||||||
cla:
|
cla:
|
||||||
if: ${{ (github.event_name != 'issue_comment' || github.event.issue.pull_request) && (github.event_name != 'pull_request_target' || github.event.action != 'closed') }}
|
if: ${{ (github.event_name != 'issue_comment' || github.event.issue.pull_request) && (github.event_name != 'pull_request_target' || github.event.action != 'closed') }}
|
||||||
|
# checks: write reports the merge-queue check run; pull-requests and issues
|
||||||
|
# let cla-bot comment and label. contents stays read — see the note above.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
checks: write
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Report CLA result for merge queue
|
- name: Report CLA result for merge queue
|
||||||
if: github.event_name == 'merge_group'
|
if: github.event_name == 'merge_group'
|
||||||
|
|||||||
@@ -62,14 +62,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-coverage
|
cache-shared-key: ci-coverage
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Install cargo-llvm-cov
|
- name: Install cargo-llvm-cov
|
||||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||||
@@ -116,6 +118,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ env:
|
|||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
REGISTRY_DOCKERHUB: rustfs/rustfs
|
REGISTRY_DOCKERHUB: rustfs/rustfs
|
||||||
REGISTRY_GHCR: ghcr.io/${{ github.repository }}
|
REGISTRY_GHCR: ghcr.io/${{ github.repository }}
|
||||||
REGISTRY_QUAY: quay.io/${{ secrets.QUAY_USERNAME }}/rustfs
|
REGISTRY_QUAY: quay.io/rustfs/rustfs
|
||||||
DOCKER_PLATFORMS: linux/amd64,linux/arm64
|
DOCKER_PLATFORMS: linux/amd64,linux/arm64
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -82,8 +82,10 @@ jobs:
|
|||||||
github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
(github.event.workflow_run.conclusion == 'success' &&
|
(github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.event == 'push' &&
|
github.event.workflow_run.event == 'push' &&
|
||||||
github.event.workflow_run.head_branch != 'main')
|
github.event.workflow_run.head_branch != 'main' &&
|
||||||
|
!contains(github.event.workflow_run.head_branch, '-preview'))
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
should_build: ${{ steps.check.outputs.should_build }}
|
should_build: ${{ steps.check.outputs.should_build }}
|
||||||
should_push: ${{ steps.check.outputs.should_push }}
|
should_push: ${{ steps.check.outputs.should_push }}
|
||||||
@@ -96,11 +98,18 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
persist-credentials: false
|
||||||
# For workflow_run events, checkout the specific commit that triggered the workflow
|
# For workflow_run events, checkout the specific commit that triggered the workflow
|
||||||
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||||
|
|
||||||
- name: Check build conditions
|
- name: Check build conditions
|
||||||
id: check
|
id: check
|
||||||
|
env:
|
||||||
|
# dispatch inputs via env, not `${{ }}` interpolation: they are
|
||||||
|
# free-form strings and would otherwise be evaluated by bash.
|
||||||
|
INPUT_VERSION: ${{ github.event.inputs.version }}
|
||||||
|
INPUT_PUSH_IMAGES: ${{ github.event.inputs.push_images }}
|
||||||
|
INPUT_FORCE_REBUILD: ${{ github.event.inputs.force_rebuild }}
|
||||||
run: |
|
run: |
|
||||||
should_build=false
|
should_build=false
|
||||||
should_push=false
|
should_push=false
|
||||||
@@ -201,9 +210,9 @@ jobs:
|
|||||||
|
|
||||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
# Manual trigger
|
# Manual trigger
|
||||||
input_version="${{ github.event.inputs.version }}"
|
input_version="$INPUT_VERSION"
|
||||||
version="${input_version}"
|
version="${input_version}"
|
||||||
should_push="${{ github.event.inputs.push_images }}"
|
should_push="$INPUT_PUSH_IMAGES"
|
||||||
should_build=true
|
should_build=true
|
||||||
|
|
||||||
# Get short SHA
|
# Get short SHA
|
||||||
@@ -211,7 +220,7 @@ jobs:
|
|||||||
|
|
||||||
echo "🎯 Manual Docker build triggered:"
|
echo "🎯 Manual Docker build triggered:"
|
||||||
echo " 📋 Requested version: $input_version"
|
echo " 📋 Requested version: $input_version"
|
||||||
echo " 🔧 Force rebuild: ${{ github.event.inputs.force_rebuild }}"
|
echo " 🔧 Force rebuild: $INPUT_FORCE_REBUILD"
|
||||||
echo " 🚀 Push images: $should_push"
|
echo " 🚀 Push images: $should_push"
|
||||||
|
|
||||||
case "$input_version" in
|
case "$input_version" in
|
||||||
@@ -220,6 +229,13 @@ jobs:
|
|||||||
create_latest=true
|
create_latest=true
|
||||||
echo "🚀 Building with latest stable release version"
|
echo "🚀 Building with latest stable release version"
|
||||||
;;
|
;;
|
||||||
|
*-preview*)
|
||||||
|
build_type="preview"
|
||||||
|
is_prerelease=true
|
||||||
|
should_build=false
|
||||||
|
should_push=false
|
||||||
|
echo "⏭️ Preview tags do not publish Docker images"
|
||||||
|
;;
|
||||||
# Prerelease versions (must match first, more specific)
|
# Prerelease versions (must match first, more specific)
|
||||||
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
||||||
build_type="prerelease"
|
build_type="prerelease"
|
||||||
@@ -290,6 +306,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||||
@@ -325,32 +343,28 @@ jobs:
|
|||||||
CREATE_LATEST="${{ needs.build-check.outputs.create_latest }}"
|
CREATE_LATEST="${{ needs.build-check.outputs.create_latest }}"
|
||||||
VARIANT_SUFFIX="${{ matrix.suffix }}"
|
VARIANT_SUFFIX="${{ matrix.suffix }}"
|
||||||
|
|
||||||
# Convert version format for Dockerfile compatibility
|
# Convert version format for Dockerfile compatibility. The former
|
||||||
|
# DOCKER_CHANNEL was "release" down every branch and was passed as a
|
||||||
|
# build-arg no Dockerfile declares, so it is gone.
|
||||||
case "$VERSION" in
|
case "$VERSION" in
|
||||||
"latest")
|
"latest")
|
||||||
# For stable latest, use RELEASE=latest + release CHANNEL
|
|
||||||
DOCKER_RELEASE="latest"
|
DOCKER_RELEASE="latest"
|
||||||
DOCKER_CHANNEL="release"
|
|
||||||
;;
|
;;
|
||||||
v*)
|
v*)
|
||||||
# For versioned releases (v1.0.0), remove 'v' prefix for Dockerfile
|
# For versioned releases (v1.0.0), remove 'v' prefix for Dockerfile
|
||||||
DOCKER_RELEASE="${VERSION#v}"
|
DOCKER_RELEASE="${VERSION#v}"
|
||||||
DOCKER_CHANNEL="release"
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
# For other versions, pass as-is
|
# For other versions, pass as-is
|
||||||
DOCKER_RELEASE="${VERSION}"
|
DOCKER_RELEASE="${VERSION}"
|
||||||
DOCKER_CHANNEL="release"
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
echo "docker_release=$DOCKER_RELEASE" >> "$GITHUB_OUTPUT"
|
echo "docker_release=$DOCKER_RELEASE" >> "$GITHUB_OUTPUT"
|
||||||
echo "docker_channel=$DOCKER_CHANNEL" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
echo "🐳 Docker build parameters:"
|
echo "🐳 Docker build parameters:"
|
||||||
echo " - Original version: $VERSION"
|
echo " - Original version: $VERSION"
|
||||||
echo " - Docker RELEASE: $DOCKER_RELEASE"
|
echo " - Docker RELEASE: $DOCKER_RELEASE"
|
||||||
echo " - Docker CHANNEL: $DOCKER_CHANNEL"
|
|
||||||
|
|
||||||
# Generate tags based on build type
|
# Generate tags based on build type
|
||||||
# Only support release and prerelease builds (no development builds)
|
# Only support release and prerelease builds (no development builds)
|
||||||
@@ -404,18 +418,24 @@ jobs:
|
|||||||
push: ${{ needs.build-check.outputs.should_push == 'true' }}
|
push: ${{ needs.build-check.outputs.should_push == 'true' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: |
|
# No layer cache. This build compiles nothing — it downloads a
|
||||||
type=gha,scope=docker-${{ matrix.variant }}
|
# release zip and runs apk/apt — so the cache could only save the
|
||||||
cache-to: |
|
# minute or two those take, while creating a correctness problem: with
|
||||||
type=gha,mode=max,scope=docker-${{ matrix.variant }}
|
# RELEASE=latest the binary URL is resolved by curl *inside* a RUN
|
||||||
|
# layer, and the layer key does not include what that resolved to. A
|
||||||
|
# rebuild at the same RELEASE value (dispatch with version=latest, or
|
||||||
|
# a re-run of the same version) would hit the old layer and ship the
|
||||||
|
# previous release's binary. mode=max also consumed the same 10GB
|
||||||
|
# Actions cache quota the Rust lanes are fighting over.
|
||||||
|
#
|
||||||
|
# Only RELEASE is passed: it is the sole build-arg the Dockerfiles
|
||||||
|
# declare besides TARGETARCH. BUILDTIME, VERSION, BUILD_TYPE, REVISION
|
||||||
|
# and CHANNEL were never read by any stage (and BUILDTIME's $(date ...)
|
||||||
|
# was a literal here, not a shell substitution). BUILD_DATE and VCS_REF
|
||||||
|
# are declared by the Dockerfiles but deliberately left unset —
|
||||||
|
# supplying them would change the published image labels.
|
||||||
build-args: |
|
build-args: |
|
||||||
BUILDTIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
|
||||||
VERSION=${{ needs.build-check.outputs.version }}
|
|
||||||
BUILD_TYPE=${{ needs.build-check.outputs.build_type }}
|
|
||||||
REVISION=${{ github.sha }}
|
|
||||||
RELEASE=${{ steps.meta.outputs.docker_release }}
|
RELEASE=${{ steps.meta.outputs.docker_release }}
|
||||||
CHANNEL=${{ steps.meta.outputs.docker_channel }}
|
|
||||||
BUILDKIT_INLINE_CACHE=1
|
|
||||||
provenance: true
|
provenance: true
|
||||||
sbom: true
|
sbom: true
|
||||||
# Add retry mechanism by splitting the build process
|
# Add retry mechanism by splitting the build process
|
||||||
@@ -431,6 +451,7 @@ jobs:
|
|||||||
needs: [ build-check, build-docker ]
|
needs: [ build-check, build-docker ]
|
||||||
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
|
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
security-events: write
|
security-events: write
|
||||||
@@ -485,6 +506,7 @@ jobs:
|
|||||||
needs: [ build-check, build-docker ]
|
needs: [ build-check, build-docker ]
|
||||||
if: always() && needs.build-check.outputs.should_build == 'true'
|
if: always() && needs.build-check.outputs.should_build == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Docker build completion summary
|
- name: Docker build completion summary
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -64,14 +64,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-e2e-repl
|
cache-shared-key: ci-e2e-repl
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
# awscurl lets the STS dual-node test actually exercise its path. Without
|
# awscurl lets the STS dual-node test actually exercise its path. Without
|
||||||
# it the test skips gracefully with a visible log line
|
# it the test skips gracefully with a visible log line
|
||||||
@@ -124,6 +126,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -45,6 +45,13 @@
|
|||||||
# The PR gate (ci.yml s3-implemented-tests) is unaffected: it avoids Docker
|
# The PR gate (ci.yml s3-implemented-tests) is unaffected: it avoids Docker
|
||||||
# via DEPLOY_MODE=binary and defers all pip setup to run.sh's self-bootstrap.
|
# via DEPLOY_MODE=binary and defers all pip setup to run.sh's self-bootstrap.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
name: e2e-s3tests
|
name: e2e-s3tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -135,6 +142,8 @@ jobs:
|
|||||||
TEST_MODE: ${{ matrix.test-mode }}
|
TEST_MODE: ${{ matrix.test-mode }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
# Provision Python explicitly rather than trusting the runner image to
|
# Provision Python explicitly rather than trusting the runner image to
|
||||||
# ship a working pip (ci-1: a bare python3 without pip is what broke the
|
# ship a working pip (ci-1: a bare python3 without pip is what broke the
|
||||||
@@ -354,6 +363,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
name: Fuzz
|
name: Fuzz
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -59,6 +66,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -79,13 +87,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: nightly
|
rust-version: nightly
|
||||||
cache-shared-key: fuzz-${{ hashFiles('fuzz/Cargo.lock') }}
|
cache-shared-key: fuzz-${{ hashFiles('fuzz/Cargo.lock') }}
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'schedule' }}
|
||||||
|
|
||||||
- name: Install cargo-fuzz
|
- name: Install cargo-fuzz
|
||||||
@@ -145,6 +154,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download prebuilt fuzz binaries
|
- name: Download prebuilt fuzz binaries
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
@@ -200,6 +211,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Download prebuilt fuzz binaries
|
- name: Download prebuilt fuzz binaries
|
||||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||||
@@ -247,6 +260,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
build-helm-package:
|
build-helm-package:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
if: |
|
if: |
|
||||||
github.event_name == 'workflow_dispatch' ||
|
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
|
||||||
(
|
(
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.event == 'push' &&
|
github.event.workflow_run.event == 'push' &&
|
||||||
contains(github.event.workflow_run.head_branch, '.')
|
contains(github.event.workflow_run.head_branch, '.') &&
|
||||||
|
!contains(github.event.workflow_run.head_branch, '-preview')
|
||||||
)
|
)
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
@@ -48,16 +50,26 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout helm chart repo
|
- name: Checkout helm chart repo
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# Both inputs reach the shell through env rather than `${{ }}`
|
||||||
|
# interpolation. A git ref name may contain `$(...)` — anything without a
|
||||||
|
# space is a legal tag — and interpolation pastes it into the script
|
||||||
|
# verbatim, where bash would run it. Reading "$RAW_INPUT" instead makes it
|
||||||
|
# data.
|
||||||
- name: Normalize release version
|
- name: Normalize release version
|
||||||
id: version
|
id: version
|
||||||
|
env:
|
||||||
|
RAW_INPUT: ${{ github.event.inputs.version }}
|
||||||
|
RAW_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
|
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||||
RAW="${{ github.event.inputs.version }}"
|
RAW="$RAW_INPUT"
|
||||||
else
|
else
|
||||||
RAW="${{ github.event.workflow_run.head_branch }}"
|
RAW="$RAW_BRANCH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
case "$RAW" in
|
case "$RAW" in
|
||||||
@@ -72,10 +84,13 @@ jobs:
|
|||||||
./scripts/helm_chart_version.sh "$RAW_TAG"
|
./scripts/helm_chart_version.sh "$RAW_TAG"
|
||||||
|
|
||||||
- name: Replace chart version and app version
|
- name: Replace chart version and app version
|
||||||
|
env:
|
||||||
|
CHART_VERSION: ${{ steps.version.outputs.chart_version }}
|
||||||
|
APP_VERSION: ${{ steps.version.outputs.app_version }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
sed -i -E 's/^version:.*/version: "${{ steps.version.outputs.chart_version }}"/' helm/rustfs/Chart.yaml
|
sed -i -E "s/^version:.*/version: \"${CHART_VERSION}\"/" helm/rustfs/Chart.yaml
|
||||||
sed -i -E 's/^appVersion:.*/appVersion: "${{ steps.version.outputs.app_version }}"/' helm/rustfs/Chart.yaml
|
sed -i -E "s/^appVersion:.*/appVersion: \"${APP_VERSION}\"/" helm/rustfs/Chart.yaml
|
||||||
|
|
||||||
- name: Set up Helm
|
- name: Set up Helm
|
||||||
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||||
@@ -100,6 +115,7 @@ jobs:
|
|||||||
|
|
||||||
publish-helm-package:
|
publish-helm-package:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
needs: [ build-helm-package ]
|
needs: [ build-helm-package ]
|
||||||
if: needs.build-helm-package.result == 'success'
|
if: needs.build-helm-package.result == 'success'
|
||||||
|
|
||||||
@@ -107,6 +123,8 @@ jobs:
|
|||||||
- name: Checkout helm package repo
|
- name: Checkout helm package repo
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
# persist-credentials-exempt: this checkout's token IS the push credential —
|
||||||
|
# the job git-pushes to rustfs/helm below. Clearing it breaks chart publishing.
|
||||||
repository: rustfs/helm
|
repository: rustfs/helm
|
||||||
token: ${{ secrets.RUSTFS_HELM_PACKAGE }}
|
token: ${{ secrets.RUSTFS_HELM_PACKAGE }}
|
||||||
|
|
||||||
@@ -122,11 +140,19 @@ jobs:
|
|||||||
- name: Generate index
|
- name: Generate index
|
||||||
run: helm repo index . --url https://charts.rustfs.com
|
run: helm repo index . --url https://charts.rustfs.com
|
||||||
|
|
||||||
|
# app_version is derived from the triggering tag name, and this job holds
|
||||||
|
# the cross-repository push token with rustfs/helm already checked out —
|
||||||
|
# the worst place in the repo to paste an attacker-influenced string into
|
||||||
|
# a shell line. Passed through env so bash treats it as data.
|
||||||
- name: Push helm package and index file
|
- name: Push helm package and index file
|
||||||
|
env:
|
||||||
|
GIT_USERNAME: ${{ secrets.USERNAME }}
|
||||||
|
GIT_EMAIL: ${{ secrets.EMAIL_ADDRESS }}
|
||||||
|
APP_VERSION: ${{ needs.build-helm-package.outputs.app_version }}
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
git config --global user.name "${{ secrets.USERNAME }}"
|
git config --global user.name "${GIT_USERNAME}"
|
||||||
git config --global user.email "${{ secrets.EMAIL_ADDRESS }}"
|
git config --global user.email "${GIT_EMAIL}"
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Update rustfs helm package with ${{ needs.build-helm-package.outputs.app_version }}." || echo "No changes to commit"
|
git commit -m "Update rustfs helm package with ${APP_VERSION}." || echo "No changes to commit"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
name: "issue-translator"
|
name: "issue-translator"
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
@@ -26,6 +33,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: usthe/issues-translate-action@b41f55ddc81d7d54bd542a4f289fe28ec081898e # v2.7
|
- uses: usthe/issues-translate-action@b41f55ddc81d7d54bd542a4f289fe28ec081898e # v2.7
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
# 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.
|
||||||
|
#
|
||||||
name: minio-interop
|
name: minio-interop
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -51,13 +58,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: ci-minio-interop
|
cache-shared-key: ci-minio-interop
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
|
||||||
- name: Generate real MinIO fixtures via Docker
|
- name: Generate real MinIO fixtures via Docker
|
||||||
|
|||||||
@@ -45,6 +45,13 @@
|
|||||||
# docker-capable self-hosted `dind-sm-standard-2` label was the alternative but
|
# docker-capable self-hosted `dind-sm-standard-2` label was the alternative but
|
||||||
# has fewer cores and reintroduces fleet-state risk for no reliability gain.
|
# has fewer cores and reintroduces fleet-state risk for no reliability gain.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
name: mint
|
name: mint
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -118,6 +125,8 @@ jobs:
|
|||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Enable buildx
|
- name: Enable buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
@@ -263,6 +272,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '0 5 * * 0' # Weekly on Sunday 05:00 UTC (staggered after the midnight ci/build crons)
|
- cron: '0 5 * * 0' # Weekly on Sunday 05:00 UTC (staggered after the midnight ci/build crons)
|
||||||
|
|
||||||
|
# GITHUB_TOKEN only needs to read the repository here: the branch push and the
|
||||||
|
# pull request are both created by update-flake-lock using the
|
||||||
|
# FLAKE_UPDATE_TOKEN PAT below, not by this token. Leaving write on it hands a
|
||||||
|
# repo-write credential to an unattended weekly job that does not use it.
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -37,6 +40,10 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
# persist-credentials-exempt: update-flake-lock pushes the branch and opens
|
||||||
|
# the PR. It passes FLAKE_UPDATE_TOKEN to create-pull-request itself rather
|
||||||
|
# than reusing .git/config, but that is unverified — exempt until a
|
||||||
|
# workflow_dispatch run confirms it (rustfs/backlog#1602).
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: DeterminateSystems/determinate-nix-action@629b284231c2a82554b724e357e47fc6020833c8 # v3
|
uses: DeterminateSystems/determinate-nix-action@629b284231c2a82554b724e357e47fc6020833c8 # v3
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
name: Nix CI
|
name: Nix CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -46,6 +53,7 @@ jobs:
|
|||||||
name: Cancel Closed PR Runs
|
name: Cancel Closed PR Runs
|
||||||
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
if: github.event_name == 'pull_request' && github.event.action == 'closed'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
@@ -63,6 +71,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: DeterminateSystems/determinate-nix-action@4eea0b33e3d1f02ecfe37cf16e7204c424009606 # v3.21.0
|
uses: DeterminateSystems/determinate-nix-action@4eea0b33e3d1f02ecfe37cf16e7204c424009606 # v3.21.0
|
||||||
|
|||||||
@@ -22,6 +22,13 @@
|
|||||||
# a deliberate correctness cost (e.g. the #4221 fsync durability fix) is
|
# a deliberate correctness cost (e.g. the #4221 fsync durability fix) is
|
||||||
# recorded but does not block (rustfs/backlog#935 correction 1).
|
# recorded but does not block (rustfs/backlog#935 correction 1).
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
name: Performance A/B
|
name: Performance A/B
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -92,6 +99,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
@@ -99,7 +108,6 @@ jobs:
|
|||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build release rustfs
|
- name: Build release rustfs
|
||||||
run: cargo build --release --bin rustfs
|
run: cargo build --release --bin rustfs
|
||||||
@@ -142,6 +150,7 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
|
persist-credentials: false
|
||||||
fetch-depth: 0 # baseline is built from origin/main
|
fetch-depth: 0 # baseline is built from origin/main
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
@@ -150,7 +159,6 @@ jobs:
|
|||||||
rust-version: stable
|
rust-version: stable
|
||||||
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
cache-shared-key: warp-ab-${{ hashFiles('**/Cargo.lock') }}
|
||||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install warp
|
- name: Install warp
|
||||||
run: |
|
run: |
|
||||||
@@ -162,13 +170,15 @@ jobs:
|
|||||||
|
|
||||||
- name: Decide exemption
|
- name: Decide exemption
|
||||||
id: exempt
|
id: exempt
|
||||||
|
env:
|
||||||
|
INPUT_ALLOW_REGRESSION: ${{ github.event.inputs.allow_regression }}
|
||||||
run: |
|
run: |
|
||||||
allow="false"
|
allow="false"
|
||||||
if [[ "${{ github.event_name }}" == "pull_request" ]] \
|
if [[ "${{ github.event_name }}" == "pull_request" ]] \
|
||||||
&& ${{ contains(github.event.pull_request.labels.*.name, 'perf-deliberate-tradeoff') }}; then
|
&& ${{ contains(github.event.pull_request.labels.*.name, 'perf-deliberate-tradeoff') }}; then
|
||||||
allow="true"
|
allow="true"
|
||||||
fi
|
fi
|
||||||
if [[ "${{ github.event.inputs.allow_regression }}" == "true" ]]; then
|
if [[ "$INPUT_ALLOW_REGRESSION" == "true" ]]; then
|
||||||
allow="true"
|
allow="true"
|
||||||
fi
|
fi
|
||||||
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
echo "allow_regression=$allow" >> "$GITHUB_OUTPUT"
|
||||||
@@ -224,6 +234,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Run warp A/B and gate
|
- name: Run warp A/B and gate
|
||||||
id: ab
|
id: ab
|
||||||
|
env:
|
||||||
|
INPUT_DURATION: ${{ github.event.inputs.duration }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Budget note: with perf-3's cached baseline the nightly does no source
|
# Budget note: with perf-3's cached baseline the nightly does no source
|
||||||
@@ -234,7 +246,7 @@ jobs:
|
|||||||
# budget, which the rig's previous 60s health poll undershot (the first
|
# budget, which the rig's previous 60s health poll undershot (the first
|
||||||
# two nightly failures). perf-6 recalibrates these once the noise study
|
# two nightly failures). perf-6 recalibrates these once the noise study
|
||||||
# lands.
|
# lands.
|
||||||
duration="${{ github.event.inputs.duration || '12s' }}"
|
duration="${INPUT_DURATION:-12s}"
|
||||||
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
|
baseline_sha="${{ steps.commits.outputs.baseline_sha }}"
|
||||||
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
|
candidate_sha="${{ steps.commits.outputs.candidate_sha }}"
|
||||||
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
|
baseline_hit="${{ steps.baseline_cache.outputs.cache-hit }}"
|
||||||
@@ -385,6 +397,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -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/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.
|
||||||
|
|
||||||
|
# Asserts that the self-hosted runners are still ephemeral — one job per pod.
|
||||||
|
#
|
||||||
|
# This repository is public and its pull_request jobs run on those runners,
|
||||||
|
# executing the PR's own build.rs, proc-macros and tests. The only thing keeping
|
||||||
|
# that code from reaching a later job is that each ARC pod handles exactly one
|
||||||
|
# job and is then destroyed. That guarantee lives in the ARC scale-set
|
||||||
|
# configuration, outside this repository, where it can be changed without any PR
|
||||||
|
# — so it is asserted here from the outside, against real run data, instead of
|
||||||
|
# being assumed.
|
||||||
|
#
|
||||||
|
# Monthly rather than per-PR: the property changes only when someone
|
||||||
|
# reconfigures the scale set, and the check costs a few dozen API calls.
|
||||||
|
# See docs/ci/runners.md and rustfs/backlog#1602.
|
||||||
|
|
||||||
|
name: Runner Hygiene
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 6 1 * *" # Monthly, 1st at 06:00 UTC (after the daily audit cron)
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: runner-hygiene
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-ephemerality:
|
||||||
|
name: Check runner ephemerality
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
# Exit 2 (inconclusive / broken) is deliberately not a pass: a window
|
||||||
|
# where every sm-* job was still queued would otherwise look identical to
|
||||||
|
# a clean bill of health.
|
||||||
|
- name: Assert one job per self-hosted runner
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: ./scripts/ci/check_runner_ephemerality.sh 40
|
||||||
|
|
||||||
|
alert-on-failure:
|
||||||
|
name: Alert on scheduled failure
|
||||||
|
needs: [check-ephemerality]
|
||||||
|
# Same ci-8 mechanism as coverage.yml, audit.yml and the nightly lanes:
|
||||||
|
# scheduled runs file a tracking issue, manual dispatch stays quiet so
|
||||||
|
# debugging never produces a spurious alert.
|
||||||
|
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 }}
|
||||||
@@ -24,6 +24,13 @@
|
|||||||
# The run itself is expected to end red (the forced failure); only the
|
# The run itself is expected to end red (the forced failure); only the
|
||||||
# alert-on-failure job result matters.
|
# alert-on-failure job result matters.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
name: Schedule Failure Alert Drill
|
name: Schedule Failure Alert Drill
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -56,6 +63,8 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
- name: Open or update failure-tracking issue
|
- name: Open or update failure-tracking issue
|
||||||
uses: ./.github/actions/schedule-failure-issue
|
uses: ./.github/actions/schedule-failure-issue
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -12,6 +12,13 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
name: "Mark stale issues"
|
name: "Mark stale issues"
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -20,6 +27,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: Star History
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: star-history
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
|
||||||
|
with:
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
output-branch: star-history
|
||||||
|
output-path: .
|
||||||
|
chart-style: gradient
|
||||||
|
animate: "true"
|
||||||
|
contributors: "true"
|
||||||
Vendored
+35
-8
@@ -172,7 +172,7 @@
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Debug executable target/debug/rustfs with sse",
|
"name": "Debug executable target/debug/rustfs with sse kms",
|
||||||
"type": "lldb",
|
"type": "lldb",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"program": "${workspaceFolder}/target/debug/rustfs",
|
"program": "${workspaceFolder}/target/debug/rustfs",
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
// 2. kms local backend test key
|
// 2. kms local backend test key
|
||||||
// "RUSTFS_KMS_ENABLE": "true",
|
// "RUSTFS_KMS_ENABLE": "true",
|
||||||
// "RUSTFS_KMS_BACKEND": "local",
|
// "RUSTFS_KMS_BACKEND": "local",
|
||||||
// "RUSTFS_KMS_KEY_DIR": "./target/kms-key-dir",
|
// "RUSTFS_KMS_KEY_DIR": "/tmp/kms-key-dir",
|
||||||
// "RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
// "RUSTFS_KMS_LOCAL_MASTER_KEY": "my-secret-key", // Some Password
|
||||||
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||||
|
|
||||||
@@ -212,13 +212,40 @@
|
|||||||
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||||
|
|
||||||
// 4. kms vault transit backend test key
|
// 4. kms vault transit backend test key
|
||||||
|
// "RUSTFS_KMS_ENABLE": "true",
|
||||||
|
// "RUSTFS_KMS_BACKEND": "vault-transit",
|
||||||
|
// "RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200",
|
||||||
|
// "RUSTFS_KMS_VAULT_TOKEN": "Dev Token",
|
||||||
|
// "RUSTFS_KMS_VAULT_MOUNT_PATH": "transit",
|
||||||
|
// "RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
||||||
|
|
||||||
|
// 5、kms static backend test key
|
||||||
"RUSTFS_KMS_ENABLE": "true",
|
"RUSTFS_KMS_ENABLE": "true",
|
||||||
"RUSTFS_KMS_BACKEND": "vault-transit",
|
"RUSTFS_KMS_BACKEND": "static",
|
||||||
"RUSTFS_KMS_VAULT_ADDRESS": "http://127.0.0.1:8200",
|
"RUSTFS_KMS_STATIC_SECRET_KEY": "rustfs-master-key:2dfNXGHlsEflGVCxb+5DIdGEl1sIvtwX+QfmYasi5QM="
|
||||||
"RUSTFS_KMS_VAULT_TOKEN": "Dev Token",
|
},
|
||||||
"RUSTFS_KMS_VAULT_MOUNT_PATH": "transit",
|
"sourceLanguages": [
|
||||||
"RUSTFS_KMS_DEFAULT_KEY_ID": "rustfs-master-key",
|
"rust"
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Debug executable target/debug/rustfs with local sse",
|
||||||
|
"type": "lldb",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "${workspaceFolder}/target/debug/rustfs",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"env": {
|
||||||
|
"RUSTFS_ACCESS_KEY": "rustfsadmin",
|
||||||
|
"RUSTFS_SECRET_KEY": "rustfsadmin",
|
||||||
|
"RUSTFS_VOLUMES": "./target/volumes/test{1...4}",
|
||||||
|
"RUSTFS_ADDRESS": ":9000",
|
||||||
|
"RUSTFS_CONSOLE_ENABLE": "true",
|
||||||
|
"RUSTFS_CONSOLE_ADDRESS": "127.0.0.1:9001",
|
||||||
|
"RUSTFS_OBS_LOG_DIRECTORY": "./target/logs",
|
||||||
|
"RUSTFS_UNSAFE_BYPASS_DISK_CHECK": "true",
|
||||||
|
"RUSTFS_SSE_S3_MASTER_KEY": "xGb3aYSp825j2tPpg8JrUzghiXsIkfdOtmrsJ/iafiM=",
|
||||||
|
"RUST_LOG": "rustfs=debug,ecstore=debug,s3s=debug,iam=debug",
|
||||||
},
|
},
|
||||||
"sourceLanguages": [
|
"sourceLanguages": [
|
||||||
"rust"
|
"rust"
|
||||||
|
|||||||
@@ -14,13 +14,19 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
|
|||||||
|
|
||||||
## Execution Discipline
|
## Execution Discipline
|
||||||
|
|
||||||
- Read the relevant existing code, tests, and local guidance before changing behavior.
|
- Read the relevant existing code, tests, and local guidance before changing behavior. For new helpers or test setup, that read includes `crates/utils`, `crates/common`, and the touched crate's own `test_util`/fixtures (see Reuse Before You Write).
|
||||||
- State assumptions when they affect the implementation or verification path.
|
- State assumptions when they affect the implementation or verification path.
|
||||||
- If a task has multiple plausible interpretations, list the options briefly and choose the narrowest reasonable path; ask when the ambiguity would make the change risky.
|
- If a task has multiple plausible interpretations, list the options briefly and choose the narrowest reasonable path; ask when the ambiguity would make the change risky.
|
||||||
- For multi-step work, keep the plan minimal and tied to verifiable outcomes.
|
- For multi-step work, keep the plan minimal and tied to verifiable outcomes.
|
||||||
- Avoid redundant file reads, repeated commands, and unnecessary exploratory work once enough context is available.
|
- Avoid redundant file reads, repeated commands, and unnecessary exploratory work once enough context is available.
|
||||||
- A good result is a minimal diff with clear assumptions, no over-engineering, and independent verification that survives Adversarial Validation (below).
|
- A good result is a minimal diff with clear assumptions, no over-engineering, and independent verification that survives Adversarial Validation (below).
|
||||||
|
|
||||||
|
## Autonomy and Approval Boundaries
|
||||||
|
|
||||||
|
- Inquiry tasks (answer, explain, review, diagnose, plan): report findings; do not change files unless a fix is explicitly requested.
|
||||||
|
- Action tasks (change, build, fix): make in-scope local changes without asking for approval.
|
||||||
|
- Ask for confirmation before destructive or hard-to-reverse operations (force-pushes, history rewrites, deleting data or branches), merging a PR (reviewer approval required), or any material expansion of the requested scope.
|
||||||
|
|
||||||
## Communication and Language
|
## Communication and Language
|
||||||
|
|
||||||
- Respond in the same language used by the requester.
|
- Respond in the same language used by the requester.
|
||||||
@@ -41,14 +47,27 @@ If repo-level instructions conflict, follow the nearest file and keep behavior a
|
|||||||
- Do not refactor existing code only to make it easier to unit test.
|
- Do not refactor existing code only to make it easier to unit test.
|
||||||
- Keep fixes narrowly aligned with the requested behavior; avoid semantic-adjacent rewrites while touching sensitive paths.
|
- Keep fixes narrowly aligned with the requested behavior; avoid semantic-adjacent rewrites while touching sensitive paths.
|
||||||
- Keep code elegant, concise, and direct. Prefer minimal, readable implementations over over-engineering and excessive abstraction. Use comments to clarify non-obvious intent and invariants, not to compensate for unclear code.
|
- Keep code elegant, concise, and direct. Prefer minimal, readable implementations over over-engineering and excessive abstraction. Use comments to clarify non-obvious intent and invariants, not to compensate for unclear code.
|
||||||
|
- Do not write comments that narrate what the next line does, restate a signature, or describe the change you just made — that commentary belongs in the PR description, not the code. Required invariant comments — lock ordering, `SAFETY`, unwrap justification, `#[allow(dead_code)]` rationale, `RUSTFS_COMPAT_TODO` — are never narration.
|
||||||
- Mention unrelated issues when useful, but do not fix them as part of a narrow task.
|
- Mention unrelated issues when useful, but do not fix them as part of a narrow task.
|
||||||
|
|
||||||
## Constant and String Usage
|
## Reuse Before You Write
|
||||||
|
|
||||||
- Before introducing new string literals, search for existing constants/enums that already represent the same semantic value.
|
Search for an existing implementation before writing a new one; extend what exists instead of duplicating it:
|
||||||
- Reuse existing constants for protocol labels, error identifiers, header keys, event names, metric names, command tags, and similar fixed tokens.
|
|
||||||
- If a new string is truly unique, define a local constant near related logic and avoid scattering the literal across multiple sites.
|
- **Helpers and utilities** (path/string handling, hashing, retry, env parsing, IO wrappers): check `ls crates/utils/src` first — file names map to operations (`retry.rs`, `envs.rs`, `hash.rs`, `path.rs`, `string.rs`, `io.rs`) — plus `crates/common` (shared structures/globals), then `rg -i 'fn \w*<term>' crates/utils/src crates/common/src <touched-crate>/src` for signatures. Helpers are snake_case: a full-text single-word grep over a large crate drowns you and a multi-word phrase returns nothing. Reimplementing an existing workspace helper — or hand-rolling what `std`, `tokio`, or an existing workspace dependency already provides — is a review finding, not a style preference.
|
||||||
- When changing existing behavior, keep naming and format consistency by aligning with established project constants.
|
- **Reuse requires matching semantics, not a matching name**: before adopting a helper, check its normalization (`clean` resolves `.`/`..` — never apply it to raw S3 object keys), error type, backoff/deadline behavior, and durability gating against the call site. When semantics differ, a new narrowly-named helper with a comment naming the rejected lookalike is the correct outcome. The inverse also holds: workspace wrappers exist because raw `std`/`tokio` semantics were insufficient (durability gates, retries) — prefer the wrapper over the raw call.
|
||||||
|
- **Constants and fixed tokens** (protocol labels, error identifiers, header keys, event names, metric names, command tags): search for existing constants/enums that already represent the same semantic value and reuse them. If a value is truly new, define one local constant near related logic; never scatter the literal across sites. When changing existing behavior, align naming and format with the established constants.
|
||||||
|
- **Test scaffolding**: reuse existing test utilities and fixtures (the touched crate's own `test_util` module and `tests/fixtures`, or `crates/test-utils`) instead of writing new setup code — run `rg -l '<fn-under-test>' <crate>/src <crate>/tests` before writing a test. A new test must pin a failure mode no existing test covers. Near-duplicate means same code path AND same poison-value class: this repo's boundary companions (n==max vs max+1, absent vs empty vs nil UUID bytes, MetaObject vs MetaDeleteMarker) are distinct by definition and must all be written.
|
||||||
|
|
||||||
|
## Necessary Code Only
|
||||||
|
|
||||||
|
Net-new code — files, types, branches, comments — is cost to justify, not progress:
|
||||||
|
|
||||||
|
- Validate at the trust boundary — untrusted client input, bytes read from disk, RPC payloads, config (see Serde Safety and Cross-Cutting Domain Invariants) — then trust the type: do not re-check what the type system or a validated upstream layer already guarantees, and cite the establishing check (`file:line`) when the guarantee is not obvious.
|
||||||
|
- The exception is load-bearing: a value that crossed a persistence, RPC, or version boundary is never guaranteed by the code on the other side — a peer may be older or buggy, disk bytes may be corrupt — so the Cross-Cutting Domain Invariant patterns apply at every consumer, and re-checks immediately before a destructive action (delete, overwrite, quorum decision) stay. Deleting an existing guard is a behavior change requiring adversarial review, not cleanup.
|
||||||
|
- Every new branch needs a nameable trigger: a concrete input, state, or failure that reaches it — for boundary-crossing values, corrupt or stale persisted/peer data is always nameable. If you cannot name one, do not write the branch. If the case is truly unreachable, encode the invariant in the type; where that is impossible, return a typed internal error (fail closed). `debug_assert!` is acceptable only for pure internal arithmetic on values that never crossed a disk/RPC/config boundary — never as the sole guard on decoded or peer-supplied data.
|
||||||
|
- Never substitute a default where the value is required (e.g. `unwrap_or_default()` on metadata that must exist) — that converts corruption into a wrong answer. Return the typed error instead: explicit failure over implicit success.
|
||||||
|
- Attach error context once, at the layer where it is actionable: re-wrapping equivalent context at every hop is noise, and expanding a fallible chain into nested `match` blocks where `?` or a combinator suffices is a finding. Never add context by converting a typed error into a generic variant below an error-aggregation or quorum layer (`reduce_errs` classifies by variant equality) — context there belongs in a `tracing` event, not the error value.
|
||||||
|
|
||||||
## Sources of Truth
|
## Sources of Truth
|
||||||
|
|
||||||
@@ -86,33 +105,78 @@ CI) fails the build if anything is committed under `docs/superpowers/`, even via
|
|||||||
|
|
||||||
## Verification Before PR
|
## Verification Before PR
|
||||||
|
|
||||||
Convert changes into independently verifiable outcomes. Prefer focused tests for behavior changes and run the relevant checks before declaring completion.
|
Convert changes into independently verifiable outcomes. This section controls
|
||||||
Non-exempt changes must also pass Adversarial Validation (next section) before the checks below count as completion.
|
agent-run local validation; preparing a commit or PR does not by itself require
|
||||||
|
the broadest gate. Inspect only the final task-owned diff, classify it by
|
||||||
|
behavioral impact rather than line count or path alone, and run the smallest
|
||||||
|
set of checks that provides meaningful coverage. Do not let unrelated
|
||||||
|
worktree changes or a generic contributor checklist expand the scope.
|
||||||
|
Non-exempt changes must also pass Adversarial Validation (next section) before
|
||||||
|
the checks below count as completion.
|
||||||
|
|
||||||
For code changes, run and pass the following before opening a PR:
|
### Validation floor
|
||||||
|
|
||||||
```bash
|
- Every change that is not documentation-only must finish with
|
||||||
make pre-pr
|
`cargo fmt --all --check` passing. An umbrella gate that runs this exact
|
||||||
```
|
check satisfies the requirement; do not run it twice. Use `cargo fmt --all`
|
||||||
|
only when formatting needs to be fixed. Run the configured formatter or
|
||||||
|
validator for other changed languages when one exists.
|
||||||
|
- Documentation-only or instruction-only means all task-owned changes are
|
||||||
|
prose or documentation assets and cannot affect runtime, builds, CI,
|
||||||
|
dependencies, generated code, or tests. Run `git diff --check` and any
|
||||||
|
relevant documentation guard, but skip Cargo formatting, compilation,
|
||||||
|
Clippy, tests, `make pre-commit`, and `make pre-pr`.
|
||||||
|
- Behavior changes require relevant existing or new tests. Prefer the most
|
||||||
|
focused test or affected package. A passing targeted test can also provide
|
||||||
|
sufficient compilation coverage when it builds every changed target and
|
||||||
|
feature involved; do not add a redundant `cargo check` in that case.
|
||||||
|
- `cargo check` supplements compilation coverage; it never substitutes for a
|
||||||
|
behavioral test. If a relevant test cannot reasonably be added or run, use
|
||||||
|
the narrowest compilation check and report the reason and remaining risk.
|
||||||
|
|
||||||
Before committing code changes, prefer focused verification for the touched
|
### Validation tiers
|
||||||
surface and use the faster local gate when a broad smoke check is needed:
|
|
||||||
|
|
||||||
```bash
|
1. **Documentation/instruction-only:** Apply the exemption above. Run a guard
|
||||||
make pre-commit
|
such as `make doc-paths-check` only when it is relevant to the edited text.
|
||||||
```
|
2. **Non-behavioral source change:** For comments, formatting, or another
|
||||||
|
demonstrably non-executable change, run the formatting floor. Compilation,
|
||||||
|
Clippy, and tests may be skipped only when the edit cannot affect
|
||||||
|
compilation or runtime behavior; run targeted doctests if executable
|
||||||
|
documentation examples changed.
|
||||||
|
3. **Localized or bounded behavior change:** Run the formatting floor and the
|
||||||
|
narrowest relevant tests. Add package-scoped `cargo check` or Clippy only
|
||||||
|
for changed targets, features, APIs, error handling, async behavior, or
|
||||||
|
control flow not already covered. When several crates are affected but the
|
||||||
|
dependency set is identifiable, validate those packages and known
|
||||||
|
dependents instead of the whole workspace. Use `make pre-commit` only when
|
||||||
|
a repository-wide fast gate adds useful confidence beyond those checks.
|
||||||
|
4. **Broad or high-risk change:** Run `make pre-pr` only when targeted coverage
|
||||||
|
cannot bound the impact, including:
|
||||||
|
- dependency, feature, build-script, procedural-macro, code-generation,
|
||||||
|
toolchain, or CI changes that alter compilation or the test matrix;
|
||||||
|
- cross-crate public APIs, shared foundational code, or broad refactors with
|
||||||
|
an unbounded dependent set;
|
||||||
|
- locking, storage durability or formats, erasure coding, replication,
|
||||||
|
RPC/protocol compatibility, IAM/KMS/auth, cryptography, or other
|
||||||
|
security-sensitive behavior;
|
||||||
|
- a targeted check that reveals wider impact, an explicit user request, or
|
||||||
|
a release policy that requires the full gate.
|
||||||
|
|
||||||
For migration batches, do not run the full `make pre-pr` gate before every
|
Documentation-only and non-behavioral classifications take precedence over
|
||||||
intermediate commit. Use focused tests and `make pre-commit` during
|
path-based triggers. A small diff can still be high-risk, while a CI comment,
|
||||||
development, then reserve `make pre-pr` for the final PR-ready branch.
|
manifest comment, or release-note edit does not require full validation.
|
||||||
|
|
||||||
Before pushing code changes, make sure formatting is clean:
|
`make pre-pr` includes `make pre-commit` coverage. Never run both for the same
|
||||||
|
unchanged diff, and do not repeat equivalent checks during PR preparation or
|
||||||
|
because a local hook already ran them. Rerun only checks whose scope is affected
|
||||||
|
by later edits. Full workspace checks do not replace a relevant integration or
|
||||||
|
E2E test for changed behavior; run that focused test when required and
|
||||||
|
available, or report why it was not run and the remaining risk.
|
||||||
|
|
||||||
- Run `cargo fmt --all`.
|
If `make` is unavailable, run the equivalent checks defined under
|
||||||
- Run `cargo fmt --all --check` and ensure no files are modified unexpectedly.
|
`.config/make/`. At handoff, list the checks actually run, checks intentionally
|
||||||
|
skipped, and the reason for the selected tier.
|
||||||
|
|
||||||
If `make` is unavailable, run the equivalent checks defined under `.config/make/`.
|
|
||||||
Documentation-only or instruction-only changes are exempt from the verification commands above (including the `.config/make/` equivalents), though any locally installed git pre-commit hooks may still run on commit unless explicitly skipped.
|
|
||||||
After build-based verification completes, clean generated build artifacts before wrapping up to avoid unnecessary disk usage.
|
After build-based verification completes, clean generated build artifacts before wrapping up to avoid unnecessary disk usage.
|
||||||
Do not open a PR with code changes when the required checks fail.
|
Do not open a PR with code changes when the required checks fail.
|
||||||
Make a failing check pass by fixing the cause, never by weakening the gate:
|
Make a failing check pass by fixing the cause, never by weakening the gate:
|
||||||
@@ -141,7 +205,7 @@ Pick the tier from the riskiest file touched; when in doubt, pick the higher.
|
|||||||
- **Exempt:** docs/comments/instruction-only changes, formatting, typos with
|
- **Exempt:** docs/comments/instruction-only changes, formatting, typos with
|
||||||
no runtime surface. Skip this section.
|
no runtime surface. Skip this section.
|
||||||
- **Mechanical:** pure renames, file moves, test-only or tooling changes —
|
- **Mechanical:** pure renames, file moves, test-only or tooling changes —
|
||||||
correctness adversary only.
|
correctness and simplicity adversaries only.
|
||||||
- **Standard (the default):** any change that affects behavior.
|
- **Standard (the default):** any change that affects behavior.
|
||||||
- **High risk:** touches locking, erasure coding, quorum/heal, replication,
|
- **High risk:** touches locking, erasure coding, quorum/heal, replication,
|
||||||
multipart, RPC, lifecycle/tiering, metadata formats (`xl.meta`),
|
multipart, RPC, lifecycle/tiering, metadata formats (`xl.meta`),
|
||||||
@@ -161,9 +225,8 @@ encode this repo's shipped bugs.
|
|||||||
|
|
||||||
- **Correctness adversary** — construct a concrete input/state/interleaving
|
- **Correctness adversary** — construct a concrete input/state/interleaving
|
||||||
that yields wrong output, data loss, or a crash. Probe error paths and edge
|
that yields wrong output, data loss, or a crash. Probe error paths and edge
|
||||||
values (empty, nil UUID, zero-length, quorum−1, missing version). For code
|
values (empty, nil UUID, zero-length, quorum−1, missing version).
|
||||||
diffs, a materially smaller or more idiomatic diff achieving the same
|
- **Simplicity adversary** — same behavior, less code. Hunt the materially smaller or more idiomatic diff (see Change Style for Existing Logic, Reuse Before You Write, and Necessary Code Only): reimplemented workspace helpers, one-caller extractions, rewrites where an in-place edit suffices, defensive branches with no nameable trigger, redundant error wrapping, near-duplicate tests, narration comments. A smaller diff achieving identical behavior is a finding, reported with the concrete replacement; forced reuse of a helper with mismatched semantics is equally a finding.
|
||||||
behavior is also a finding (see Change Style for Existing Logic).
|
|
||||||
- **Security reviewer** — authn/authz bypass, injection, secret leakage,
|
- **Security reviewer** — authn/authz bypass, injection, secret leakage,
|
||||||
untrusted deserialization (see Serde Safety), path traversal, timing leaks.
|
untrusted deserialization (see Serde Safety), path traversal, timing leaks.
|
||||||
- **Concurrency/durability reviewer** — lock ordering, races, cancellation,
|
- **Concurrency/durability reviewer** — lock ordering, races, cancellation,
|
||||||
@@ -179,11 +242,12 @@ encode this repo's shipped bugs.
|
|||||||
wrong while all tests stay green — if one exists, coverage is insufficient.
|
wrong while all tests stay green — if one exists, coverage is insufficient.
|
||||||
A missing test is a finding, not a note.
|
A missing test is a finding, not a note.
|
||||||
|
|
||||||
Standard tier: correctness adversary + test-coverage skeptic, plus every
|
Standard tier: correctness adversary + simplicity adversary + test-coverage
|
||||||
role whose domain the diff touches (async or shared-state code →
|
skeptic, plus every role whose domain the diff touches (async or
|
||||||
concurrency; parsing of untrusted input → security; public crate API shape
|
shared-state code → concurrency; parsing of untrusted input → security;
|
||||||
→ compatibility; per-request or per-object hot paths → performance).
|
public crate API shape → compatibility; per-request or per-object hot paths
|
||||||
High risk: all six roles.
|
→ performance).
|
||||||
|
High risk: all seven roles.
|
||||||
|
|
||||||
### Protocol
|
### Protocol
|
||||||
|
|
||||||
|
|||||||
+18
-117
@@ -41,7 +41,7 @@ The repository is a Cargo workspace with a flat `crates/` layout:
|
|||||||
|
|
||||||
```
|
```
|
||||||
rustfs/ # Workspace root (virtual manifest)
|
rustfs/ # Workspace root (virtual manifest)
|
||||||
├── rustfs/ # Main binary + library crate (75K lines)
|
├── rustfs/ # Main binary + library crate
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── main.rs # Entry point, startup sequence
|
│ ├── main.rs # Entry point, startup sequence
|
||||||
│ ├── lib.rs # Module tree root
|
│ ├── lib.rs # Module tree root
|
||||||
@@ -53,7 +53,7 @@ rustfs/ # Workspace root (virtual manifest)
|
|||||||
│ ├── config/ # CLI args, config parsing, workload profiles
|
│ ├── config/ # CLI args, config parsing, workload profiles
|
||||||
│ └── ...
|
│ └── ...
|
||||||
├── crates/ # library crates (authoritative list: Cargo.toml [workspace].members)
|
├── crates/ # library crates (authoritative list: Cargo.toml [workspace].members)
|
||||||
│ ├── ecstore/ # Erasure-coded storage engine (⚠️ 87K lines)
|
│ ├── ecstore/ # Erasure-coded storage engine
|
||||||
│ ├── rio/ # Reader I/O pipeline (encrypt, compress, hash)
|
│ ├── rio/ # Reader I/O pipeline (encrypt, compress, hash)
|
||||||
│ ├── io-core/ # Zero-copy I/O, scheduling, buffer pool
|
│ ├── io-core/ # Zero-copy I/O, scheduling, buffer pool
|
||||||
│ ├── io-metrics/ # I/O metrics collection
|
│ ├── io-metrics/ # I/O metrics collection
|
||||||
@@ -83,124 +83,25 @@ A request flows **downward** through the layers. No layer should reach upward
|
|||||||
|
|
||||||
### Crate Reference
|
### Crate Reference
|
||||||
|
|
||||||
> Depth levels, line counts, and crate counts in this section are a
|
`Cargo.toml` is the authoritative workspace membership and `cargo tree` is the
|
||||||
> point-in-time snapshot and drift with refactors. Treat them as orders of
|
authoritative dependency graph. This overview deliberately avoids line-count
|
||||||
> magnitude; `Cargo.toml` and `cargo tree` are the source of truth.
|
and dependency-depth snapshots because both quickly become stale during
|
||||||
|
refactors.
|
||||||
Crates are organized in a dependency DAG with 9 depth levels (0 = leaf, 8 = top):
|
|
||||||
|
|
||||||
```
|
|
||||||
Depth 0 — LEAF (no internal deps):
|
|
||||||
appauth, checksums, config, credentials, crypto, io-metrics,
|
|
||||||
madmin, s3-common, workers, zip
|
|
||||||
|
|
||||||
Depth 1:
|
|
||||||
io-core (→ io-metrics)
|
|
||||||
policy (→ config, credentials, crypto)
|
|
||||||
utils (historical → config edge removed; now effectively leaf)
|
|
||||||
|
|
||||||
Depth 2:
|
|
||||||
concurrency, filemeta, keystone, kms, lock, obs,
|
|
||||||
signer, targets, trusted-proxies
|
|
||||||
|
|
||||||
Depth 3:
|
|
||||||
common (historical → filemeta/madmin edges removed; now effectively leaf)
|
|
||||||
|
|
||||||
Depth 4:
|
|
||||||
object-capacity, protos, rio
|
|
||||||
|
|
||||||
Depth 5 — CORE:
|
|
||||||
ecstore (16 internal deps, 11 dependents — the architectural heart)
|
|
||||||
|
|
||||||
Depth 6:
|
|
||||||
audit, heal, iam, metrics, notify, s3select-api, scanner
|
|
||||||
|
|
||||||
Depth 7:
|
|
||||||
object-io, protocols, s3select-query
|
|
||||||
|
|
||||||
Depth 8 — TOP:
|
|
||||||
rustfs (35 internal deps — the binary, depends on almost everything)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### By Domain
|
#### By Domain
|
||||||
|
|
||||||
**Core Infrastructure:**
|
| Domain | Current workspace crates | Responsibility |
|
||||||
|
|--------|--------------------------|----------------|
|
||||||
|
| Foundation | `checksums`, `common`, `config`, `data-usage`, `utils` | Shared configuration, data-usage models, 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`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, and I/O pipelines. |
|
||||||
|
| 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. |
|
||||||
|
| Operations and integration | `audit`, `notify`, `obs`, `targets`, `zip` | Auditing, observability, event delivery, notification targets, and archive support. |
|
||||||
|
| Test support | `e2e_test`, `test-utils` | End-to-end validation and shared test bootstrap utilities. |
|
||||||
|
|
||||||
| Crate | Lines | Purpose |
|
The `rustfs` binary crate composes these libraries into the running server.
|
||||||
|-------|-------|---------|
|
`ecstore` remains the storage engine at the architectural center; its internal
|
||||||
| `config` | 3.3K | Configuration types and environment parsing |
|
module split is tracked under `docs/architecture/`.
|
||||||
| `utils` | 8.7K | Pure utilities (paths, compression, network, retry) |
|
|
||||||
| `common` | 4.4K | Shared runtime state, globals, data usage types, metrics |
|
|
||||||
| `madmin` | 5.5K | Admin API request/response types |
|
|
||||||
|
|
||||||
**I/O Pipeline:**
|
|
||||||
|
|
||||||
| Crate | Lines | Purpose |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| `io-core` | 6.5K | Zero-copy I/O, buffer pool, direct I/O, scheduling, backpressure |
|
|
||||||
| `io-metrics` | 4.5K | I/O operation metrics and counters |
|
|
||||||
| `rio` | 6.9K | Composable reader chain (encrypt → compress → hash → limit) |
|
|
||||||
| `object-io` | 2.4K | High-level object read/write using rio + ecstore |
|
|
||||||
| `concurrency` | 0.8K | Shared concurrency contract types: workload admission snapshots, worker-slot pool, policy types (runtime control lives in `rustfs/src/storage`) |
|
|
||||||
|
|
||||||
**Storage Engine:**
|
|
||||||
|
|
||||||
| Crate | Lines | Purpose |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| `ecstore` | 87K | ⚠️ Erasure-coded storage: disks, pools, buckets, replication, lifecycle |
|
|
||||||
| `filemeta` | 10K | File/object metadata types and versioning |
|
|
||||||
| `checksums` | 732 | Checksum computation |
|
|
||||||
| `lock` | 7.1K | Distributed lock manager |
|
|
||||||
| `heal` | 5.9K | Data healing / bitrot repair |
|
|
||||||
| `scanner` | 5.4K | Background data usage scanner |
|
|
||||||
| `object-capacity` | 2.5K | Capacity tracking and management |
|
|
||||||
|
|
||||||
**Security & Auth:**
|
|
||||||
|
|
||||||
| Crate | Lines | Purpose |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| `crypto` | 1.6K | Encryption primitives |
|
|
||||||
| `credentials` | 713 | Credential types (access key / secret key) |
|
|
||||||
| `signer` | 1.4K | S3 v4 request signing |
|
|
||||||
| `iam` | 9.0K | Identity and access management |
|
|
||||||
| `policy` | 8.8K | Policy engine (S3 bucket/IAM policies) |
|
|
||||||
| `kms` | 8.1K | Key management service integration |
|
|
||||||
| `keystone` | 1.9K | OpenStack Keystone auth |
|
|
||||||
| `appauth` | 143 | Application-level auth tokens |
|
|
||||||
|
|
||||||
**Protocol & API:**
|
|
||||||
|
|
||||||
| Crate | Lines | Purpose |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| `protos` | 5.7K | Protobuf/gRPC definitions for inter-node RPC |
|
|
||||||
| `protocols` | 18K | FTP/FTPS, WebDAV, Swift API support |
|
|
||||||
| `s3-common` | 738 | Shared S3 types |
|
|
||||||
| `s3select-api` | 1.9K | S3 Select interface |
|
|
||||||
| `s3select-query` | 3.6K | S3 Select query engine |
|
|
||||||
|
|
||||||
**Observability:**
|
|
||||||
|
|
||||||
| Crate | Lines | Purpose |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| `metrics` | 8.4K | Prometheus metric collectors |
|
|
||||||
| `io-metrics` | 4.5K | I/O-specific metrics |
|
|
||||||
| `obs` | 5.6K | OpenTelemetry tracing and telemetry |
|
|
||||||
| `audit` | 2.4K | Audit logging |
|
|
||||||
|
|
||||||
**Events:**
|
|
||||||
|
|
||||||
| Crate | Lines | Purpose |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| `notify` | 5.5K | Event notification system |
|
|
||||||
| `targets` | 3.2K | Notification targets (Kafka, AMQP, webhook, etc.) |
|
|
||||||
|
|
||||||
**Other:**
|
|
||||||
|
|
||||||
| Crate | Lines | Purpose |
|
|
||||||
|-------|-------|---------|
|
|
||||||
| `trusted-proxies` | 4.0K | Trusted proxy / IP forwarding |
|
|
||||||
| `zip` | 986 | ZIP archive support for bulk downloads |
|
|
||||||
| `workers` | 136 | Simple worker abstraction |
|
|
||||||
|
|
||||||
## Architecture Invariants
|
## Architecture Invariants
|
||||||
|
|
||||||
@@ -212,7 +113,7 @@ Depth 8 — TOP:
|
|||||||
No upward imports.
|
No upward imports.
|
||||||
|
|
||||||
2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`,
|
2. **Leaf crates have zero internal dependencies.** `config`, `credentials`, `crypto`,
|
||||||
`io-metrics`, `madmin`, `s3-common` should depend only on external crates.
|
`io-metrics`, and `madmin` should depend only on external crates.
|
||||||
- ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin`
|
- ✅ RESOLVED: the historical `utils → config` and `common → filemeta`/`madmin`
|
||||||
edges were removed; do not reintroduce them (see Known Structural Issues).
|
edges were removed; do not reintroduce them (see Known Structural Issues).
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
- **Helm Ingress**: `customAnnotations` are now merged with class-specific annotations (nginx/traefik) instead of being ignored when `ingress.className` is set.
|
||||||
|
- **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
|
||||||
- **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.
|
||||||
@@ -38,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
|
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
|
||||||
|
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
|
||||||
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
|
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
|
||||||
- **Auth Module**: Modified `check_key_valid()` to retrieve Keystone credentials from task-local storage and determine admin status
|
- **Auth Module**: Modified `check_key_valid()` to retrieve Keystone credentials from task-local storage and determine admin status
|
||||||
- **`StorageBackend` trait**: extended with multipart upload methods (`create_multipart_upload`, `upload_part`, `complete_multipart_upload`, `abort_multipart_upload`) plus `upload_part_copy`. Streaming-upload code path is now available to FTPS, WebDAV, and Swift drivers as well.
|
- **`StorageBackend` trait**: extended with multipart upload methods (`create_multipart_upload`, `upload_part`, `complete_multipart_upload`, `abort_multipart_upload`) plus `upload_part_copy`. Streaming-upload code path is now available to FTPS, WebDAV, and Swift drivers as well.
|
||||||
|
|||||||
Generated
+854
-512
File diff suppressed because it is too large
Load Diff
+104
-110
@@ -31,6 +31,7 @@ members = [
|
|||||||
"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
|
||||||
|
"crates/log-analyzer", # Offline log fault-analysis core (rustfs diagnose)
|
||||||
"crates/madmin", # Management dashboard and admin API interface
|
"crates/madmin", # Management dashboard and admin API interface
|
||||||
"crates/notify", # Notification system for events
|
"crates/notify", # Notification system for events
|
||||||
"crates/obs", # Observability utilities
|
"crates/obs", # Observability utilities
|
||||||
@@ -67,8 +68,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.96.0"
|
rust-version = "1.97.1"
|
||||||
version = "1.0.0-beta.10"
|
version = "1.0.0-beta.12"
|
||||||
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"]
|
||||||
@@ -85,83 +86,84 @@ redundant_clone = "warn"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# RustFS Internal Crates
|
# RustFS Internal Crates
|
||||||
rustfs = { path = "./rustfs", version = "1.0.0-beta.10" }
|
rustfs = { path = "./rustfs", version = "1.0.0-beta.12" }
|
||||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.10" }
|
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.12" }
|
||||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.10" }
|
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.12" }
|
||||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.10" }
|
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.12" }
|
||||||
rustfs-common = { path = "crates/common", version = "1.0.0-beta.10" }
|
rustfs-common = { path = "crates/common", version = "1.0.0-beta.12" }
|
||||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.10" }
|
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.12" }
|
||||||
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.10" }
|
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.12" }
|
||||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.10" }
|
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.12" }
|
||||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.10" }
|
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.12" }
|
||||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.10" }
|
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.12" }
|
||||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.10" }
|
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.12" }
|
||||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.10" }
|
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.12" }
|
||||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.10" }
|
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.12" }
|
||||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.10" }
|
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.12" }
|
||||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.10" }
|
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.12" }
|
||||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.10" }
|
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.12" }
|
||||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.10" }
|
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.12" }
|
||||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.10" }
|
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.12" }
|
||||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.10" }
|
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.12" }
|
||||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.10" }
|
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.12" }
|
||||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.10" }
|
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.12" }
|
||||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.10" }
|
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.12" }
|
||||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.10" }
|
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.12" }
|
||||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.10" }
|
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.12" }
|
||||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.10" }
|
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.12" }
|
||||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.10" }
|
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.12" }
|
||||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.10" }
|
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.12" }
|
||||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.10" }
|
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.12" }
|
||||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.10" }
|
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.12" }
|
||||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.10" }
|
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.12" }
|
||||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.10" }
|
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.12" }
|
||||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.10" }
|
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.12" }
|
||||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.10" }
|
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.12" }
|
||||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.10" }
|
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.12" }
|
||||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.10" }
|
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.12" }
|
||||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.10" }
|
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.12" }
|
||||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.10" }
|
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.12" }
|
||||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.10" }
|
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.12" }
|
||||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.10" }
|
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.12" }
|
||||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.10" }
|
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.12" }
|
||||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.10" }
|
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.12" }
|
||||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.10" }
|
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.12" }
|
||||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.10" }
|
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.12" }
|
||||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.10" }
|
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.12" }
|
||||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.10" }
|
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.12" }
|
||||||
|
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.12" }
|
||||||
|
|
||||||
# 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.18" }
|
async_zip = { default-features = false, version = "0.0.18" }
|
||||||
mysql_async = { default-features = false, version = "0.37" }
|
mysql_async = { default-features = false, version = "0.37" }
|
||||||
async-compression = { version = "0.4.42" }
|
async-compression = { version = "0.4.43" }
|
||||||
async-recursion = "1.1.1"
|
async-recursion = "1.1.1"
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.91"
|
||||||
async-nats = "0.49.1"
|
async-nats = { version = "0.50.0", default-features = false }
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
futures = "0.3.32"
|
futures = "0.3.33"
|
||||||
futures-core = "0.3.32"
|
futures-core = "0.3.33"
|
||||||
futures-lite = "2.6.1"
|
futures-lite = "2.6.1"
|
||||||
futures-util = "0.3.32"
|
futures-util = "0.3.33"
|
||||||
pollster = "1.0.1"
|
pollster = "1.0.1"
|
||||||
pulsar = { default-features = false, version = "6.8.0" }
|
pulsar = { default-features = false, version = "6.8.0" }
|
||||||
lapin = { default-features = false, version = "4.10.0" }
|
lapin = { default-features = false, version = "4.10.0" }
|
||||||
hyper = { version = "1.10.1" }
|
hyper = { version = "1.11.0" }
|
||||||
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.4.2"
|
http = "1.5.0"
|
||||||
http-body = "1.1.0"
|
http-body = "1.1.0"
|
||||||
http-body-util = "0.1.4"
|
http-body-util = "0.1.4"
|
||||||
minlz = "1.2.3"
|
minlz = "1.2.3"
|
||||||
reqwest = { default-features = false, version = "0.13.4" }
|
reqwest = "0.13.4"
|
||||||
rustfs-kafka-async = { version = "1.2.0" }
|
rustfs-kafka-async = { version = "1.2.0" }
|
||||||
socket2 = { version = "0.6.5" }
|
socket2 = { version = "0.6.5" }
|
||||||
tokio = { version = "1.52.3" }
|
tokio = { version = "1.53.1" }
|
||||||
tokio-rustls = { default-features = false, version = "0.26.4" }
|
tokio-rustls = { default-features = false, version = "0.26.4" }
|
||||||
tokio-stream = { version = "0.1.18" }
|
tokio-stream = { version = "0.1.19" }
|
||||||
tokio-test = "0.4.5"
|
tokio-test = "0.4.5"
|
||||||
tokio-util = { version = "0.7.18" }
|
tokio-util = { version = "0.7.19" }
|
||||||
tonic = { version = "0.14.6" }
|
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" }
|
||||||
@@ -171,7 +173,7 @@ tower-http = { version = "0.7.0" }
|
|||||||
# Serialization and Data Formats
|
# Serialization and Data Formats
|
||||||
apache-avro = "0.21.0"
|
apache-avro = "0.21.0"
|
||||||
bytes = { version = "1.12.1" }
|
bytes = { version = "1.12.1" }
|
||||||
bytesize = "2.4.2"
|
bytesize = "2.6.0"
|
||||||
byteorder = "1.5.0"
|
byteorder = "1.5.0"
|
||||||
flatbuffers = "25.12.19"
|
flatbuffers = "25.12.19"
|
||||||
form_urlencoded = "1.2.2"
|
form_urlencoded = "1.2.2"
|
||||||
@@ -179,8 +181,8 @@ prost = "0.14.4"
|
|||||||
quick-xml = "0.41.0"
|
quick-xml = "0.41.0"
|
||||||
rmp = { version = "0.8.15" }
|
rmp = { version = "0.8.15" }
|
||||||
rmp-serde = { version = "1.3.1" }
|
rmp-serde = { version = "1.3.1" }
|
||||||
serde = { version = "1.0.228" }
|
serde = { version = "1.0.229" }
|
||||||
serde_json = { version = "1.0.150" }
|
serde_json = { version = "1.0.151" }
|
||||||
serde_urlencoded = "0.7.1"
|
serde_urlencoded = "0.7.1"
|
||||||
|
|
||||||
# Cryptography and Security
|
# Cryptography and Security
|
||||||
@@ -194,13 +196,13 @@ blake2 = "=0.11.0-rc.6"
|
|||||||
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 = "10.4.0" }
|
jsonwebtoken = { version = "11.0.0" }
|
||||||
openidconnect = { default-features = false, version = "4.0" }
|
openidconnect = { default-features = false, version = "4.0" }
|
||||||
pbkdf2 = "0.13.0"
|
pbkdf2 = "0.13.0"
|
||||||
rsa = { version = "=0.10.0-rc.18" }
|
rsa = { version = "=0.10.0-rc.18" }
|
||||||
rustls = { default-features = false, version = "0.23.42" }
|
rustls = { default-features = false, version = "0.23.43" }
|
||||||
rustls-native-certs = "0.8"
|
rustls-native-certs = "0.8"
|
||||||
rustls-pki-types = "1.15.0"
|
rustls-pki-types = "1.15.1"
|
||||||
sha1 = "0.11.0"
|
sha1 = "0.11.0"
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
subtle = "2.6"
|
subtle = "2.6"
|
||||||
@@ -209,8 +211,8 @@ zeroize = { version = "1.9.0" }
|
|||||||
# Time and Date
|
# Time and Date
|
||||||
chrono = { version = "0.4.45" }
|
chrono = { version = "0.4.45" }
|
||||||
humantime = "2.4.0"
|
humantime = "2.4.0"
|
||||||
jiff = { version = "0.2.32" }
|
jiff = { version = "0.2.35" }
|
||||||
time = { version = "0.3.53" }
|
time = { version = "0.3.54" }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
deadpool-postgres = { version = "0.14" }
|
deadpool-postgres = { version = "0.14" }
|
||||||
@@ -218,21 +220,23 @@ tokio-postgres = { default-features = false, version = "0.7.18" }
|
|||||||
tokio-postgres-rustls = "0.14.0"
|
tokio-postgres-rustls = "0.14.0"
|
||||||
|
|
||||||
# Utilities and Tools
|
# Utilities and Tools
|
||||||
anyhow = "1.0.103"
|
anyhow = "1.0.104"
|
||||||
arc-swap = "1.9.2"
|
arc-swap = "1.9.2"
|
||||||
astral-tokio-tar = "0.6.3"
|
astral-tokio-tar = "0.6.4"
|
||||||
atoi = "3.1.0"
|
atoi = "3.1.0"
|
||||||
atomic_enum = "0.3.0"
|
atomic_enum = "0.3.0"
|
||||||
aws-config = { version = "1.9.0" }
|
aws-config = { version = "1.10.1" }
|
||||||
aws-credential-types = { version = "1.3.0" }
|
aws-credential-types = { version = "1.3.0" }
|
||||||
aws-sdk-s3 = { default-features = false, version = "1.138.0" }
|
aws-sdk-kms = { default-features = false, version = "1.114.0" }
|
||||||
|
aws-sdk-s3 = { default-features = false, version = "1.140.0" }
|
||||||
|
aws-sdk-sts = { default-features = false, version = "1.110.0" }
|
||||||
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
|
aws-smithy-http-client = { default-features = false, version = "1.2.0" }
|
||||||
aws-smithy-runtime-api = { version = "1.13.0" }
|
aws-smithy-runtime-api = { version = "1.14.0" }
|
||||||
aws-smithy-types = { version = "1.6.1" }
|
aws-smithy-types = { version = "1.6.1" }
|
||||||
base64 = "0.22.1"
|
base64 = "0.23.0"
|
||||||
base64-simd = "0.8.0"
|
base64-simd = "0.8.0"
|
||||||
brotli = "8.0.4"
|
brotli = "8.0.4"
|
||||||
clap = { version = "4.6.2" }
|
clap = { version = "4.6.5" }
|
||||||
const-str = { version = "1.1.0" }
|
const-str = { version = "1.1.0" }
|
||||||
convert_case = "0.11.0"
|
convert_case = "0.11.0"
|
||||||
criterion = { version = "0.8" }
|
criterion = { version = "0.8" }
|
||||||
@@ -241,27 +245,28 @@ crossbeam-channel = "0.5.16"
|
|||||||
crossbeam-deque = "0.8.7"
|
crossbeam-deque = "0.8.7"
|
||||||
crossbeam-utils = "0.8.22"
|
crossbeam-utils = "0.8.22"
|
||||||
datafusion = { default-features = false, git = "https://github.com/apache/datafusion.git", rev = "dae03ee062b2abf986de8df12ea82fb1578a2d99" }
|
datafusion = { default-features = false, git = "https://github.com/apache/datafusion.git", rev = "dae03ee062b2abf986de8df12ea82fb1578a2d99" }
|
||||||
|
#datafusion = { default-features = false, version = "54.1.0" }
|
||||||
derive_builder = "0.20.2"
|
derive_builder = "0.20.2"
|
||||||
enumset = "1.1.13"
|
enumset = "1.1.14"
|
||||||
faster-hex = "0.10.0"
|
faster-hex = "0.10.0"
|
||||||
flate2 = "1.1.9"
|
flate2 = "1.1.9"
|
||||||
glob = "0.3.3"
|
glob = "0.3.4"
|
||||||
google-cloud-storage = "1.16.0"
|
google-cloud-storage = "1.17.0"
|
||||||
google-cloud-auth = "1.14.0"
|
google-cloud-auth = "1.15.0"
|
||||||
hashbrown = { version = "0.17.1" }
|
hashbrown = { version = "0.17.1" }
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
hex-simd = "0.8.0"
|
hex-simd = "0.8.0"
|
||||||
highway = { version = "1.3.0" }
|
highway = { version = "1.3.0" }
|
||||||
|
hostname = "0.4.2"
|
||||||
ipnetwork = { version = "0.21.1" }
|
ipnetwork = { version = "0.21.1" }
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
libc = "0.2.186"
|
libc = "0.2.189"
|
||||||
libsystemd = "0.7.2"
|
libsystemd = "0.7.2"
|
||||||
local-ip-address = "0.6.13"
|
local-ip-address = "0.6.13"
|
||||||
memmap2 = "0.9.11"
|
memmap2 = "0.9.11"
|
||||||
lz4 = "1.28.1"
|
lz4 = "1.28.1"
|
||||||
matchit = "0.9.2"
|
matchit = "0.9.2"
|
||||||
md-5 = "0.11.0"
|
md-5 = "0.11.0"
|
||||||
md5 = "0.8.1"
|
|
||||||
mime_guess = "2.0.5"
|
mime_guess = "2.0.5"
|
||||||
moka = { version = "0.12.15" }
|
moka = { version = "0.12.15" }
|
||||||
netif = "0.1.6"
|
netif = "0.1.6"
|
||||||
@@ -276,16 +281,17 @@ pretty_assertions = "1.4.1"
|
|||||||
rand = { version = "0.10.2" }
|
rand = { version = "0.10.2" }
|
||||||
ratelimit = "0.10.1"
|
ratelimit = "0.10.1"
|
||||||
rayon = "1.12.0"
|
rayon = "1.12.0"
|
||||||
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.0" }
|
reed-solomon-erasure = { package = "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.33.2" }
|
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
|
||||||
redis = { version = "1.4.0" }
|
redis = { version = "1.5.0" }
|
||||||
|
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/s3s-project/s3s.git", rev = "ce69c3f10824535c7c24b2f71cdb2aaa4dffb5e0" }
|
s3s = { git = "https://github.com/cxymds/s3s.git", rev = "fe3941d91fa1c69956f209a9145995c9f0235bff" }
|
||||||
serial_test = "3.5.0"
|
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.15.2" }
|
||||||
@@ -297,9 +303,10 @@ sysinfo = "0.39.6"
|
|||||||
temp-env = "0.3.6"
|
temp-env = "0.3.6"
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
test-case = "3.3.1"
|
test-case = "3.3.1"
|
||||||
thiserror = "2.0.18"
|
thiserror = "2.0.19"
|
||||||
tracing = { version = "0.1.44" }
|
tracing = { version = "0.1.44" }
|
||||||
tracing-appender = "0.2.5"
|
tracing-appender = "0.2.5"
|
||||||
|
tracing-core = "0.1.36"
|
||||||
tracing-error = "0.2.1"
|
tracing-error = "0.2.1"
|
||||||
tracing-opentelemetry = { version = "0.33" }
|
tracing-opentelemetry = { version = "0.33" }
|
||||||
tracing-subscriber = { version = "0.3.23" }
|
tracing-subscriber = { version = "0.3.23" }
|
||||||
@@ -308,9 +315,12 @@ url = "2.5.8"
|
|||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
uuid = { version = "1.24.0" }
|
uuid = { version = "1.24.0" }
|
||||||
vaultrs = { version = "0.8.0" }
|
vaultrs = { version = "0.8.0" }
|
||||||
|
tar = "0.4.46"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
|
winapi-util = "0.1.11"
|
||||||
windows = { version = "0.62.2" }
|
windows = { version = "0.62.2" }
|
||||||
xxhash-rust = { version = "0.8.17" }
|
windows-sys = "0.61.2"
|
||||||
|
xxhash-rust = { version = "0.8.18" }
|
||||||
zip = "8.6.0"
|
zip = "8.6.0"
|
||||||
zstd = "0.13.3"
|
zstd = "0.13.3"
|
||||||
|
|
||||||
@@ -320,25 +330,27 @@ dial9-tokio-telemetry = "0.3"
|
|||||||
opentelemetry = { version = "0.32.0" }
|
opentelemetry = { version = "0.32.0" }
|
||||||
opentelemetry-appender-tracing = { version = "0.32.0" }
|
opentelemetry-appender-tracing = { version = "0.32.0" }
|
||||||
opentelemetry-otlp = { version = "0.32.0" }
|
opentelemetry-otlp = { version = "0.32.0" }
|
||||||
|
opentelemetry-proto = { version = "0.32.0", default-features = false, features = ["metrics", "gen-tonic-messages"] }
|
||||||
opentelemetry_sdk = { version = "0.32.1" }
|
opentelemetry_sdk = { version = "0.32.1" }
|
||||||
opentelemetry-semantic-conventions = { version = "0.32.1" }
|
opentelemetry-semantic-conventions = { version = "0.32.1" }
|
||||||
opentelemetry-stdout = { version = "0.32.0" }
|
opentelemetry-stdout = { version = "0.32.0" }
|
||||||
pyroscope = { version = "2.1.0" }
|
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.1" }
|
suppaftp = { version = "10.0.1" }
|
||||||
rcgen = "0.14.8"
|
rcgen = { version = "0.14.8", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||||
russh = { version = "0.62.2" }
|
russh = { version = "0.62.5" }
|
||||||
russh-sftp = "2.3.0"
|
russh-sftp = "2.3.0"
|
||||||
|
|
||||||
# WebDAV
|
# WebDAV
|
||||||
dav-server = "0.11.0"
|
dav-server = "0.11.0"
|
||||||
|
|
||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
mimalloc = "0.1"
|
mimalloc = { version = "0.1.52", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "1cdadea43e9c5a0f054b65be21200ce580e4eb13" }
|
||||||
hotpath = "0.21"
|
libmimalloc-sys = { version = "0.1.49", git = "https://github.com/xonatius/mimalloc_rust.git", rev = "1cdadea43e9c5a0f054b65be21200ce580e4eb13", features = ["extended"] }
|
||||||
|
hotpath = { version = "0.22.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" }
|
||||||
|
|
||||||
@@ -367,21 +379,3 @@ inherits = "release"
|
|||||||
inherits = "release"
|
inherits = "release"
|
||||||
debug = true
|
debug = true
|
||||||
strip = "none"
|
strip = "none"
|
||||||
|
|
||||||
# Pin hyper to a revision that carries the HTTP/1 "flush buffered data before
|
|
||||||
# shutdown" fix (hyperium/hyper#4018, commit 72046cc7). This lands as a
|
|
||||||
# `[patch.crates-io]` entry — not on the `hyper` workspace dependency — so that
|
|
||||||
# every consumer in the tree, including the transitive `hyper-util` server path
|
|
||||||
# (`conn::auto` / `GracefulShutdown`) that actually drives our connections,
|
|
||||||
# resolves to the fixed hyper rather than the buggy crates.io copy.
|
|
||||||
#
|
|
||||||
# hyper <= 1.10.1 can call `poll_shutdown()` on the socket while response bytes
|
|
||||||
# are still buffered (a prior `poll_flush()` returned `Poll::Pending` and the
|
|
||||||
# result was discarded). A backpressured / slow-reading peer then receives a
|
|
||||||
# graceful FIN before the full Content-Length body is flushed, which standard S3
|
|
||||||
# clients (minio-go / warp) report as `unexpected EOF` on large-object GET under
|
|
||||||
# load. The fix is not in any crates.io release yet as of hyper 1.10.1; drop
|
|
||||||
# this patch once a released version (> 1.10.1) contains commit 72046cc7.
|
|
||||||
# See rustfs/backlog#1232.
|
|
||||||
[patch.crates-io]
|
|
||||||
hyper = { git = "https://github.com/hyperium/hyper.git", rev = "ccc1e850dc0cda3e71b0acd11f60ca3d48d09034" }
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
FROM rust:1.97-trixie
|
FROM rust:1.97.1-trixie
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
export DEBIAN_FRONTEND=noninteractive; \
|
export DEBIAN_FRONTEND=noninteractive; \
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ ARG RUSTFS_BUILD_FEATURES=""
|
|||||||
# -----------------------------
|
# -----------------------------
|
||||||
# Build stage
|
# Build stage
|
||||||
# -----------------------------
|
# -----------------------------
|
||||||
FROM rust:1.97-trixie AS builder
|
FROM rust:1.97.1-trixie AS builder
|
||||||
|
|
||||||
# Re-declare args after FROM
|
# Re-declare args after FROM
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
|
|||||||
@@ -116,7 +116,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-beta.10
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.12
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
@@ -163,6 +163,7 @@ docker run -d --name rustfs -p 9000:9000 \
|
|||||||
-e RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY=on \
|
-e RUSTFS_NOTIFY_WEBHOOK_ENABLE_PRIMARY=on \
|
||||||
-e RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY=http://<host-ip>:3020/webhook \
|
-e RUSTFS_NOTIFY_WEBHOOK_ENDPOINT_PRIMARY=http://<host-ip>:3020/webhook \
|
||||||
-e RUSTFS_NOTIFY_WEBHOOK_QUEUE_DIR_PRIMARY=/tmp/rustfs-events \
|
-e RUSTFS_NOTIFY_WEBHOOK_QUEUE_DIR_PRIMARY=/tmp/rustfs-events \
|
||||||
|
-e RUSTFS_OUTBOUND_ALLOW_ORIGINS=http://<host-ip>:3020 \
|
||||||
rustfs/rustfs:latest
|
rustfs/rustfs:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -171,6 +172,11 @@ Notes:
|
|||||||
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
|
- For ARN `arn:rustfs:sqs::primary:webhook`, use instance-scoped env vars with `_PRIMARY`.
|
||||||
- If queue dir is omitted, default is `/opt/rustfs/events`; ensure it is writable by the container runtime user.
|
- If queue dir is omitted, default is `/opt/rustfs/events`; ensure it is writable by the container runtime user.
|
||||||
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
|
- `RUSTFS_NOTIFY_WEBHOOK_SKIP_TLS_VERIFY_PRIMARY` defaults to `false`; enabling it skips webhook TLS certificate verification, allows MITM attacks, and emits a startup warning. Prefer `RUSTFS_NOTIFY_WEBHOOK_CLIENT_CA_PRIMARY` for private CAs.
|
||||||
|
- Since `1.0.0-beta.11`, webhook endpoints on private or container networks
|
||||||
|
(`Docker Compose service names`, `host.docker.internal`, RFC 1918 addresses) are
|
||||||
|
blocked unless their exact `scheme://host:port` origin is listed in
|
||||||
|
`RUSTFS_OUTBOUND_ALLOW_ORIGINS` (the origin only, without the path). See
|
||||||
|
[Outbound Connection Policy](docs/operations/outbound-connection-policy.md).
|
||||||
|
|
||||||
**NOTE**: We recommend reviewing the `docker-compose.yml` file before running. It defines several services including Grafana, Prometheus, and Jaeger, which are helpful for RustFS observability. If you wish to start Redis or Nginx containers, you can specify the corresponding profiles.
|
**NOTE**: We recommend reviewing the `docker-compose.yml` file before running. It defines several services including Grafana, Prometheus, and Jaeger, which are helpful for RustFS observability. If you wish to start Redis or Nginx containers, you can specify the corresponding profiles.
|
||||||
|
|
||||||
@@ -262,7 +268,7 @@ rustfs --help
|
|||||||
2. **Create a Bucket**: Use the console to create a new bucket for your objects.
|
2. **Create a Bucket**: Use the console to create a new bucket for your objects.
|
||||||
3. **Upload Objects**: You can upload files directly through the console or use S3-compatible APIs/clients to interact with your RustFS instance.
|
3. **Upload Objects**: You can upload files directly through the console or use S3-compatible APIs/clients to interact with your RustFS instance.
|
||||||
|
|
||||||
**NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured.html).
|
**NOTE**: To access the RustFS instance via `https`, please refer to the [TLS Configuration Docs](https://docs.rustfs.com/integration/tls-configured).
|
||||||
|
|
||||||
### OIDC Roles Claim (Microsoft Entra ID)
|
### OIDC Roles Claim (Microsoft Entra ID)
|
||||||
|
|
||||||
@@ -338,12 +344,18 @@ If you have any questions or need assistance:
|
|||||||
RustFS is a community-driven project, and we appreciate all contributions. Check out the [Contributors](https://github.com/rustfs/rustfs/graphs/contributors) page to see the amazing people who have helped make RustFS better.
|
RustFS is a community-driven project, and we appreciate all contributions. Check out the [Contributors](https://github.com/rustfs/rustfs/graphs/contributors) page to see the amazing people who have helped make RustFS better.
|
||||||
|
|
||||||
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
|
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
|
||||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="Contributors" />
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-dark.svg">
|
||||||
|
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-light.svg" alt="RustFS contributors">
|
||||||
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
[](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-dark.svg">
|
||||||
|
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-light.svg" alt="RustFS star history chart">
|
||||||
|
</picture>
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -113,7 +113,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-beta.10
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.12
|
||||||
```
|
```
|
||||||
|
|
||||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||||
@@ -214,7 +214,7 @@ rustfs --help
|
|||||||
2. **创建存储桶**: 使用控制台为您的对象创建一个新的存储桶 (Bucket)。
|
2. **创建存储桶**: 使用控制台为您的对象创建一个新的存储桶 (Bucket)。
|
||||||
3. **上传对象**: 您可以直接通过控制台上传文件,或使用 S3 兼容的 API/客户端与您的 RustFS 实例进行交互。
|
3. **上传对象**: 您可以直接通过控制台上传文件,或使用 S3 兼容的 API/客户端与您的 RustFS 实例进行交互。
|
||||||
|
|
||||||
**注意**: 如果您希望通过 `https` 访问 RustFS 实例,请参考 [TLS 配置文档](https://docs.rustfs.com/integration/tls-configured.html)。
|
**注意**: 如果您希望通过 `https` 访问 RustFS 实例,请参考 [TLS 配置文档](https://docs.rustfs.com/integration/tls-configured)。
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
@@ -247,12 +247,18 @@ rustfs --help
|
|||||||
RustFS 是一个社区驱动的项目,我们感谢所有的贡献。请查看 [贡献者](https://github.com/rustfs/rustfs/graphs/contributors) 页面,看看那些让 RustFS 变得更好的了不起的人们。
|
RustFS 是一个社区驱动的项目,我们感谢所有的贡献。请查看 [贡献者](https://github.com/rustfs/rustfs/graphs/contributors) 页面,看看那些让 RustFS 变得更好的了不起的人们。
|
||||||
|
|
||||||
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
|
<a href="https://github.com/rustfs/rustfs/graphs/contributors">
|
||||||
<img src="https://opencollective.com/rustfs/contributors.svg?width=890&limit=500&button=false" alt="Contributors" />
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-dark.svg">
|
||||||
|
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/contributors-light.svg" alt="RustFS 贡献者">
|
||||||
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Star 历史
|
## Star 历史
|
||||||
|
|
||||||
[](https://www.star-history.com/#rustfs/rustfs&type=date&legend=top-left)
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-dark.svg">
|
||||||
|
<img src="https://raw.githubusercontent.com/rustfs/rustfs/star-history/star-history-light.svg" alt="RustFS Star 历史图表">
|
||||||
|
</picture>
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,33 @@ documentation = "https://docs.rs/rustfs-audit/latest/rustfs_audit/"
|
|||||||
keywords = ["audit", "target", "management", "fan-out", "RustFS"]
|
keywords = ["audit", "target", "management", "fan-out", "RustFS"]
|
||||||
categories = ["web-programming", "development-tools", "asynchronous", "api-bindings"]
|
categories = ["web-programming", "development-tools", "asynchronous", "api-bindings"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hotpath = [
|
||||||
|
"hotpath/hotpath",
|
||||||
|
"hotpath/tokio",
|
||||||
|
"hotpath/futures",
|
||||||
|
"rustfs-config/hotpath",
|
||||||
|
"rustfs-s3-types/hotpath",
|
||||||
|
"rustfs-targets/hotpath",
|
||||||
|
]
|
||||||
|
hotpath-alloc = [
|
||||||
|
"hotpath",
|
||||||
|
"hotpath/hotpath-alloc",
|
||||||
|
"rustfs-config/hotpath-alloc",
|
||||||
|
"rustfs-s3-types/hotpath-alloc",
|
||||||
|
"rustfs-targets/hotpath-alloc",
|
||||||
|
]
|
||||||
|
hotpath-cpu = [
|
||||||
|
"hotpath",
|
||||||
|
"hotpath/hotpath-cpu",
|
||||||
|
"rustfs-config/hotpath-cpu",
|
||||||
|
"rustfs-s3-types/hotpath-cpu",
|
||||||
|
"rustfs-targets/hotpath-cpu",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hotpath.workspace = true
|
||||||
rustfs-targets = { workspace = true }
|
rustfs-targets = { workspace = true }
|
||||||
rustfs-config = { workspace = true, features = ["audit", "server-config-model"] }
|
rustfs-config = { workspace = true, features = ["audit", "server-config-model"] }
|
||||||
rustfs-s3-types = { workspace = true }
|
rustfs-s3-types = { workspace = true }
|
||||||
|
|||||||
@@ -292,8 +292,8 @@ impl AuditPipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn snapshot_target_health(&self) -> Vec<rustfs_targets::RuntimeTargetHealthSnapshot> {
|
pub async fn snapshot_target_health(&self) -> Vec<rustfs_targets::RuntimeTargetHealthSnapshot> {
|
||||||
let registry = self.registry.lock().await;
|
let targets = self.registry.lock().await.list_target_values();
|
||||||
registry.runtime_manager().health_snapshots().await
|
rustfs_targets::health_snapshots_for_targets(targets).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -570,7 +570,7 @@ mod tests {
|
|||||||
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
use rustfs_targets::target::{EntityTarget, QueuedPayload, QueuedPayloadMeta};
|
||||||
use rustfs_targets::{StoreError, Target, TargetError};
|
use rustfs_targets::{StoreError, Target, TargetError};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::{Mutex, Notify};
|
||||||
|
|
||||||
/// Mock target whose `save()` outcome is fixed at construction so tests can
|
/// Mock target whose `save()` outcome is fixed at construction so tests can
|
||||||
/// force full-success / full-failure / partial-failure fan-outs.
|
/// force full-success / full-failure / partial-failure fan-outs.
|
||||||
@@ -578,6 +578,7 @@ mod tests {
|
|||||||
struct MockTarget {
|
struct MockTarget {
|
||||||
id: TargetID,
|
id: TargetID,
|
||||||
fail: bool,
|
fail: bool,
|
||||||
|
health_gate: Option<(Arc<Notify>, Arc<Notify>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockTarget {
|
impl MockTarget {
|
||||||
@@ -585,8 +586,14 @@ mod tests {
|
|||||||
Self {
|
Self {
|
||||||
id: TargetID::new(id.to_string(), "webhook".to_string()),
|
id: TargetID::new(id.to_string(), "webhook".to_string()),
|
||||||
fail,
|
fail,
|
||||||
|
health_gate: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn with_health_gate(mut self, started: Arc<Notify>, release: Arc<Notify>) -> Self {
|
||||||
|
self.health_gate = Some((started, release));
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -599,6 +606,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn is_active(&self) -> Result<bool, TargetError> {
|
async fn is_active(&self) -> Result<bool, TargetError> {
|
||||||
|
if let Some((started, release)) = &self.health_gate {
|
||||||
|
started.notify_one();
|
||||||
|
release.notified().await;
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,6 +684,24 @@ mod tests {
|
|||||||
pipeline.dispatch(entry()).await.expect("no targets should return Ok");
|
pipeline.dispatch(entry()).await.expect("no targets should return Ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn health_probe_does_not_hold_the_registry_lock() {
|
||||||
|
let started = Arc::new(Notify::new());
|
||||||
|
let release = Arc::new(Notify::new());
|
||||||
|
let pipeline = pipeline_with(vec![MockTarget::new("blocked", false).with_health_gate(started.clone(), release.clone())]);
|
||||||
|
let registry = Arc::clone(&pipeline.registry);
|
||||||
|
let snapshot_task = tokio::spawn(async move { pipeline.snapshot_target_health().await });
|
||||||
|
started.notified().await;
|
||||||
|
|
||||||
|
let guard = tokio::time::timeout(std::time::Duration::from_secs(1), registry.lock())
|
||||||
|
.await
|
||||||
|
.expect("network health probe must not retain the audit registry lock");
|
||||||
|
drop(guard);
|
||||||
|
release.notify_one();
|
||||||
|
|
||||||
|
assert_eq!(snapshot_task.await.expect("snapshot task should finish").len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
// backlog#962: dispatch_batch must mirror dispatch and propagate a
|
// backlog#962: dispatch_batch must mirror dispatch and propagate a
|
||||||
// whole-batch loss instead of returning Ok.
|
// whole-batch loss instead of returning Ok.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -25,7 +25,17 @@ keywords = ["checksum-calculation", "verification", "integrity", "authenticity",
|
|||||||
categories = ["web-programming", "development-tools", "network-programming"]
|
categories = ["web-programming", "development-tools", "network-programming"]
|
||||||
documentation = "https://docs.rs/rustfs-checksums/latest/rustfs_checksum/"
|
documentation = "https://docs.rs/rustfs-checksums/latest/rustfs_checksum/"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hotpath = ["hotpath/hotpath"]
|
||||||
|
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||||
|
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hotpath.workspace = true
|
||||||
bytes = { workspace = true, features = ["serde"] }
|
bytes = { workspace = true, features = ["serde"] }
|
||||||
crc-fast = { workspace = true }
|
crc-fast = { workspace = true }
|
||||||
http = { workspace = true }
|
http = { workspace = true }
|
||||||
|
|||||||
@@ -27,7 +27,14 @@ categories = ["web-programming", "development-tools", "data-structures"]
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hotpath = ["hotpath/hotpath", "hotpath/tokio"]
|
||||||
|
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||||
|
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hotpath.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ pub async fn get_global_local_node_name() -> String {
|
|||||||
GLOBAL_LOCAL_NODE_NAME.read().await.clone()
|
GLOBAL_LOCAL_NODE_NAME.read().await.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read the local node name without waiting for initialization or a writer.
|
||||||
|
pub fn try_get_global_local_node_name() -> Option<String> {
|
||||||
|
GLOBAL_LOCAL_NODE_NAME
|
||||||
|
.try_read()
|
||||||
|
.ok()
|
||||||
|
.map(|name| name.clone())
|
||||||
|
.filter(|name| !name.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the global RustFS initialization time to the current UTC time.
|
/// Set the global RustFS initialization time to the current UTC time.
|
||||||
pub async fn set_global_init_time_now() {
|
pub async fn set_global_init_time_now() {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|||||||
@@ -243,6 +243,19 @@ pub enum HealAdmissionResult {
|
|||||||
Dropped(HealAdmissionDropReason),
|
Dropped(HealAdmissionDropReason),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Admission decision together with the canonical task identifier.
|
||||||
|
///
|
||||||
|
/// A merged request must return the identifier of the task that already owns
|
||||||
|
/// the work instead of exposing the discarded request identifier as a new
|
||||||
|
/// client token.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HealAdmissionReceipt {
|
||||||
|
/// Admission decision for the submitted request.
|
||||||
|
pub result: HealAdmissionResult,
|
||||||
|
/// Canonical identifier of the accepted or merged task.
|
||||||
|
pub task_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl HealAdmissionResult {
|
impl HealAdmissionResult {
|
||||||
pub fn result_label(self) -> &'static str {
|
pub fn result_label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
@@ -382,8 +395,25 @@ pub type HealChannelSender = mpsc::UnboundedSender<HealChannelCommand>;
|
|||||||
/// Heal channel receiver
|
/// Heal channel receiver
|
||||||
pub type HealChannelReceiver = mpsc::UnboundedReceiver<HealChannelCommand>;
|
pub type HealChannelReceiver = mpsc::UnboundedReceiver<HealChannelCommand>;
|
||||||
|
|
||||||
|
/// Canonical-receipt start command kept separate from the legacy public enum.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct HealReceiptCommand {
|
||||||
|
/// Heal request to admit.
|
||||||
|
pub request: HealChannelRequest,
|
||||||
|
/// Completion channel for the admission receipt.
|
||||||
|
pub response_tx: oneshot::Sender<Result<HealAdmissionReceipt, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical-receipt command receiver.
|
||||||
|
pub type HealReceiptReceiver = mpsc::UnboundedReceiver<HealReceiptCommand>;
|
||||||
|
|
||||||
|
struct HealChannelSenders {
|
||||||
|
command: HealChannelSender,
|
||||||
|
receipt: mpsc::UnboundedSender<HealReceiptCommand>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Global heal channel sender
|
/// Global heal channel sender
|
||||||
static GLOBAL_HEAL_CHANNEL_SENDER: OnceLock<HealChannelSender> = OnceLock::new();
|
static GLOBAL_HEAL_CHANNEL_SENDERS: OnceLock<HealChannelSenders> = OnceLock::new();
|
||||||
|
|
||||||
type HealResponseSender = broadcast::Sender<HealChannelResponse>;
|
type HealResponseSender = broadcast::Sender<HealChannelResponse>;
|
||||||
|
|
||||||
@@ -392,17 +422,24 @@ static GLOBAL_HEAL_RESPONSE_SENDER: OnceLock<HealResponseSender> = OnceLock::new
|
|||||||
|
|
||||||
/// Initialize global heal channel
|
/// Initialize global heal channel
|
||||||
pub fn init_heal_channel() -> Result<HealChannelReceiver, &'static str> {
|
pub fn init_heal_channel() -> Result<HealChannelReceiver, &'static str> {
|
||||||
let (tx, rx) = mpsc::unbounded_channel();
|
let (receiver, receipt_receiver) = init_heal_channels()?;
|
||||||
if GLOBAL_HEAL_CHANNEL_SENDER.set(tx).is_ok() {
|
drop(receipt_receiver);
|
||||||
Ok(rx)
|
Ok(receiver)
|
||||||
} else {
|
}
|
||||||
Err("Heal channel sender already initialized")
|
|
||||||
}
|
/// Initialize the legacy command and canonical-receipt channels atomically.
|
||||||
|
pub fn init_heal_channels() -> Result<(HealChannelReceiver, HealReceiptReceiver), &'static str> {
|
||||||
|
let (command, command_receiver) = mpsc::unbounded_channel();
|
||||||
|
let (receipt, receipt_receiver) = mpsc::unbounded_channel();
|
||||||
|
GLOBAL_HEAL_CHANNEL_SENDERS
|
||||||
|
.set(HealChannelSenders { command, receipt })
|
||||||
|
.map_err(|_| "Heal channel sender already initialized")?;
|
||||||
|
Ok((command_receiver, receipt_receiver))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get global heal channel sender
|
/// Get global heal channel sender
|
||||||
pub fn get_heal_channel_sender() -> Option<&'static HealChannelSender> {
|
pub fn get_heal_channel_sender() -> Option<&'static HealChannelSender> {
|
||||||
GLOBAL_HEAL_CHANNEL_SENDER.get()
|
GLOBAL_HEAL_CHANNEL_SENDERS.get().map(|senders| &senders.command)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send heal command through global channel
|
/// Send heal command through global channel
|
||||||
@@ -436,6 +473,21 @@ pub fn subscribe_heal_responses() -> broadcast::Receiver<HealChannelResponse> {
|
|||||||
heal_response_sender().subscribe()
|
heal_response_sender().subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Send heal start request and wait for structured admission feedback.
|
||||||
|
pub async fn send_heal_request_with_receipt(request: HealChannelRequest) -> Result<HealAdmissionReceipt, String> {
|
||||||
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
let senders = GLOBAL_HEAL_CHANNEL_SENDERS
|
||||||
|
.get()
|
||||||
|
.ok_or_else(|| "Heal channel not initialized".to_string())?;
|
||||||
|
senders
|
||||||
|
.receipt
|
||||||
|
.send(HealReceiptCommand { request, response_tx })
|
||||||
|
.map_err(|err| format!("Failed to send heal receipt command: {err}"))?;
|
||||||
|
response_rx
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to receive heal admission response: {e}"))?
|
||||||
|
}
|
||||||
|
|
||||||
/// Send heal start request and wait for structured admission feedback.
|
/// Send heal start request and wait for structured admission feedback.
|
||||||
pub async fn send_heal_request_with_admission(request: HealChannelRequest) -> Result<HealAdmissionResult, String> {
|
pub async fn send_heal_request_with_admission(request: HealChannelRequest) -> Result<HealAdmissionResult, String> {
|
||||||
let (response_tx, response_rx) = oneshot::channel();
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
|||||||
+332
-49
@@ -768,6 +768,7 @@ pub struct Metrics {
|
|||||||
last_scan_cycle_replication_checks: AtomicU64,
|
last_scan_cycle_replication_checks: AtomicU64,
|
||||||
last_scan_cycle_usage_saves: AtomicU64,
|
last_scan_cycle_usage_saves: AtomicU64,
|
||||||
failed_scan_cycles: AtomicU64,
|
failed_scan_cycles: AtomicU64,
|
||||||
|
superseded_scan_cycles: AtomicU64,
|
||||||
partial_scan_cycles_unknown: AtomicU64,
|
partial_scan_cycles_unknown: AtomicU64,
|
||||||
partial_scan_cycles_runtime: AtomicU64,
|
partial_scan_cycles_runtime: AtomicU64,
|
||||||
partial_scan_cycles_objects: AtomicU64,
|
partial_scan_cycles_objects: AtomicU64,
|
||||||
@@ -785,6 +786,9 @@ pub struct Metrics {
|
|||||||
scanner_expiry_queue_missed: AtomicU64,
|
scanner_expiry_queue_missed: AtomicU64,
|
||||||
scanner_expiry_queued_total: AtomicU64,
|
scanner_expiry_queued_total: AtomicU64,
|
||||||
scanner_expiry_missed_total: AtomicU64,
|
scanner_expiry_missed_total: AtomicU64,
|
||||||
|
scanner_expiry_blocked_total: AtomicU64,
|
||||||
|
scanner_expiry_not_enqueued_total: AtomicU64,
|
||||||
|
scanner_expiry_delete_failed_total: AtomicU64,
|
||||||
scanner_transition_queue_capacity: AtomicU64,
|
scanner_transition_queue_capacity: AtomicU64,
|
||||||
scanner_transition_queued: AtomicU64,
|
scanner_transition_queued: AtomicU64,
|
||||||
scanner_transition_active: AtomicU64,
|
scanner_transition_active: AtomicU64,
|
||||||
@@ -833,10 +837,12 @@ const SCAN_CYCLE_RESULT_UNKNOWN: u8 = 0;
|
|||||||
const SCAN_CYCLE_RESULT_SUCCESS: u8 = 1;
|
const SCAN_CYCLE_RESULT_SUCCESS: u8 = 1;
|
||||||
const SCAN_CYCLE_RESULT_ERROR: u8 = 2;
|
const SCAN_CYCLE_RESULT_ERROR: u8 = 2;
|
||||||
const SCAN_CYCLE_RESULT_PARTIAL: u8 = 3;
|
const SCAN_CYCLE_RESULT_PARTIAL: u8 = 3;
|
||||||
|
const SCAN_CYCLE_RESULT_SUPERSEDED: u8 = 4;
|
||||||
const SCAN_CYCLE_RESULT_UNKNOWN_LABEL: &str = "unknown";
|
const SCAN_CYCLE_RESULT_UNKNOWN_LABEL: &str = "unknown";
|
||||||
const SCAN_CYCLE_RESULT_SUCCESS_LABEL: &str = "success";
|
const SCAN_CYCLE_RESULT_SUCCESS_LABEL: &str = "success";
|
||||||
const SCAN_CYCLE_RESULT_ERROR_LABEL: &str = "error";
|
const SCAN_CYCLE_RESULT_ERROR_LABEL: &str = "error";
|
||||||
const SCAN_CYCLE_RESULT_PARTIAL_LABEL: &str = "partial";
|
const SCAN_CYCLE_RESULT_PARTIAL_LABEL: &str = "partial";
|
||||||
|
const SCAN_CYCLE_RESULT_SUPERSEDED_LABEL: &str = "superseded";
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub enum ScanCyclePartialReason {
|
pub enum ScanCyclePartialReason {
|
||||||
@@ -1048,6 +1054,12 @@ pub struct ScannerLifecycleExpirySnapshot {
|
|||||||
pub queue_missed: u64,
|
pub queue_missed: u64,
|
||||||
pub scanner_queued: u64,
|
pub scanner_queued: u64,
|
||||||
pub scanner_missed: u64,
|
pub scanner_missed: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scanner_blocked: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub scanner_not_enqueued: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub delete_failed: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
@@ -1102,6 +1114,8 @@ pub struct ScannerLastMinute {
|
|||||||
pub struct ScannerMetricsReport {
|
pub struct ScannerMetricsReport {
|
||||||
pub collected_at: DateTime<Utc>,
|
pub collected_at: DateTime<Utc>,
|
||||||
pub current_cycle: u64,
|
pub current_cycle: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub current_cycle_active: bool,
|
||||||
pub current_started: DateTime<Utc>,
|
pub current_started: DateTime<Utc>,
|
||||||
pub cycles_completed_at: Vec<DateTime<Utc>>,
|
pub cycles_completed_at: Vec<DateTime<Utc>>,
|
||||||
pub ongoing_buckets: usize,
|
pub ongoing_buckets: usize,
|
||||||
@@ -1208,6 +1222,8 @@ pub struct ScannerMetricsReport {
|
|||||||
pub last_cycle_usage_saves: u64,
|
pub last_cycle_usage_saves: u64,
|
||||||
pub failed_cycles: u64,
|
pub failed_cycles: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub superseded_cycles: u64,
|
||||||
|
#[serde(default)]
|
||||||
pub partial_cycles_unknown: u64,
|
pub partial_cycles_unknown: u64,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub partial_cycles_runtime: u64,
|
pub partial_cycles_runtime: u64,
|
||||||
@@ -1310,6 +1326,7 @@ fn scan_cycle_result_label(result: u8) -> &'static str {
|
|||||||
SCAN_CYCLE_RESULT_SUCCESS => SCAN_CYCLE_RESULT_SUCCESS_LABEL,
|
SCAN_CYCLE_RESULT_SUCCESS => SCAN_CYCLE_RESULT_SUCCESS_LABEL,
|
||||||
SCAN_CYCLE_RESULT_ERROR => SCAN_CYCLE_RESULT_ERROR_LABEL,
|
SCAN_CYCLE_RESULT_ERROR => SCAN_CYCLE_RESULT_ERROR_LABEL,
|
||||||
SCAN_CYCLE_RESULT_PARTIAL => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
|
SCAN_CYCLE_RESULT_PARTIAL => SCAN_CYCLE_RESULT_PARTIAL_LABEL,
|
||||||
|
SCAN_CYCLE_RESULT_SUPERSEDED => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL,
|
||||||
_ => SCAN_CYCLE_RESULT_UNKNOWN_LABEL,
|
_ => SCAN_CYCLE_RESULT_UNKNOWN_LABEL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1633,6 +1650,11 @@ pub fn emit_scan_cycle_partial_with_source(
|
|||||||
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_PARTIAL_LABEL).increment(1);
|
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_PARTIAL_LABEL).increment(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn emit_scan_cycle_superseded(duration: Duration) {
|
||||||
|
global_metrics().record_scan_cycle_superseded(duration);
|
||||||
|
metrics::counter!(OTEL_SCANNER_CYCLES, "result" => SCAN_CYCLE_RESULT_SUPERSEDED_LABEL).increment(1);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) {
|
pub fn emit_scan_bucket_drive_complete(success: bool, bucket: &str, disk: &str, duration: Duration) {
|
||||||
let result = if success { "success" } else { "error" };
|
let result = if success { "success" } else { "error" };
|
||||||
metrics::counter!(
|
metrics::counter!(
|
||||||
@@ -1726,6 +1748,7 @@ impl Metrics {
|
|||||||
last_scan_cycle_replication_checks: AtomicU64::new(0),
|
last_scan_cycle_replication_checks: AtomicU64::new(0),
|
||||||
last_scan_cycle_usage_saves: AtomicU64::new(0),
|
last_scan_cycle_usage_saves: AtomicU64::new(0),
|
||||||
failed_scan_cycles: AtomicU64::new(0),
|
failed_scan_cycles: AtomicU64::new(0),
|
||||||
|
superseded_scan_cycles: AtomicU64::new(0),
|
||||||
partial_scan_cycles_unknown: AtomicU64::new(0),
|
partial_scan_cycles_unknown: AtomicU64::new(0),
|
||||||
partial_scan_cycles_runtime: AtomicU64::new(0),
|
partial_scan_cycles_runtime: AtomicU64::new(0),
|
||||||
partial_scan_cycles_objects: AtomicU64::new(0),
|
partial_scan_cycles_objects: AtomicU64::new(0),
|
||||||
@@ -1743,6 +1766,9 @@ impl Metrics {
|
|||||||
scanner_expiry_queue_missed: AtomicU64::new(0),
|
scanner_expiry_queue_missed: AtomicU64::new(0),
|
||||||
scanner_expiry_queued_total: AtomicU64::new(0),
|
scanner_expiry_queued_total: AtomicU64::new(0),
|
||||||
scanner_expiry_missed_total: AtomicU64::new(0),
|
scanner_expiry_missed_total: AtomicU64::new(0),
|
||||||
|
scanner_expiry_blocked_total: AtomicU64::new(0),
|
||||||
|
scanner_expiry_not_enqueued_total: AtomicU64::new(0),
|
||||||
|
scanner_expiry_delete_failed_total: AtomicU64::new(0),
|
||||||
scanner_transition_queue_capacity: AtomicU64::new(0),
|
scanner_transition_queue_capacity: AtomicU64::new(0),
|
||||||
scanner_transition_queued: AtomicU64::new(0),
|
scanner_transition_queued: AtomicU64::new(0),
|
||||||
scanner_transition_active: AtomicU64::new(0),
|
scanner_transition_active: AtomicU64::new(0),
|
||||||
@@ -1973,9 +1999,18 @@ impl Metrics {
|
|||||||
self.scanner_expiry_queued_total.fetch_add(count, Ordering::Relaxed);
|
self.scanner_expiry_queued_total.fetch_add(count, Ordering::Relaxed);
|
||||||
} else {
|
} else {
|
||||||
self.scanner_expiry_missed_total.fetch_add(count, Ordering::Relaxed);
|
self.scanner_expiry_missed_total.fetch_add(count, Ordering::Relaxed);
|
||||||
|
self.scanner_expiry_not_enqueued_total.fetch_add(count, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn record_scanner_expiry_blocked(&self, count: u64) {
|
||||||
|
self.scanner_expiry_blocked_total.fetch_add(count, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_scanner_expiry_delete_failed(&self, count: u64) {
|
||||||
|
self.scanner_expiry_delete_failed_total.fetch_add(count, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_scanner_transition_enqueue_result(&self, count: u64, queued: bool) {
|
pub fn record_scanner_transition_enqueue_result(&self, count: u64, queued: bool) {
|
||||||
self.record_scanner_ilm_enqueue_result(count, queued);
|
self.record_scanner_ilm_enqueue_result(count, queued);
|
||||||
if queued {
|
if queued {
|
||||||
@@ -2018,7 +2053,7 @@ impl Metrics {
|
|||||||
pub fn record_scanner_transition_failed(&self, count: u64) {
|
pub fn record_scanner_transition_failed(&self, count: u64) {
|
||||||
self.scanner_transition_failed.fetch_add(count, Ordering::Relaxed);
|
self.scanner_transition_failed.fetch_add(count, Ordering::Relaxed);
|
||||||
self.record_scanner_source_failed(ScannerWorkSource::Lifecycle, count);
|
self.record_scanner_source_failed(ScannerWorkSource::Lifecycle, count);
|
||||||
if !self.current_scan_cycle_work_active.load(Ordering::Relaxed) {
|
if !self.current_scan_cycle_work_active.load(Ordering::Acquire) {
|
||||||
self.record_last_cycle_scanner_source_work(ScannerWorkSource::Lifecycle, ScannerSourceWorkUpdate::failed(count));
|
self.record_last_cycle_scanner_source_work(ScannerWorkSource::Lifecycle, ScannerSourceWorkUpdate::failed(count));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2303,6 +2338,21 @@ impl Metrics {
|
|||||||
*self.cycle_info.write().await = cycle;
|
*self.cycle_info.write().await = cycle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Publish a scanner cycle and its work-accounting baseline as one state transition.
|
||||||
|
pub async fn start_scan_cycle_work_with_cycle(&self, cycle: CurrentCycle) -> ScanCycleWorkSnapshot {
|
||||||
|
let mut current_cycle = self.cycle_info.write().await;
|
||||||
|
let snapshot = self.start_scan_cycle_work();
|
||||||
|
*current_cycle = Some(cycle);
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish the completed work snapshot and idle cycle state as one state transition.
|
||||||
|
pub async fn finish_scan_cycle_work_with_cycle(&self, start: ScanCycleWorkSnapshot, cycle: CurrentCycle) {
|
||||||
|
let mut current_cycle = self.cycle_info.write().await;
|
||||||
|
self.finish_scan_cycle_work(start);
|
||||||
|
*current_cycle = Some(cycle);
|
||||||
|
}
|
||||||
|
|
||||||
/// Read the current cycle record.
|
/// Read the current cycle record.
|
||||||
pub async fn get_cycle(&self) -> Option<CurrentCycle> {
|
pub async fn get_cycle(&self) -> Option<CurrentCycle> {
|
||||||
self.cycle_info.read().await.clone()
|
self.cycle_info.read().await.clone()
|
||||||
@@ -2349,6 +2399,18 @@ impl Metrics {
|
|||||||
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn record_scan_cycle_superseded(&self, duration: Duration) {
|
||||||
|
self.record_scanner_cycle_end_time();
|
||||||
|
self.superseded_scan_cycles.fetch_add(1, Ordering::Relaxed);
|
||||||
|
self.last_scan_cycle_result
|
||||||
|
.store(SCAN_CYCLE_RESULT_SUPERSEDED, Ordering::Relaxed);
|
||||||
|
self.last_scan_cycle_partial_reason
|
||||||
|
.store(ScanCyclePartialReason::Unknown as u8, Ordering::Relaxed);
|
||||||
|
self.last_scan_cycle_partial_source.store(0, Ordering::Relaxed);
|
||||||
|
self.last_scan_cycle_duration_millis
|
||||||
|
.store(duration_millis_saturated(duration), Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_scan_cycle_partial(&self, duration: Duration, reason: ScanCyclePartialReason) {
|
pub fn record_scan_cycle_partial(&self, duration: Duration, reason: ScanCyclePartialReason) {
|
||||||
self.record_scan_cycle_partial_with_source(duration, reason, None);
|
self.record_scan_cycle_partial_with_source(duration, reason, None);
|
||||||
}
|
}
|
||||||
@@ -2419,7 +2481,7 @@ impl Metrics {
|
|||||||
&self.current_scan_cycle_replication_repair_work_start,
|
&self.current_scan_cycle_replication_repair_work_start,
|
||||||
&replication_repair_snapshot,
|
&replication_repair_snapshot,
|
||||||
);
|
);
|
||||||
self.current_scan_cycle_work_active.store(true, Ordering::Relaxed);
|
self.current_scan_cycle_work_active.store(true, Ordering::Release);
|
||||||
snapshot
|
snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2431,7 +2493,19 @@ impl Metrics {
|
|||||||
self.record_scan_cycle_work(work);
|
self.record_scan_cycle_work(work);
|
||||||
self.record_scan_cycle_source_work(&source_work);
|
self.record_scan_cycle_source_work(&source_work);
|
||||||
self.record_scan_cycle_replication_repair_work(&replication_repair_work);
|
self.record_scan_cycle_replication_repair_work(&replication_repair_work);
|
||||||
self.current_scan_cycle_work_active.store(false, Ordering::Relaxed);
|
self.current_scan_cycle_work_active.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_scan_cycle_has_unresolved_heal_work(&self) -> bool {
|
||||||
|
if !self.current_scan_cycle_work_active.load(Ordering::Acquire) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
|
||||||
|
[ScannerWorkSource::Heal, ScannerWorkSource::Bitrot]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|source| source_work.get(source.index()))
|
||||||
|
.any(|work| work.queued > 0 || work.skipped > 0 || work.failed > 0 || work.missed > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scan_cycle_work_snapshot(&self) -> ScanCycleWorkSnapshot {
|
fn scan_cycle_work_snapshot(&self) -> ScanCycleWorkSnapshot {
|
||||||
@@ -2689,13 +2763,41 @@ impl Metrics {
|
|||||||
pub async fn report(&self) -> ScannerMetricsReport {
|
pub async fn report(&self) -> ScannerMetricsReport {
|
||||||
let mut m = ScannerMetricsReport::default();
|
let mut m = ScannerMetricsReport::default();
|
||||||
|
|
||||||
let has_cycle = if let Some(cycle) = self.get_cycle().await {
|
let has_cycle = {
|
||||||
m.current_cycle = cycle.current;
|
let cycle = self.cycle_info.read().await;
|
||||||
m.cycles_completed_at = cycle.cycle_completed;
|
let has_cycle = if let Some(cycle) = cycle.as_ref() {
|
||||||
m.current_started = cycle.started;
|
m.current_cycle = cycle.current;
|
||||||
true
|
m.cycles_completed_at = cycle.cycle_completed.clone();
|
||||||
} else {
|
m.current_started = cycle.started;
|
||||||
false
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
m.current_cycle_active = self.current_scan_cycle_work_active.load(Ordering::Acquire);
|
||||||
|
if m.current_cycle_active {
|
||||||
|
let current_work = self.scan_cycle_work_since(self.current_scan_cycle_work_start());
|
||||||
|
let current_source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
|
||||||
|
let current_replication_repair_work =
|
||||||
|
self.scanner_replication_repair_work_since(&self.current_scan_cycle_replication_repair_work_start_values());
|
||||||
|
m.current_cycle_objects_scanned = current_work.objects_scanned;
|
||||||
|
m.current_cycle_directories_scanned = current_work.directories_scanned;
|
||||||
|
m.current_cycle_bucket_drive_scans = current_work.bucket_drive_scans;
|
||||||
|
m.current_cycle_bucket_drive_failures = current_work.bucket_drive_failures;
|
||||||
|
m.current_cycle_yield_events = current_work.yield_events;
|
||||||
|
m.current_cycle_yield_duration_seconds = current_work.yield_duration_millis as f64 / 1000.0;
|
||||||
|
m.current_cycle_throttle_sleep_events = current_work.throttle_sleep_events;
|
||||||
|
m.current_cycle_throttle_sleep_duration_seconds = current_work.throttle_sleep_duration_millis as f64 / 1000.0;
|
||||||
|
m.current_cycle_ilm_actions = current_work.ilm_actions;
|
||||||
|
m.current_cycle_lifecycle_expiry_actions = current_work.lifecycle_expiry_actions;
|
||||||
|
m.current_cycle_lifecycle_transition_actions = current_work.lifecycle_transition_actions;
|
||||||
|
m.current_cycle_heal_objects = current_work.heal_objects;
|
||||||
|
m.current_cycle_replication_checks = current_work.replication_checks;
|
||||||
|
m.current_cycle_usage_saves = current_work.usage_saves;
|
||||||
|
m.current_cycle_source_work = self.scanner_source_work_snapshots(¤t_source_work);
|
||||||
|
m.current_cycle_replication_repair =
|
||||||
|
self.scanner_replication_repair_work_snapshots(¤t_replication_repair_work);
|
||||||
|
}
|
||||||
|
has_cycle
|
||||||
};
|
};
|
||||||
|
|
||||||
if !has_cycle && let Some(init_time) = crate::get_global_init_time().await {
|
if !has_cycle && let Some(init_time) = crate::get_global_init_time().await {
|
||||||
@@ -2736,28 +2838,6 @@ impl Metrics {
|
|||||||
m.current_disk_scan_concurrency_limit = disk_scan_concurrency_limit;
|
m.current_disk_scan_concurrency_limit = disk_scan_concurrency_limit;
|
||||||
m.current_disk_bucket_scans_queued = disk_bucket_scans_queued;
|
m.current_disk_bucket_scans_queued = disk_bucket_scans_queued;
|
||||||
m.current_disk_bucket_scans_active = disk_bucket_scans_active;
|
m.current_disk_bucket_scans_active = disk_bucket_scans_active;
|
||||||
if self.current_scan_cycle_work_active.load(Ordering::Relaxed) {
|
|
||||||
let current_work = self.scan_cycle_work_since(self.current_scan_cycle_work_start());
|
|
||||||
let current_source_work = self.scanner_source_work_since(&self.current_scan_cycle_source_work_start_values());
|
|
||||||
let current_replication_repair_work =
|
|
||||||
self.scanner_replication_repair_work_since(&self.current_scan_cycle_replication_repair_work_start_values());
|
|
||||||
m.current_cycle_objects_scanned = current_work.objects_scanned;
|
|
||||||
m.current_cycle_directories_scanned = current_work.directories_scanned;
|
|
||||||
m.current_cycle_bucket_drive_scans = current_work.bucket_drive_scans;
|
|
||||||
m.current_cycle_bucket_drive_failures = current_work.bucket_drive_failures;
|
|
||||||
m.current_cycle_yield_events = current_work.yield_events;
|
|
||||||
m.current_cycle_yield_duration_seconds = current_work.yield_duration_millis as f64 / 1000.0;
|
|
||||||
m.current_cycle_throttle_sleep_events = current_work.throttle_sleep_events;
|
|
||||||
m.current_cycle_throttle_sleep_duration_seconds = current_work.throttle_sleep_duration_millis as f64 / 1000.0;
|
|
||||||
m.current_cycle_ilm_actions = current_work.ilm_actions;
|
|
||||||
m.current_cycle_lifecycle_expiry_actions = current_work.lifecycle_expiry_actions;
|
|
||||||
m.current_cycle_lifecycle_transition_actions = current_work.lifecycle_transition_actions;
|
|
||||||
m.current_cycle_heal_objects = current_work.heal_objects;
|
|
||||||
m.current_cycle_replication_checks = current_work.replication_checks;
|
|
||||||
m.current_cycle_usage_saves = current_work.usage_saves;
|
|
||||||
m.current_cycle_source_work = self.scanner_source_work_snapshots(¤t_source_work);
|
|
||||||
m.current_cycle_replication_repair = self.scanner_replication_repair_work_snapshots(¤t_replication_repair_work);
|
|
||||||
}
|
|
||||||
let last_cycle_result = self.last_scan_cycle_result.load(Ordering::Relaxed);
|
let last_cycle_result = self.last_scan_cycle_result.load(Ordering::Relaxed);
|
||||||
m.last_cycle_result = scan_cycle_result_label(last_cycle_result).to_string();
|
m.last_cycle_result = scan_cycle_result_label(last_cycle_result).to_string();
|
||||||
m.last_cycle_result_code = last_cycle_result as u64;
|
m.last_cycle_result_code = last_cycle_result as u64;
|
||||||
@@ -2790,6 +2870,7 @@ impl Metrics {
|
|||||||
m.last_cycle_replication_repair =
|
m.last_cycle_replication_repair =
|
||||||
self.scanner_replication_repair_work_counter_snapshots(&self.last_scan_cycle_replication_repair_work);
|
self.scanner_replication_repair_work_counter_snapshots(&self.last_scan_cycle_replication_repair_work);
|
||||||
m.failed_cycles = self.failed_scan_cycles.load(Ordering::Relaxed);
|
m.failed_cycles = self.failed_scan_cycles.load(Ordering::Relaxed);
|
||||||
|
m.superseded_cycles = self.superseded_scan_cycles.load(Ordering::Relaxed);
|
||||||
m.partial_cycles_unknown = self.partial_scan_cycles_unknown.load(Ordering::Relaxed);
|
m.partial_cycles_unknown = self.partial_scan_cycles_unknown.load(Ordering::Relaxed);
|
||||||
m.partial_cycles_runtime = self.partial_scan_cycles_runtime.load(Ordering::Relaxed);
|
m.partial_cycles_runtime = self.partial_scan_cycles_runtime.load(Ordering::Relaxed);
|
||||||
m.partial_cycles_objects = self.partial_scan_cycles_objects.load(Ordering::Relaxed);
|
m.partial_cycles_objects = self.partial_scan_cycles_objects.load(Ordering::Relaxed);
|
||||||
@@ -2813,6 +2894,9 @@ impl Metrics {
|
|||||||
queue_missed: self.scanner_expiry_queue_missed.load(Ordering::Relaxed),
|
queue_missed: self.scanner_expiry_queue_missed.load(Ordering::Relaxed),
|
||||||
scanner_queued: self.scanner_expiry_queued_total.load(Ordering::Relaxed),
|
scanner_queued: self.scanner_expiry_queued_total.load(Ordering::Relaxed),
|
||||||
scanner_missed: self.scanner_expiry_missed_total.load(Ordering::Relaxed),
|
scanner_missed: self.scanner_expiry_missed_total.load(Ordering::Relaxed),
|
||||||
|
scanner_blocked: self.scanner_expiry_blocked_total.load(Ordering::Relaxed),
|
||||||
|
scanner_not_enqueued: self.scanner_expiry_not_enqueued_total.load(Ordering::Relaxed),
|
||||||
|
delete_failed: self.scanner_expiry_delete_failed_total.load(Ordering::Relaxed),
|
||||||
};
|
};
|
||||||
m.lifecycle_transition = ScannerLifecycleTransitionSnapshot {
|
m.lifecycle_transition = ScannerLifecycleTransitionSnapshot {
|
||||||
current_queue_capacity: self.scanner_transition_queue_capacity.load(Ordering::Relaxed),
|
current_queue_capacity: self.scanner_transition_queue_capacity.load(Ordering::Relaxed),
|
||||||
@@ -2938,19 +3022,15 @@ pub type CloseDiskFn = Arc<dyn Fn() -> Pin<Box<dyn Future<Output = ()> + Send>>
|
|||||||
|
|
||||||
/// Register a new disk in the global path tracker and return two callbacks:
|
/// Register a new disk in the global path tracker and return two callbacks:
|
||||||
/// one to update the current path and one to deregister the disk when done.
|
/// one to update the current path and one to deregister the disk when done.
|
||||||
pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn, CloseDiskFn) {
|
pub async fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn, CloseDiskFn) {
|
||||||
let tracker = Arc::new(CurrentPathTracker::new(initial.to_string()));
|
let tracker = Arc::new(CurrentPathTracker::new(initial.to_string()));
|
||||||
let disk_name = disk.to_string();
|
let disk_name = disk.to_string();
|
||||||
|
|
||||||
let tracker_clone = Arc::clone(&tracker);
|
global_metrics()
|
||||||
let disk_insert = disk_name.clone();
|
.current_paths
|
||||||
tokio::spawn(async move {
|
.write()
|
||||||
global_metrics()
|
.await
|
||||||
.current_paths
|
.insert(disk_name.clone(), Arc::clone(&tracker));
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(disk_insert, tracker_clone);
|
|
||||||
});
|
|
||||||
|
|
||||||
let update_fn: UpdateCurrentPathFn = {
|
let update_fn: UpdateCurrentPathFn = {
|
||||||
let tracker = Arc::clone(&tracker);
|
let tracker = Arc::clone(&tracker);
|
||||||
@@ -2978,23 +3058,28 @@ pub fn current_path_updater(disk: &str, initial: &str) -> (UpdateCurrentPathFn,
|
|||||||
// CloseDiskGuard
|
// CloseDiskGuard
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub struct CloseDiskGuard(CloseDiskFn);
|
pub struct CloseDiskGuard(Option<CloseDiskFn>);
|
||||||
|
|
||||||
impl CloseDiskGuard {
|
impl CloseDiskGuard {
|
||||||
pub fn new(close_disk: CloseDiskFn) -> Self {
|
pub fn new(close_disk: CloseDiskFn) -> Self {
|
||||||
Self(close_disk)
|
Self(Some(close_disk))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn close(&self) {
|
pub async fn close(&mut self) {
|
||||||
self.0().await;
|
let Some(close_disk) = self.0.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
close_disk().await;
|
||||||
|
self.0 = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for CloseDiskGuard {
|
impl Drop for CloseDiskGuard {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
if let Some(close_disk) = self.0.take()
|
||||||
let close_fn = self.0.clone();
|
&& let Ok(handle) = tokio::runtime::Handle::try_current()
|
||||||
handle.spawn(async move { close_fn().await });
|
{
|
||||||
|
handle.spawn(close_disk());
|
||||||
}
|
}
|
||||||
// If there is no runtime we are in a test or shutdown path; skip cleanup.
|
// If there is no runtime we are in a test or shutdown path; skip cleanup.
|
||||||
}
|
}
|
||||||
@@ -3004,6 +3089,61 @@ impl Drop for CloseDiskGuard {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn close_disk_guard_runs_cleanup_when_an_early_return_drops_it() {
|
||||||
|
let (closed_tx, closed_rx) = tokio::sync::oneshot::channel();
|
||||||
|
let closed_tx = Arc::new(std::sync::Mutex::new(Some(closed_tx)));
|
||||||
|
let close_disk: CloseDiskFn = {
|
||||||
|
let closed_tx = Arc::clone(&closed_tx);
|
||||||
|
Arc::new(move || {
|
||||||
|
let closed_tx = closed_tx.lock().expect("close callback lock").take();
|
||||||
|
Box::pin(async move {
|
||||||
|
if let Some(closed_tx) = closed_tx {
|
||||||
|
let _ = closed_tx.send(());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let guard = CloseDiskGuard::new(close_disk);
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(1), closed_rx)
|
||||||
|
.await
|
||||||
|
.expect("drop cleanup should run")
|
||||||
|
.expect("drop cleanup should signal");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn close_disk_guard_runs_explicit_cleanup_once() {
|
||||||
|
let close_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let close_disk: CloseDiskFn = {
|
||||||
|
let close_count = Arc::clone(&close_count);
|
||||||
|
Arc::new(move || {
|
||||||
|
close_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
Box::pin(std::future::ready(()))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut guard = CloseDiskGuard::new(close_disk);
|
||||||
|
guard.close().await;
|
||||||
|
drop(guard);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||||
|
|
||||||
|
assert_eq!(close_count.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn current_path_updater_registers_before_return() {
|
||||||
|
let disk = format!("test-disk-{}", uuid::Uuid::new_v4());
|
||||||
|
let (_update_path, close_disk) = current_path_updater(&disk, "bucket-a").await;
|
||||||
|
|
||||||
|
assert!(global_metrics().current_paths.read().await.contains_key(&disk));
|
||||||
|
|
||||||
|
close_disk().await;
|
||||||
|
assert!(!global_metrics().current_paths.read().await.contains_key(&disk));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn report_counts_active_scan_paths() {
|
async fn report_counts_active_scan_paths() {
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
@@ -3292,6 +3432,8 @@ mod tests {
|
|||||||
});
|
});
|
||||||
metrics.record_scanner_expiry_enqueue_result(6, true);
|
metrics.record_scanner_expiry_enqueue_result(6, true);
|
||||||
metrics.record_scanner_expiry_enqueue_result(2, false);
|
metrics.record_scanner_expiry_enqueue_result(2, false);
|
||||||
|
metrics.record_scanner_expiry_blocked(4);
|
||||||
|
metrics.record_scanner_expiry_delete_failed(1);
|
||||||
|
|
||||||
let report = metrics.report().await;
|
let report = metrics.report().await;
|
||||||
|
|
||||||
@@ -3302,6 +3444,9 @@ mod tests {
|
|||||||
assert_eq!(report.lifecycle_expiry.queue_missed, 3);
|
assert_eq!(report.lifecycle_expiry.queue_missed, 3);
|
||||||
assert_eq!(report.lifecycle_expiry.scanner_queued, 6);
|
assert_eq!(report.lifecycle_expiry.scanner_queued, 6);
|
||||||
assert_eq!(report.lifecycle_expiry.scanner_missed, 2);
|
assert_eq!(report.lifecycle_expiry.scanner_missed, 2);
|
||||||
|
assert_eq!(report.lifecycle_expiry.scanner_blocked, 4);
|
||||||
|
assert_eq!(report.lifecycle_expiry.scanner_not_enqueued, 2);
|
||||||
|
assert_eq!(report.lifecycle_expiry.delete_failed, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -3361,6 +3506,40 @@ mod tests {
|
|||||||
metrics.finish_scan_cycle_work(start);
|
metrics.finish_scan_cycle_work(start);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unresolved_heal_work_only_reflects_the_active_cycle() {
|
||||||
|
let metrics = Metrics::new();
|
||||||
|
assert!(!metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||||
|
|
||||||
|
let start = metrics.start_scan_cycle_work();
|
||||||
|
metrics.record_scanner_source_missed(ScannerWorkSource::Heal, 1);
|
||||||
|
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||||
|
|
||||||
|
metrics.finish_scan_cycle_work(start);
|
||||||
|
assert!(!metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||||
|
|
||||||
|
let start = metrics.start_scan_cycle_work();
|
||||||
|
metrics.record_scanner_source_queued(ScannerWorkSource::Heal, 1);
|
||||||
|
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||||
|
metrics.finish_scan_cycle_work(start);
|
||||||
|
|
||||||
|
let start = metrics.start_scan_cycle_work();
|
||||||
|
metrics.record_scanner_source_work(
|
||||||
|
ScannerWorkSource::Bitrot,
|
||||||
|
ScannerSourceWorkUpdate {
|
||||||
|
skipped: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||||
|
metrics.finish_scan_cycle_work(start);
|
||||||
|
|
||||||
|
let start = metrics.start_scan_cycle_work();
|
||||||
|
metrics.record_scanner_source_failed(ScannerWorkSource::Bitrot, 1);
|
||||||
|
assert!(metrics.current_scan_cycle_has_unresolved_heal_work());
|
||||||
|
metrics.finish_scan_cycle_work(start);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn report_marks_transition_failures_as_blocked_lifecycle_control() {
|
async fn report_marks_transition_failures_as_blocked_lifecycle_control() {
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
@@ -3804,6 +3983,21 @@ mod tests {
|
|||||||
assert_eq!(report.failed_cycles, 1);
|
assert_eq!(report.failed_cycles, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn report_tracks_superseded_cycle_without_failed_increment() {
|
||||||
|
let metrics = Metrics::new();
|
||||||
|
metrics.record_scan_cycle_superseded(Duration::from_millis(750));
|
||||||
|
|
||||||
|
let report = metrics.report().await;
|
||||||
|
|
||||||
|
assert_eq!(report.last_cycle_result, SCAN_CYCLE_RESULT_SUPERSEDED_LABEL);
|
||||||
|
assert_eq!(report.last_cycle_result_code, u64::from(SCAN_CYCLE_RESULT_SUPERSEDED));
|
||||||
|
assert_eq!(report.last_cycle_duration_seconds, 0.75);
|
||||||
|
assert_eq!(report.failed_cycles, 0);
|
||||||
|
assert_eq!(report.superseded_cycles, 1);
|
||||||
|
assert_eq!(report.partial_cycles, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn report_tracks_successful_scan_cycle_without_failed_increment() {
|
async fn report_tracks_successful_scan_cycle_without_failed_increment() {
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
@@ -3971,6 +4165,8 @@ mod tests {
|
|||||||
|
|
||||||
let report = metrics.report().await;
|
let report = metrics.report().await;
|
||||||
|
|
||||||
|
assert!(report.current_cycle_active);
|
||||||
|
assert_eq!(report.current_cycle, 0);
|
||||||
assert_eq!(report.current_cycle_objects_scanned, 7);
|
assert_eq!(report.current_cycle_objects_scanned, 7);
|
||||||
assert_eq!(report.current_cycle_directories_scanned, 3);
|
assert_eq!(report.current_cycle_directories_scanned, 3);
|
||||||
assert_eq!(report.current_cycle_bucket_drive_scans, 2);
|
assert_eq!(report.current_cycle_bucket_drive_scans, 2);
|
||||||
@@ -3987,6 +4183,8 @@ mod tests {
|
|||||||
metrics.finish_scan_cycle_work(start);
|
metrics.finish_scan_cycle_work(start);
|
||||||
let report = metrics.report().await;
|
let report = metrics.report().await;
|
||||||
|
|
||||||
|
assert!(!report.current_cycle_active);
|
||||||
|
assert_eq!(report.current_cycle, 0);
|
||||||
assert_eq!(report.current_cycle_objects_scanned, 0);
|
assert_eq!(report.current_cycle_objects_scanned, 0);
|
||||||
assert_eq!(report.current_cycle_directories_scanned, 0);
|
assert_eq!(report.current_cycle_directories_scanned, 0);
|
||||||
assert_eq!(report.current_cycle_bucket_drive_scans, 0);
|
assert_eq!(report.current_cycle_bucket_drive_scans, 0);
|
||||||
@@ -4013,6 +4211,91 @@ mod tests {
|
|||||||
assert_eq!(report.last_cycle_usage_saves, 2);
|
assert_eq!(report.last_cycle_usage_saves, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scan_cycle_activity_and_cycle_state_publish_together() {
|
||||||
|
let metrics = Metrics::new();
|
||||||
|
let cycle_started = Utc::now() - chrono::Duration::seconds(5);
|
||||||
|
let active_cycle = CurrentCycle {
|
||||||
|
current: 12,
|
||||||
|
next: 13,
|
||||||
|
started: cycle_started,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let cycle_state = metrics.cycle_info.read().await;
|
||||||
|
let mut start_transition = Box::pin(metrics.start_scan_cycle_work_with_cycle(active_cycle));
|
||||||
|
let waker = std::task::Waker::noop();
|
||||||
|
let mut context = std::task::Context::from_waker(waker);
|
||||||
|
assert!(start_transition.as_mut().poll(&mut context).is_pending());
|
||||||
|
assert!(!metrics.current_scan_cycle_work_active.load(Ordering::Acquire));
|
||||||
|
drop(cycle_state);
|
||||||
|
|
||||||
|
let start = start_transition.await;
|
||||||
|
let active = metrics.report().await;
|
||||||
|
assert!(active.current_cycle_active);
|
||||||
|
assert_eq!(active.current_cycle, 12);
|
||||||
|
assert_eq!(active.current_started, cycle_started);
|
||||||
|
|
||||||
|
let idle_cycle = CurrentCycle {
|
||||||
|
current: 0,
|
||||||
|
next: 13,
|
||||||
|
started: cycle_started,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cycle_state = metrics.cycle_info.read().await;
|
||||||
|
let mut finish_transition = Box::pin(metrics.finish_scan_cycle_work_with_cycle(start, idle_cycle));
|
||||||
|
assert!(finish_transition.as_mut().poll(&mut context).is_pending());
|
||||||
|
assert!(metrics.current_scan_cycle_work_active.load(Ordering::Acquire));
|
||||||
|
drop(cycle_state);
|
||||||
|
|
||||||
|
finish_transition.await;
|
||||||
|
let idle = metrics.report().await;
|
||||||
|
assert!(!idle.current_cycle_active);
|
||||||
|
assert_eq!(idle.current_cycle, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn report_keeps_cycle_identity_and_work_in_one_snapshot() {
|
||||||
|
let metrics = Metrics::new();
|
||||||
|
let cycle_ten = CurrentCycle {
|
||||||
|
current: 10,
|
||||||
|
next: 11,
|
||||||
|
started: Utc::now() - chrono::Duration::seconds(10),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let cycle_ten_start = metrics.start_scan_cycle_work_with_cycle(cycle_ten.clone()).await;
|
||||||
|
metrics.operations[Metric::ScanObject as usize].store(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
let paths = metrics.current_paths.write().await;
|
||||||
|
let mut report = Box::pin(metrics.report());
|
||||||
|
let waker = std::task::Waker::noop();
|
||||||
|
let mut context = std::task::Context::from_waker(waker);
|
||||||
|
assert!(report.as_mut().poll(&mut context).is_pending());
|
||||||
|
|
||||||
|
metrics
|
||||||
|
.finish_scan_cycle_work_with_cycle(cycle_ten_start, CurrentCycle { current: 0, ..cycle_ten })
|
||||||
|
.await;
|
||||||
|
let cycle_eleven_start = metrics
|
||||||
|
.start_scan_cycle_work_with_cycle(CurrentCycle {
|
||||||
|
current: 11,
|
||||||
|
next: 12,
|
||||||
|
started: Utc::now(),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
metrics.operations[Metric::ScanObject as usize].store(101, Ordering::Relaxed);
|
||||||
|
|
||||||
|
drop(paths);
|
||||||
|
let snapshot = report.await;
|
||||||
|
|
||||||
|
assert_eq!(snapshot.current_cycle, 10);
|
||||||
|
assert_eq!(snapshot.current_cycle_objects_scanned, 1);
|
||||||
|
|
||||||
|
metrics
|
||||||
|
.finish_scan_cycle_work_with_cycle(cycle_eleven_start, CurrentCycle::default())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn scanner_cycle_ilm_actions_ignore_global_ilm_work() {
|
async fn scanner_cycle_ilm_actions_ignore_global_ilm_work() {
|
||||||
let metrics = Metrics::new();
|
let metrics = Metrics::new();
|
||||||
|
|||||||
@@ -10,7 +10,17 @@ description = "Shared concurrency contract types for RustFS - workload admission
|
|||||||
keywords = ["rustfs", "concurrency", "admission", "backpressure", "workers"]
|
keywords = ["rustfs", "concurrency", "admission", "backpressure", "workers"]
|
||||||
categories = ["concurrency", "filesystem"]
|
categories = ["concurrency", "filesystem"]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hotpath = ["hotpath/hotpath", "hotpath/tokio", "rustfs-io-core/hotpath"]
|
||||||
|
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "rustfs-io-core/hotpath-alloc"]
|
||||||
|
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-io-core/hotpath-cpu"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hotpath.workspace = true
|
||||||
# Internal crates
|
# Internal crates
|
||||||
rustfs-io-core = { workspace = true }
|
rustfs-io-core = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ keywords = ["configuration", "settings", "management", "rustfs", "Minio"]
|
|||||||
categories = ["web-programming", "development-tools", "config"]
|
categories = ["web-programming", "development-tools", "config"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hotpath.workspace = true
|
||||||
const-str = { workspace = true, optional = true, features = ["std", "proc"] }
|
const-str = { workspace = true, optional = true, features = ["std", "proc"] }
|
||||||
serde = { workspace = true, optional = true, features = ["derive"] }
|
serde = { workspace = true, optional = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true, optional = true, features = ["raw_value"] }
|
serde_json = { workspace = true, optional = true, features = ["raw_value"] }
|
||||||
@@ -34,6 +35,9 @@ workspace = true
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["constants"]
|
default = ["constants"]
|
||||||
|
hotpath = ["hotpath/hotpath"]
|
||||||
|
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||||
|
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||||
audit = ["dep:const-str", "constants"]
|
audit = ["dep:const-str", "constants"]
|
||||||
constants = ["dep:const-str"]
|
constants = ["dep:const-str"]
|
||||||
notify = ["dep:const-str", "constants"]
|
notify = ["dep:const-str", "constants"]
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ Current guidance:
|
|||||||
|
|
||||||
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
|
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
|
||||||
|
|
||||||
|
## Distributed endpoint locality
|
||||||
|
|
||||||
|
- `RUSTFS_LOCAL_ENDPOINT_HOST` identifies this server's host in a distributed `RUSTFS_VOLUMES` topology without resolving every peer during startup. Set it to exactly one host, without a scheme, port, or path. It is accepted only for orchestrated URL topologies and must match at least one endpoint on the RustFS server port; invalid or unmatched values fail startup. Leave it unset to retain DNS-based locality discovery.
|
||||||
|
|
||||||
## Scanner environment aliases
|
## Scanner environment aliases
|
||||||
|
|
||||||
- `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`)
|
- `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`)
|
||||||
|
|||||||
@@ -50,3 +50,43 @@ pub const ENV_API_RATE_LIMIT_BURST: &str = "RUSTFS_API_RATE_LIMIT_BURST";
|
|||||||
|
|
||||||
/// Default for `RUSTFS_API_RATE_LIMIT_BURST` (`0` = same as RPM).
|
/// Default for `RUSTFS_API_RATE_LIMIT_BURST` (`0` = same as RPM).
|
||||||
pub const DEFAULT_API_RATE_LIMIT_BURST: u32 = 0;
|
pub const DEFAULT_API_RATE_LIMIT_BURST: u32 = 0;
|
||||||
|
|
||||||
|
/// Sustained S3 API request budget per addressed bucket, in requests per
|
||||||
|
/// minute — a collective ceiling shared by all clients of that bucket.
|
||||||
|
///
|
||||||
|
/// Complements the per-client-IP dimension: it protects the server from one
|
||||||
|
/// hot bucket regardless of how many client IPs the traffic comes from. `0`
|
||||||
|
/// disables the bucket dimension. Requires `RUSTFS_API_RATE_LIMIT_ENABLE`.
|
||||||
|
/// Environment variable: RUSTFS_API_RATE_LIMIT_BUCKET_RPM
|
||||||
|
/// Example: RUSTFS_API_RATE_LIMIT_BUCKET_RPM=60000
|
||||||
|
pub const ENV_API_RATE_LIMIT_BUCKET_RPM: &str = "RUSTFS_API_RATE_LIMIT_BUCKET_RPM";
|
||||||
|
|
||||||
|
/// Default for `RUSTFS_API_RATE_LIMIT_BUCKET_RPM` (`0` = dimension disabled).
|
||||||
|
pub const DEFAULT_API_RATE_LIMIT_BUCKET_RPM: u32 = 0;
|
||||||
|
|
||||||
|
/// Burst capacity per bucket (maximum tokens in the bucket-dimension bucket).
|
||||||
|
///
|
||||||
|
/// `0` means "same as `RUSTFS_API_RATE_LIMIT_BUCKET_RPM`".
|
||||||
|
/// Environment variable: RUSTFS_API_RATE_LIMIT_BUCKET_BURST
|
||||||
|
/// Example: RUSTFS_API_RATE_LIMIT_BUCKET_BURST=2000
|
||||||
|
pub const ENV_API_RATE_LIMIT_BUCKET_BURST: &str = "RUSTFS_API_RATE_LIMIT_BUCKET_BURST";
|
||||||
|
|
||||||
|
/// Default for `RUSTFS_API_RATE_LIMIT_BUCKET_BURST` (`0` = same as bucket RPM).
|
||||||
|
pub const DEFAULT_API_RATE_LIMIT_BUCKET_BURST: u32 = 0;
|
||||||
|
|
||||||
|
/// Maximum concurrently served connections on the main API listener.
|
||||||
|
///
|
||||||
|
/// `0` (the default) means unlimited. When set, the accept loop stops
|
||||||
|
/// accepting once the cap is reached and lets the kernel backlog absorb
|
||||||
|
/// bursts, releasing capacity as connections close. This bounds file
|
||||||
|
/// descriptor and memory usage under a connection flood.
|
||||||
|
///
|
||||||
|
/// The cap covers everything on the main listener — S3, admin, console,
|
||||||
|
/// and internode gRPC — so size it well above peer-node count plus the
|
||||||
|
/// expected client concurrency.
|
||||||
|
/// Environment variable: RUSTFS_API_MAX_CONNECTIONS
|
||||||
|
/// Example: RUSTFS_API_MAX_CONNECTIONS=10000
|
||||||
|
pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
|
||||||
|
|
||||||
|
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
|
||||||
|
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ pub const ENV_RUSTFS_ADDRESS: &str = "RUSTFS_ADDRESS";
|
|||||||
/// Environment variable for server volumes.
|
/// Environment variable for server volumes.
|
||||||
pub const ENV_RUSTFS_VOLUMES: &str = "RUSTFS_VOLUMES";
|
pub const ENV_RUSTFS_VOLUMES: &str = "RUSTFS_VOLUMES";
|
||||||
|
|
||||||
|
/// Environment variable identifying this server's host in distributed endpoint
|
||||||
|
/// lists without relying on DNS locality discovery.
|
||||||
|
pub const ENV_LOCAL_ENDPOINT_HOST: &str = "RUSTFS_LOCAL_ENDPOINT_HOST";
|
||||||
|
|
||||||
/// Environment variable to explicitly bypass local physical disk independence checks.
|
/// Environment variable to explicitly bypass local physical disk independence checks.
|
||||||
pub const ENV_UNSAFE_BYPASS_DISK_CHECK: &str = "RUSTFS_UNSAFE_BYPASS_DISK_CHECK";
|
pub const ENV_UNSAFE_BYPASS_DISK_CHECK: &str = "RUSTFS_UNSAFE_BYPASS_DISK_CHECK";
|
||||||
|
|
||||||
@@ -226,6 +230,19 @@ pub const ENV_RUSTFS_KMS_ENABLE: &str = "RUSTFS_KMS_ENABLE";
|
|||||||
/// Default value: false
|
/// Default value: false
|
||||||
pub const DEFAULT_KMS_ENABLE: bool = false;
|
pub const DEFAULT_KMS_ENABLE: bool = false;
|
||||||
|
|
||||||
|
/// Environment variable enabling per-key KMS authorization on the SSE-KMS data path.
|
||||||
|
///
|
||||||
|
/// When enabled, an SSE-KMS write additionally requires `kms:GenerateDataKey` and an
|
||||||
|
/// SSE-KMS read additionally requires `kms:Decrypt` on the resolved key, evaluated as
|
||||||
|
/// the requesting identity. SSE-S3 and SSE-C are unaffected.
|
||||||
|
pub const ENV_RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY: &str = "RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY";
|
||||||
|
|
||||||
|
/// Default per-key KMS authorization mode for the SSE-KMS data path.
|
||||||
|
///
|
||||||
|
/// Off for now so deployments whose identity policies only grant s3 actions keep
|
||||||
|
/// working; the default flips to on in a later release.
|
||||||
|
pub const DEFAULT_KMS_ENFORCE_SSE_KEY_POLICY: bool = false;
|
||||||
|
|
||||||
/// Environment variable for server KMS backend.
|
/// Environment variable for server KMS backend.
|
||||||
pub const ENV_RUSTFS_KMS_BACKEND: &str = "RUSTFS_KMS_BACKEND";
|
pub const ENV_RUSTFS_KMS_BACKEND: &str = "RUSTFS_KMS_BACKEND";
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ pub const MAX_ADMIN_REQUEST_BODY_SIZE: usize = 1024 * 1024; // 1 MB
|
|||||||
/// Rationale: ZIP archives with hundreds of IAM entities. 10MB allows ~10,000 small configs.
|
/// Rationale: ZIP archives with hundreds of IAM entities. 10MB allows ~10,000 small configs.
|
||||||
pub const MAX_IAM_IMPORT_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
pub const MAX_IAM_IMPORT_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
/// Maximum total size the members of an IAM import ZIP may expand to (100 MB).
|
||||||
|
/// Used for: bounding decompression of `ImportIam` archive members.
|
||||||
|
/// Rationale: `MAX_IAM_IMPORT_SIZE` caps the *compressed* upload only. Deflate
|
||||||
|
/// reaches ratios far above 100:1, so without a separate budget a 10 MB archive
|
||||||
|
/// can expand without bound. 100 MB keeps a 10x headroom over the compressed cap
|
||||||
|
/// — ample for legitimate IAM exports, which are small JSON documents — while
|
||||||
|
/// keeping the worst case bounded.
|
||||||
|
pub const MAX_IAM_IMPORT_EXPANDED_SIZE: u64 = 100 * 1024 * 1024; // 100 MB
|
||||||
|
|
||||||
/// Maximum size for bucket metadata import operations (100 MB)
|
/// Maximum size for bucket metadata import operations (100 MB)
|
||||||
/// Used for: Bucket metadata import containing configurations for many buckets
|
/// Used for: Bucket metadata import containing configurations for many buckets
|
||||||
/// Rationale: Large deployments may have thousands of buckets with various configs.
|
/// Rationale: Large deployments may have thousands of buckets with various configs.
|
||||||
@@ -54,3 +63,12 @@ pub const MAX_HEAL_REQUEST_SIZE: usize = 1024 * 1024; // 1 MB
|
|||||||
/// 10MB provides generous headroom for legitimate responses while preventing
|
/// 10MB provides generous headroom for legitimate responses while preventing
|
||||||
/// memory exhaustion from malicious or misconfigured remote services.
|
/// memory exhaustion from malicious or misconfigured remote services.
|
||||||
pub const MAX_S3_CLIENT_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
pub const MAX_S3_CLIENT_RESPONSE_SIZE: usize = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
/// Maximum size for OIDC provider response bodies (1 MB)
|
||||||
|
/// Used for: discovery documents, JWKS documents and token endpoint responses
|
||||||
|
/// Rationale: a hostile or compromised identity provider must not be able to exhaust
|
||||||
|
/// memory through an arbitrarily large or endless response body.
|
||||||
|
/// - Discovery documents: typically < 10KB
|
||||||
|
/// - JWKS documents: typically < 50KB
|
||||||
|
/// - Token responses: typically < 10KB
|
||||||
|
pub const MAX_OIDC_RESPONSE_SIZE: usize = 1024 * 1024; // 1 MB
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ pub const DEFAULT_DRIVE_WALKDIR_TIMEOUT_SECS: u64 = 5;
|
|||||||
pub const ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS";
|
pub const ENV_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_STALL_TIMEOUT_SECS";
|
||||||
pub const DEFAULT_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: u64 = 5;
|
pub const DEFAULT_DRIVE_WALKDIR_STALL_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|
||||||
|
/// Maximum time the metacache merge consumer waits for the next visible
|
||||||
|
/// `walk_dir()` entry from a reader before detaching it from the merge.
|
||||||
|
pub const ENV_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS: &str = "RUSTFS_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS";
|
||||||
|
pub const DEFAULT_DRIVE_WALKDIR_PEEK_TIMEOUT_SECS: u64 = 10;
|
||||||
|
|
||||||
/// Interval in seconds between active health probes for local and remote drives.
|
/// Interval in seconds between active health probes for local and remote drives.
|
||||||
pub const ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: &str = "RUSTFS_DRIVE_ACTIVE_CHECK_INTERVAL_SECS";
|
pub const ENV_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: &str = "RUSTFS_DRIVE_ACTIVE_CHECK_INTERVAL_SECS";
|
||||||
pub const DEFAULT_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: u64 = 15;
|
pub const DEFAULT_DRIVE_ACTIVE_CHECK_INTERVAL_SECS: u64 = 15;
|
||||||
|
|||||||
@@ -97,19 +97,96 @@ pub const ENV_INTERNODE_RPC_MAX_MESSAGE_SIZE: &str = "RUSTFS_INTERNODE_RPC_MAX_M
|
|||||||
pub const ENV_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: &str = "RUSTFS_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES";
|
pub const ENV_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: &str = "RUSTFS_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES";
|
||||||
pub const DEFAULT_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: usize = 8 * 1024 * 1024;
|
pub const DEFAULT_INTERNODE_RPC_LARGE_PAYLOAD_WARN_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
|
||||||
/// Stop dual-writing the JSON compatibility strings on internode metadata RPCs and send only the
|
/// Request stopping the JSON compatibility strings on internode metadata RPCs and sending only the
|
||||||
/// msgpack `_bin` payloads (grpc-optimization P2-1).
|
/// msgpack `_bin` payloads (grpc-optimization P2-1).
|
||||||
///
|
///
|
||||||
/// Defaults to `false` (dual-write, byte-for-byte legacy behavior). This is a rollout lever, not a
|
/// Defaults to `false` (dual-write, byte-for-byte legacy behavior). This is only a request; RustFS
|
||||||
/// wire-format change: it may only be enabled **after** the JSON-fallback counter
|
/// keeps JSON compatibility fields unless [`ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED`] is also
|
||||||
/// (`rustfs_system_network_internode_msgpack_json_fallback_total`) has read zero across a release
|
/// true after the release-window convergence and rollback gates pass. See
|
||||||
/// window fleet-wide, confirming every peer decodes `_bin` first. Single-env rollback. See
|
|
||||||
/// `docs/operations/internode-msgpack-json-convergence-runbook.md`.
|
/// `docs/operations/internode-msgpack-json-convergence-runbook.md`.
|
||||||
pub const ENV_INTERNODE_RPC_MSGPACK_ONLY: &str = "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY";
|
pub const ENV_INTERNODE_RPC_MSGPACK_ONLY: &str = "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY";
|
||||||
pub const DEFAULT_INTERNODE_RPC_MSGPACK_ONLY: bool = false;
|
pub const DEFAULT_INTERNODE_RPC_MSGPACK_ONLY: bool = false;
|
||||||
|
|
||||||
// Compile-time invariant: dual-write by default so the base build is byte-for-byte legacy behavior.
|
/// Explicit fleet-wide confirmation gate for [`ENV_INTERNODE_RPC_MSGPACK_ONLY`].
|
||||||
|
///
|
||||||
|
/// This separate default-off guard prevents a single legacy flag from accidentally emptying JSON
|
||||||
|
/// fields in a mixed-version fleet where an older peer still reads the JSON field.
|
||||||
|
pub const ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED: &str = "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED";
|
||||||
|
pub const DEFAULT_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED: bool = false;
|
||||||
|
|
||||||
|
// Compile-time invariants: dual-write by default so the base build is byte-for-byte legacy behavior.
|
||||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_MSGPACK_ONLY);
|
const _: () = assert!(!DEFAULT_INTERNODE_RPC_MSGPACK_ONLY);
|
||||||
|
const _: () = assert!(!DEFAULT_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED);
|
||||||
|
|
||||||
|
/// Require target-bound v2 signatures on every internode gRPC request, rejecting the legacy
|
||||||
|
/// constant-target fallback instead of accepting it (<https://github.com/rustfs/backlog/issues/1327>).
|
||||||
|
///
|
||||||
|
/// Defaults to `false` (fail-open): a request without any v2 auth headers keeps authenticating
|
||||||
|
/// through the legacy signature, so legacy-only peers survive rolling upgrades with byte-for-byte
|
||||||
|
/// the pre-gate acceptance behavior. This is a rollout lever, not a wire-format change: it may only
|
||||||
|
/// be enabled **after** the v1-fallback counter
|
||||||
|
/// (`rustfs_system_network_internode_signature_v1_fallback_total`) has read zero across a release
|
||||||
|
/// window fleet-wide, confirming every peer already sends v2 authentication on every internode gRPC
|
||||||
|
/// request. Single-env rollback. Requests that do carry v2 headers are unaffected by this switch:
|
||||||
|
/// they are always verified as v2 with no downgrade, strict or not.
|
||||||
|
pub const ENV_INTERNODE_RPC_SIGNATURE_STRICT: &str = "RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT";
|
||||||
|
pub const DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT: bool = false;
|
||||||
|
|
||||||
|
// Compile-time invariant: fail-open by default so legacy-only peers keep authenticating during
|
||||||
|
// rolling upgrades until the fleet-wide v1-fallback counter reads zero.
|
||||||
|
const _: () = assert!(!DEFAULT_INTERNODE_RPC_SIGNATURE_STRICT);
|
||||||
|
|
||||||
|
/// Require a signature-bound canonical body digest on every mutating internode disk RPC
|
||||||
|
/// (RenameData, DeleteVersion, DeleteVersions, WriteMetadata, UpdateMetadata, WriteAll, Delete,
|
||||||
|
/// DeletePaths, RenameFile, RenamePart, DeleteVolume, MakeVolume, MakeVolumes), rejecting requests
|
||||||
|
/// that authenticate without one (<https://github.com/rustfs/backlog/issues/1327>).
|
||||||
|
///
|
||||||
|
/// Defaults to `false` (fail-open): a mutating request without a body digest keeps authenticating
|
||||||
|
/// through the method-bound v2 (or legacy) signature, so peers from releases that predate
|
||||||
|
/// body-digest signing survive rolling upgrades unchanged. Requests that do carry a digest are
|
||||||
|
/// always verified with no downgrade, strict or not — the digest value is part of the signed v2
|
||||||
|
/// scope, so an on-path attacker cannot strip it without invalidating the signature. This is a
|
||||||
|
/// rollout lever gated on the body-digest fallback counter
|
||||||
|
/// (`rustfs_system_network_internode_body_digest_fallback_total`) reading zero across a release
|
||||||
|
/// window fleet-wide. Single-env rollback. It is deliberately separate from
|
||||||
|
/// [`ENV_INTERNODE_RPC_SIGNATURE_STRICT`]: the two enforcement flips converge on different
|
||||||
|
/// counters and must not gate each other.
|
||||||
|
pub const ENV_INTERNODE_RPC_BODY_DIGEST_STRICT: &str = "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT";
|
||||||
|
pub const DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT: bool = false;
|
||||||
|
|
||||||
|
// Compile-time invariant: fail-open by default so digestless peers keep authenticating during
|
||||||
|
// rolling upgrades until the fleet-wide body-digest fallback counter reads zero.
|
||||||
|
const _: () = assert!(!DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT);
|
||||||
|
|
||||||
|
/// Require the replay-scoped internode RPC signature after the fleet has converged on it.
|
||||||
|
///
|
||||||
|
/// The default keeps v1/v2 peers available during a rolling upgrade. Operators may set this only
|
||||||
|
/// after `rustfs_system_network_internode_replay_scope_fallback_total` remains zero for a full
|
||||||
|
/// release window. The node still accepts a v2-authenticated `Ping` carrying an epoch challenge:
|
||||||
|
/// that narrowly scoped bootstrap lets an upgraded client learn the receiving process epoch and
|
||||||
|
/// immediately retry with the replay-scoped signature after a peer restart.
|
||||||
|
pub const ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT: &str = "RUSTFS_INTERNODE_RPC_REPLAY_SCOPE_STRICT";
|
||||||
|
pub const DEFAULT_INTERNODE_RPC_REPLAY_SCOPE_STRICT: bool = false;
|
||||||
|
|
||||||
|
// Compile-time invariant: mixed-version clusters must remain available until operators make the
|
||||||
|
// observed fallback counter an explicit strictness decision.
|
||||||
|
const _: () = assert!(!DEFAULT_INTERNODE_RPC_REPLAY_SCOPE_STRICT);
|
||||||
|
|
||||||
|
/// Capacity (distinct nonces) of the process-local internode RPC replay cache that enforces
|
||||||
|
/// one-time consumption of authenticated RPC signatures.
|
||||||
|
///
|
||||||
|
/// The cache retains each nonce for the ~10-minute signature freshness envelope. Once peers use
|
||||||
|
/// replay-scoped v3 authentication, every authenticated RPC consumes one entry, so the steady
|
||||||
|
/// state holds roughly `authenticated RPC RPS x 601s` entries. The default sustains about 1,700
|
||||||
|
/// authenticated RPCs per second (about 120 MiB worst case, allocated only under sustained load);
|
||||||
|
/// operators must size it for the node's aggregate peak RPC rate before enabling strict replay
|
||||||
|
/// scope. Overflow fails closed — legitimate signed traffic is the only thing that can fill the
|
||||||
|
/// cache (replays are rejected before insertion, and an attacker cannot mint valid nonces without
|
||||||
|
/// the shared secret) — and increments
|
||||||
|
/// `rustfs_system_network_internode_replay_cache_overflow_total`, so a sustained non-zero overflow
|
||||||
|
/// counter means this capacity is undersized for the node's peak authenticated RPC rate.
|
||||||
|
pub const ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: &str = "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY";
|
||||||
|
pub const DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: usize = 1_048_576;
|
||||||
|
|
||||||
/// Consecutive-failure threshold after which an internode peer is marked offline (grpc-optimization
|
/// Consecutive-failure threshold after which an internode peer is marked offline (grpc-optimization
|
||||||
/// P3 observability).
|
/// P3 observability).
|
||||||
@@ -273,8 +350,36 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn internode_msgpack_only_env_name_is_stable() {
|
fn internode_msgpack_only_env_name_is_stable() {
|
||||||
// The dual-write-by-default invariant is asserted at compile time next to the definition.
|
// The dual-write-by-default invariants are asserted at compile time next to the definitions.
|
||||||
assert_eq!(ENV_INTERNODE_RPC_MSGPACK_ONLY, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY");
|
assert_eq!(ENV_INTERNODE_RPC_MSGPACK_ONLY, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY");
|
||||||
|
assert_eq!(
|
||||||
|
ENV_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED,
|
||||||
|
"RUSTFS_INTERNODE_RPC_MSGPACK_ONLY_FLEET_CONFIRMED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internode_signature_strict_env_name_is_stable() {
|
||||||
|
// The fail-open default invariant is asserted at compile time next to the definition.
|
||||||
|
assert_eq!(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internode_body_digest_strict_env_name_is_stable() {
|
||||||
|
// The fail-open default invariant is asserted at compile time next to the definition.
|
||||||
|
assert_eq!(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internode_replay_scope_strict_env_name_is_stable() {
|
||||||
|
// The fail-open default invariant is asserted at compile time next to the definition.
|
||||||
|
assert_eq!(ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT, "RUSTFS_INTERNODE_RPC_REPLAY_SCOPE_STRICT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn internode_replay_cache_capacity_defaults_and_env_name() {
|
||||||
|
assert_eq!(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY");
|
||||||
|
assert_eq!(DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, 1_048_576);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -116,6 +116,27 @@ pub const ENV_OBJECT_GET_SKIP_BITROT_VERIFY: &str = "RUSTFS_OBJECT_GET_SKIP_BITR
|
|||||||
/// Default: bitrot verification is enabled on GetObject reads (do not skip).
|
/// Default: bitrot verification is enabled on GetObject reads (do not skip).
|
||||||
pub const DEFAULT_OBJECT_GET_SKIP_BITROT_VERIFY: bool = false;
|
pub const DEFAULT_OBJECT_GET_SKIP_BITROT_VERIFY: bool = false;
|
||||||
|
|
||||||
|
/// Request writing the complete remote-tier version state into object metadata.
|
||||||
|
///
|
||||||
|
/// This remains ineffective until
|
||||||
|
/// [`ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED`] is also enabled.
|
||||||
|
pub const ENV_TIER_REMOTE_VERSION_STATE_WRITE: &str = "RUSTFS_TIER_REMOTE_VERSION_STATE_WRITE";
|
||||||
|
pub const DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE: bool = false;
|
||||||
|
|
||||||
|
/// Operator-attested fleet-wide confirmation for
|
||||||
|
/// [`ENV_TIER_REMOTE_VERSION_STATE_WRITE`].
|
||||||
|
///
|
||||||
|
/// This flag is an operational contract, not automatic capability discovery.
|
||||||
|
/// Operators may enable it only after every node that can write or read
|
||||||
|
/// transitioned object metadata supports the remote version-state schema and
|
||||||
|
/// semantics. Keeping the confirmation separate makes a single-node request or
|
||||||
|
/// a writer whose local opt-in is removed fail closed.
|
||||||
|
pub const ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: &str = "RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED";
|
||||||
|
pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
|
||||||
|
|
||||||
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
||||||
|
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -617,3 +638,15 @@ pub const ENV_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY: &str = "RUSTFS_OBJ
|
|||||||
|
|
||||||
/// Default read-ahead disable concurrency threshold: 4.
|
/// Default read-ahead disable concurrency threshold: 4.
|
||||||
pub const DEFAULT_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY: usize = 4;
|
pub const DEFAULT_OBJECT_IO_RANDOM_READAHEAD_DISABLE_CONCURRENCY: usize = 4;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod remote_version_state_tests {
|
||||||
|
#[test]
|
||||||
|
fn remote_version_state_gate_uses_stable_environment_names() {
|
||||||
|
assert_eq!(super::ENV_TIER_REMOTE_VERSION_STATE_WRITE, "RUSTFS_TIER_REMOTE_VERSION_STATE_WRITE");
|
||||||
|
assert_eq!(
|
||||||
|
super::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
|
||||||
|
"RUSTFS_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
// OIDC configuration field keys (used in KVS)
|
// OIDC configuration field keys (used in KVS)
|
||||||
pub const OIDC_CONFIG_URL: &str = "config_url";
|
pub const OIDC_CONFIG_URL: &str = "config_url";
|
||||||
|
pub const OIDC_ISSUER: &str = "issuer";
|
||||||
pub const OIDC_CLIENT_ID: &str = "client_id";
|
pub const OIDC_CLIENT_ID: &str = "client_id";
|
||||||
pub const OIDC_CLIENT_SECRET: &str = "client_secret";
|
pub const OIDC_CLIENT_SECRET: &str = "client_secret";
|
||||||
pub const OIDC_SCOPES: &str = "scopes";
|
pub const OIDC_SCOPES: &str = "scopes";
|
||||||
@@ -33,6 +34,7 @@ pub const OIDC_HIDE_FROM_UI: &str = "hide_from_ui";
|
|||||||
// Environment variable names for OIDC
|
// Environment variable names for OIDC
|
||||||
pub const ENV_IDENTITY_OPENID_ENABLE: &str = "RUSTFS_IDENTITY_OPENID_ENABLE";
|
pub const ENV_IDENTITY_OPENID_ENABLE: &str = "RUSTFS_IDENTITY_OPENID_ENABLE";
|
||||||
pub const ENV_IDENTITY_OPENID_CONFIG_URL: &str = "RUSTFS_IDENTITY_OPENID_CONFIG_URL";
|
pub const ENV_IDENTITY_OPENID_CONFIG_URL: &str = "RUSTFS_IDENTITY_OPENID_CONFIG_URL";
|
||||||
|
pub const ENV_IDENTITY_OPENID_ISSUER: &str = "RUSTFS_IDENTITY_OPENID_ISSUER";
|
||||||
pub const ENV_IDENTITY_OPENID_CLIENT_ID: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_ID";
|
pub const ENV_IDENTITY_OPENID_CLIENT_ID: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_ID";
|
||||||
pub const ENV_IDENTITY_OPENID_CLIENT_SECRET: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_SECRET";
|
pub const ENV_IDENTITY_OPENID_CLIENT_SECRET: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_SECRET";
|
||||||
pub const ENV_IDENTITY_OPENID_SCOPES: &str = "RUSTFS_IDENTITY_OPENID_SCOPES";
|
pub const ENV_IDENTITY_OPENID_SCOPES: &str = "RUSTFS_IDENTITY_OPENID_SCOPES";
|
||||||
@@ -50,9 +52,10 @@ pub const ENV_IDENTITY_OPENID_USERNAME_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_USE
|
|||||||
pub const ENV_IDENTITY_OPENID_HIDE_FROM_UI: &str = "RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI";
|
pub const ENV_IDENTITY_OPENID_HIDE_FROM_UI: &str = "RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI";
|
||||||
|
|
||||||
/// List of all environment variable keys for an OIDC provider.
|
/// List of all environment variable keys for an OIDC provider.
|
||||||
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
|
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 18] = &[
|
||||||
ENV_IDENTITY_OPENID_ENABLE,
|
ENV_IDENTITY_OPENID_ENABLE,
|
||||||
ENV_IDENTITY_OPENID_CONFIG_URL,
|
ENV_IDENTITY_OPENID_CONFIG_URL,
|
||||||
|
ENV_IDENTITY_OPENID_ISSUER,
|
||||||
ENV_IDENTITY_OPENID_CLIENT_ID,
|
ENV_IDENTITY_OPENID_CLIENT_ID,
|
||||||
ENV_IDENTITY_OPENID_CLIENT_SECRET,
|
ENV_IDENTITY_OPENID_CLIENT_SECRET,
|
||||||
ENV_IDENTITY_OPENID_SCOPES,
|
ENV_IDENTITY_OPENID_SCOPES,
|
||||||
@@ -74,6 +77,7 @@ pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
|
|||||||
pub const IDENTITY_OPENID_KEYS: &[&str] = &[
|
pub const IDENTITY_OPENID_KEYS: &[&str] = &[
|
||||||
crate::ENABLE_KEY,
|
crate::ENABLE_KEY,
|
||||||
OIDC_CONFIG_URL,
|
OIDC_CONFIG_URL,
|
||||||
|
OIDC_ISSUER,
|
||||||
OIDC_CLIENT_ID,
|
OIDC_CLIENT_ID,
|
||||||
OIDC_CLIENT_SECRET,
|
OIDC_CLIENT_SECRET,
|
||||||
OIDC_SCOPES,
|
OIDC_SCOPES,
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub const ENV_WEBDAV_CERTS_DIR: &str = "RUSTFS_WEBDAV_CERTS_DIR";
|
|||||||
pub const ENV_WEBDAV_CA_FILE: &str = "RUSTFS_WEBDAV_CA_FILE";
|
pub const ENV_WEBDAV_CA_FILE: &str = "RUSTFS_WEBDAV_CA_FILE";
|
||||||
pub const ENV_WEBDAV_MAX_BODY_SIZE: &str = "RUSTFS_WEBDAV_MAX_BODY_SIZE";
|
pub const ENV_WEBDAV_MAX_BODY_SIZE: &str = "RUSTFS_WEBDAV_MAX_BODY_SIZE";
|
||||||
pub const ENV_WEBDAV_REQUEST_TIMEOUT: &str = "RUSTFS_WEBDAV_REQUEST_TIMEOUT";
|
pub const ENV_WEBDAV_REQUEST_TIMEOUT: &str = "RUSTFS_WEBDAV_REQUEST_TIMEOUT";
|
||||||
|
pub const ENV_WEBDAV_MAX_CONNECTIONS: &str = "RUSTFS_WEBDAV_MAX_CONNECTIONS";
|
||||||
|
|
||||||
/// Default SFTP server bind address.
|
/// Default SFTP server bind address.
|
||||||
pub const DEFAULT_SFTP_ADDRESS: &str = "0.0.0.0:2222";
|
pub const DEFAULT_SFTP_ADDRESS: &str = "0.0.0.0:2222";
|
||||||
|
|||||||
@@ -220,12 +220,10 @@ pub const ENV_SCANNER_YIELD_EVERY_N_OBJECTS: &str = "RUSTFS_SCANNER_YIELD_EVERY_
|
|||||||
pub const DEFAULT_SCANNER_IDLE_MODE: bool = true;
|
pub const DEFAULT_SCANNER_IDLE_MODE: bool = true;
|
||||||
|
|
||||||
/// Default set scan concurrency budget.
|
/// Default set scan concurrency budget.
|
||||||
/// `0` means no additional limit beyond deployment topology.
|
pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 4;
|
||||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_SET_SCANS: usize = 0;
|
|
||||||
|
|
||||||
/// Default disk scan concurrency budget.
|
/// Default disk scan concurrency budget.
|
||||||
/// `0` means no additional limit beyond available disks in the set.
|
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 4;
|
||||||
pub const DEFAULT_SCANNER_MAX_CONCURRENT_DISK_SCANS: usize = 0;
|
|
||||||
|
|
||||||
/// Default object interval for cooperative scanner yields.
|
/// Default object interval for cooperative scanner yields.
|
||||||
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
|
pub const DEFAULT_SCANNER_YIELD_EVERY_N_OBJECTS: u64 = 128;
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ pub const DEFAULT_H2_KEEP_ALIVE_TIMEOUT: u64 = 10;
|
|||||||
/// proxy's upstream idle-keepalive, or lower the proxy's keepalive below this
|
/// proxy's upstream idle-keepalive, or lower the proxy's keepalive below this
|
||||||
/// value. Environments that expose RustFS directly to untrusted slow clients and
|
/// value. Environments that expose RustFS directly to untrusted slow clients and
|
||||||
/// want tighter slowloris protection can lower it via the env var below.
|
/// want tighter slowloris protection can lower it via the env var below.
|
||||||
|
///
|
||||||
|
/// The same budget bounds the TLS handshake on the listener, so an unauthenticated
|
||||||
|
/// peer cannot park an accept task and its socket indefinitely by opening a
|
||||||
|
/// connection and then stalling the handshake.
|
||||||
pub const ENV_HTTP1_HEADER_READ_TIMEOUT: &str = "RUSTFS_HTTP1_HEADER_READ_TIMEOUT";
|
pub const ENV_HTTP1_HEADER_READ_TIMEOUT: &str = "RUSTFS_HTTP1_HEADER_READ_TIMEOUT";
|
||||||
pub const DEFAULT_HTTP1_HEADER_READ_TIMEOUT: u64 = 75;
|
pub const DEFAULT_HTTP1_HEADER_READ_TIMEOUT: u64 = 75;
|
||||||
|
|
||||||
|
|||||||
@@ -56,3 +56,33 @@ pub const DEFAULT_OBJECT_MMAP_READ_ENABLE: bool = true;
|
|||||||
///
|
///
|
||||||
/// Prefer [`DEFAULT_OBJECT_MMAP_READ_ENABLE`].
|
/// Prefer [`DEFAULT_OBJECT_MMAP_READ_ENABLE`].
|
||||||
pub const DEFAULT_OBJECT_ZERO_COPY_ENABLE: bool = DEFAULT_OBJECT_MMAP_READ_ENABLE;
|
pub const DEFAULT_OBJECT_ZERO_COPY_ENABLE: bool = DEFAULT_OBJECT_MMAP_READ_ENABLE;
|
||||||
|
|
||||||
|
/// Environment variable capping the byte length a single mmap-copy read may
|
||||||
|
/// materialize in memory.
|
||||||
|
///
|
||||||
|
/// The mmap-copy read path returns the whole requested range as one owned
|
||||||
|
/// allocation before the first byte is served. GET/heal shard reads request
|
||||||
|
/// the entire part span in one call, so for a large single-part object
|
||||||
|
/// (e.g. a multi-gigabyte non-multipart upload) an uncapped mmap-copy read
|
||||||
|
/// allocates the whole shard in memory — stalling first-byte latency past the
|
||||||
|
/// disk-read timeout and OOM-killing memory-limited deployments
|
||||||
|
/// (<https://github.com/rustfs/rustfs/issues/5123>). Reads longer than this
|
||||||
|
/// cap fall back to the bounded streaming reader instead.
|
||||||
|
///
|
||||||
|
/// - Purpose: Bound per-shard-read memory for mmap-based reads
|
||||||
|
/// - Acceptable values: byte count as an unsigned integer; `0` disables
|
||||||
|
/// mmap-copy for all non-empty reads (every read streams)
|
||||||
|
/// - Example: `export RUSTFS_OBJECT_MMAP_READ_MAX_LENGTH=8388608`
|
||||||
|
pub const ENV_OBJECT_MMAP_READ_MAX_LENGTH: &str = "RUSTFS_OBJECT_MMAP_READ_MAX_LENGTH";
|
||||||
|
|
||||||
|
/// Default mmap-copy read length cap: 32 MiB per shard read.
|
||||||
|
///
|
||||||
|
/// Large enough that typical multipart part shards (parts up to a few hundred
|
||||||
|
/// megabytes across the erasure set) keep the mmap fast path, small enough
|
||||||
|
/// that whole-part reads of huge single-part objects stream instead of
|
||||||
|
/// materializing gigabytes per shard.
|
||||||
|
///
|
||||||
|
/// The cap bounds memory per shard reader, so a single part read can still
|
||||||
|
/// materialize up to `data_shards x cap` bytes; raising the cap raises that
|
||||||
|
/// per-request bound proportionally.
|
||||||
|
pub const DEFAULT_OBJECT_MMAP_READ_MAX_LENGTH: usize = 32 * 1024 * 1024;
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ description = "Credentials management utilities for RustFS, enabling secure hand
|
|||||||
keywords = ["rustfs", "Minio", "credentials", "authentication", "authorization"]
|
keywords = ["rustfs", "Minio", "credentials", "authentication", "authorization"]
|
||||||
categories = ["web-programming", "development-tools", "data-structures", "security"]
|
categories = ["web-programming", "development-tools", "data-structures", "security"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hotpath = ["hotpath/hotpath"]
|
||||||
|
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||||
|
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hotpath.workspace = true
|
||||||
base64-simd = { workspace = true }
|
base64-simd = { workspace = true }
|
||||||
hmac = { workspace = true }
|
hmac = { workspace = true }
|
||||||
rand = { workspace = true, features = ["serde"] }
|
rand = { workspace = true, features = ["serde"] }
|
||||||
|
|||||||
@@ -260,13 +260,10 @@ fn resolve_rpc_secret(env_secret: Option<&str>, global_access: Option<&str>, glo
|
|||||||
|
|
||||||
match (global_access, global_secret) {
|
match (global_access, global_secret) {
|
||||||
(Some(access_key), Some(secret_key)) => {
|
(Some(access_key), Some(secret_key)) => {
|
||||||
// Fail closed: never derive the RPC secret while the default secret
|
// Fail closed when either half of the active credential pair still
|
||||||
// key is in effect. The derivation uses `secret_key` as the HMAC key,
|
// uses the public default. Operators must configure both custom
|
||||||
// so a public default secret yields a publicly computable RPC secret
|
// credentials or provide RUSTFS_RPC_SECRET explicitly.
|
||||||
// that any network peer can use to forge internode RPC signatures.
|
if access_key.trim() == DEFAULT_ACCESS_KEY || secret_key.trim() == DEFAULT_SECRET_KEY {
|
||||||
// Operators running with default credentials must configure
|
|
||||||
// RUSTFS_RPC_SECRET (or set a non-default RUSTFS_SECRET_KEY) instead.
|
|
||||||
if secret_key.trim() == DEFAULT_SECRET_KEY {
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
derive_rpc_secret(access_key, secret_key)
|
derive_rpc_secret(access_key, secret_key)
|
||||||
@@ -589,18 +586,11 @@ mod tests {
|
|||||||
fn test_resolve_rpc_secret_rejects_default_credentials_for_derivation() {
|
fn test_resolve_rpc_secret_rejects_default_credentials_for_derivation() {
|
||||||
assert!(resolve_rpc_secret(None, None, None).is_none());
|
assert!(resolve_rpc_secret(None, None, None).is_none());
|
||||||
|
|
||||||
// Fail closed: the default secret key must not yield a derivable RPC
|
// Fail closed when either half of the credential pair uses the public
|
||||||
// secret, otherwise the derived value is publicly computable and any
|
// default.
|
||||||
// network peer can forge internode RPC signatures.
|
|
||||||
assert!(resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).is_none());
|
assert!(resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).is_none());
|
||||||
|
assert!(resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some("custom-global-secret")).is_none());
|
||||||
// A default access key paired with a non-default secret key is still
|
assert!(resolve_rpc_secret(None, Some("custom-access"), Some(DEFAULT_SECRET_KEY)).is_none());
|
||||||
// safe to derive: the HMAC key (the secret key) is not public.
|
|
||||||
let expected = derive_rpc_secret(DEFAULT_ACCESS_KEY, "custom-global-secret").expect("secret should derive");
|
|
||||||
assert_eq!(
|
|
||||||
resolve_rpc_secret(None, Some(DEFAULT_ACCESS_KEY), Some("custom-global-secret")).as_deref(),
|
|
||||||
Some(expected.as_str())
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(resolve_rpc_secret(Some(DEFAULT_SECRET_KEY), Some("custom-access"), Some("custom-global-secret")).is_none());
|
assert!(resolve_rpc_secret(Some(DEFAULT_SECRET_KEY), Some("custom-access"), Some("custom-global-secret")).is_none());
|
||||||
}
|
}
|
||||||
@@ -635,6 +625,10 @@ mod tests {
|
|||||||
resolve_rpc_secret(Some("custom-rpc-secret"), None, None).as_deref(),
|
resolve_rpc_secret(Some("custom-rpc-secret"), None, None).as_deref(),
|
||||||
Some("custom-rpc-secret")
|
Some("custom-rpc-secret")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_rpc_secret(Some("custom-rpc-secret"), Some(DEFAULT_ACCESS_KEY), Some(DEFAULT_SECRET_KEY)).as_deref(),
|
||||||
|
Some("custom-rpc-secret")
|
||||||
|
);
|
||||||
let expected = derive_rpc_secret("custom-access", "custom-global-secret").expect("secret should derive");
|
let expected = derive_rpc_secret("custom-access", "custom-global-secret").expect("secret should derive");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resolve_rpc_secret(None, Some("custom-access"), Some("custom-global-secret")).as_deref(),
|
resolve_rpc_secret(None, Some("custom-access"), Some("custom-global-secret")).as_deref(),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ documentation = "https://docs.rs/rustfs-crypto/latest/rustfs_crypto/"
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hotpath.workspace = true
|
||||||
aes-gcm = { workspace = true, optional = true, features = ["rand_core"] }
|
aes-gcm = { workspace = true, optional = true, features = ["rand_core"] }
|
||||||
argon2 = { workspace = true, optional = true }
|
argon2 = { workspace = true, optional = true }
|
||||||
chacha20poly1305 = { workspace = true, optional = true }
|
chacha20poly1305 = { workspace = true, optional = true }
|
||||||
@@ -49,6 +50,9 @@ time = { workspace = true, features = ["parsing", "formatting", "macros", "serde
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["crypto", "fips"]
|
default = ["crypto", "fips"]
|
||||||
|
hotpath = ["hotpath/hotpath"]
|
||||||
|
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||||
|
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||||
fips = []
|
fips = []
|
||||||
crypto = [
|
crypto = [
|
||||||
"dep:aes-gcm",
|
"dep:aes-gcm",
|
||||||
|
|||||||
@@ -27,9 +27,15 @@ categories = ["data-structures", "filesystem"]
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
hotpath = ["hotpath/hotpath", "rustfs-filemeta/hotpath"]
|
||||||
|
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "rustfs-filemeta/hotpath-alloc"]
|
||||||
|
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-filemeta/hotpath-cpu"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hotpath.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
path-clean = { workspace = true }
|
|
||||||
rmp-serde = { workspace = true }
|
rmp-serde = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
rustfs-filemeta = { workspace = true }
|
rustfs-filemeta = { workspace = true }
|
||||||
|
|||||||
@@ -12,15 +12,45 @@
|
|||||||
// 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.
|
||||||
|
|
||||||
use path_clean::PathClean;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
hash::{DefaultHasher, Hash, Hasher},
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
path::Path,
|
time::{Duration, SystemTime},
|
||||||
time::SystemTime,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Maximum amount a persisted `last_update` may lead the local wall clock before the
|
||||||
|
/// persisted timestamp is treated as untrustworthy.
|
||||||
|
///
|
||||||
|
/// Invariant: the "skip stale usage update" monotonicity check (incoming `last_update`
|
||||||
|
/// <= existing `last_update` => skip persisting) is only valid while the existing
|
||||||
|
/// timestamp could plausibly have been produced by a healthy clock. If the on-disk
|
||||||
|
/// snapshot is future-dated beyond this tolerance (NTP step-back, or scanner
|
||||||
|
/// leadership moving to a node with a slower clock), the comparison would skip every
|
||||||
|
/// save forever and freeze admin usage stats; callers must bypass the skip instead.
|
||||||
|
pub const USAGE_LAST_UPDATE_FUTURE_TOLERANCE: Duration = Duration::from_secs(5 * 60);
|
||||||
|
|
||||||
|
/// Cluster-wide usage snapshot written by coordinated scanners.
|
||||||
|
///
|
||||||
|
/// `usage_snapshot_complete` is an additive JSON field: older readers ignore
|
||||||
|
/// it, while current readers treat snapshots from older writers as unknown.
|
||||||
|
/// Keeping the existing object name preserves rolling-upgrade and rollback
|
||||||
|
/// compatibility without allowing an ambiguous snapshot to become authoritative.
|
||||||
|
pub const DATA_USAGE_OBJECT_NAME: &str = ".usage.v2.json";
|
||||||
|
|
||||||
|
/// Usage snapshot written by scanner implementations predating distributed
|
||||||
|
/// leadership fencing. It is read only when neither authoritative snapshot
|
||||||
|
/// copy exists.
|
||||||
|
// RUSTFS_COMPAT_TODO(scanner-usage-v2): keep .usage.json readable and removable during rolling upgrades from pre-v2 scanners. Remove after supported direct-upgrade sources all write .usage.v2.json.
|
||||||
|
pub const LEGACY_DATA_USAGE_OBJECT_NAME: &str = ".usage.json";
|
||||||
|
|
||||||
|
/// Returns true when `existing_last_update` is ahead of `now` by more than
|
||||||
|
/// [`USAGE_LAST_UPDATE_FUTURE_TOLERANCE`], i.e. the persisted timestamp cannot be
|
||||||
|
/// trusted for staleness comparisons and a fresh snapshot save must be allowed.
|
||||||
|
pub fn usage_last_update_is_untrusted_future(existing_last_update: SystemTime, now: SystemTime) -> bool {
|
||||||
|
existing_last_update > now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq)]
|
#[derive(Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct TierStats {
|
pub struct TierStats {
|
||||||
pub total_size: u64,
|
pub total_size: u64,
|
||||||
@@ -77,7 +107,7 @@ impl AllTierStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Bucket target usage info provides replication statistics
|
/// Bucket target usage info provides replication statistics
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct BucketTargetUsageInfo {
|
pub struct BucketTargetUsageInfo {
|
||||||
pub replication_pending_size: u64,
|
pub replication_pending_size: u64,
|
||||||
pub replication_failed_size: u64,
|
pub replication_failed_size: u64,
|
||||||
@@ -89,7 +119,7 @@ pub struct BucketTargetUsageInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Bucket usage info provides bucket-level statistics
|
/// Bucket usage info provides bucket-level statistics
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct BucketUsageInfo {
|
pub struct BucketUsageInfo {
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
// Following five fields suffixed with V1 are here for backward compatibility
|
// Following five fields suffixed with V1 are here for backward compatibility
|
||||||
@@ -115,7 +145,7 @@ pub struct BucketUsageInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// DataUsageInfo represents data usage stats of the underlying storage
|
/// DataUsageInfo represents data usage stats of the underlying storage
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct DataUsageInfo {
|
pub struct DataUsageInfo {
|
||||||
/// Total capacity
|
/// Total capacity
|
||||||
pub total_capacity: u64,
|
pub total_capacity: u64,
|
||||||
@@ -127,6 +157,22 @@ pub struct DataUsageInfo {
|
|||||||
/// LastUpdate is the timestamp of when the data usage info was last updated
|
/// LastUpdate is the timestamp of when the data usage info was last updated
|
||||||
pub last_update: Option<SystemTime>,
|
pub last_update: Option<SystemTime>,
|
||||||
|
|
||||||
|
/// Monotonic scanner cycle that produced this complete snapshot.
|
||||||
|
///
|
||||||
|
/// Older snapshots omit this field and continue to use `last_update` for
|
||||||
|
/// compatibility. New scanner snapshots use the cycle to fence stale
|
||||||
|
/// leaders independently of wall-clock skew.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scanner_cycle: Option<u64>,
|
||||||
|
|
||||||
|
/// Persisted scanner leadership epoch that produced this snapshot.
|
||||||
|
///
|
||||||
|
/// The epoch is claimed through the cycle-state CAS before scanning. It
|
||||||
|
/// orders snapshots from different leaders even when their wall clocks or
|
||||||
|
/// cycle counters coincide.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scanner_epoch: Option<u64>,
|
||||||
|
|
||||||
/// Objects total count across all buckets
|
/// Objects total count across all buckets
|
||||||
pub objects_total_count: u64,
|
pub objects_total_count: u64,
|
||||||
/// Versions total count across all buckets
|
/// Versions total count across all buckets
|
||||||
@@ -142,6 +188,12 @@ pub struct DataUsageInfo {
|
|||||||
pub buckets_count: u64,
|
pub buckets_count: u64,
|
||||||
/// Buckets usage info provides following information across all buckets
|
/// Buckets usage info provides following information across all buckets
|
||||||
pub buckets_usage: HashMap<String, BucketUsageInfo>,
|
pub buckets_usage: HashMap<String, BucketUsageInfo>,
|
||||||
|
/// Whether this snapshot covers the complete bucket namespace.
|
||||||
|
///
|
||||||
|
/// Legacy snapshots default to `false`. A complete snapshot contains an
|
||||||
|
/// explicit entry for every bucket, including confirmed-empty buckets.
|
||||||
|
#[serde(default)]
|
||||||
|
pub usage_snapshot_complete: bool,
|
||||||
/// Deprecated kept here for backward compatibility reasons
|
/// Deprecated kept here for backward compatibility reasons
|
||||||
pub bucket_sizes: HashMap<String, u64>,
|
pub bucket_sizes: HashMap<String, u64>,
|
||||||
/// Per-disk snapshot information when available
|
/// Per-disk snapshot information when available
|
||||||
@@ -150,7 +202,7 @@ pub struct DataUsageInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Metadata describing the status of a disk-level data usage snapshot.
|
/// Metadata describing the status of a disk-level data usage snapshot.
|
||||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct DiskUsageStatus {
|
pub struct DiskUsageStatus {
|
||||||
pub disk_id: String,
|
pub disk_id: String,
|
||||||
pub pool_index: Option<usize>,
|
pub pool_index: Option<usize>,
|
||||||
@@ -250,19 +302,37 @@ impl DataUsageHash {
|
|||||||
pub type DataUsageHashMap = HashSet<String>;
|
pub type DataUsageHashMap = HashSet<String>;
|
||||||
|
|
||||||
/// Size histogram for object size distribution
|
/// Size histogram for object size distribution
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
const SIZE_HISTOGRAM_LEN: usize = 11;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct SizeHistogram(Vec<u64>);
|
pub struct SizeHistogram(Vec<u64>);
|
||||||
|
|
||||||
impl Default for SizeHistogram {
|
impl Default for SizeHistogram {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self(vec![0; 11]) // DATA_USAGE_BUCKET_LEN = 11
|
Self(vec![0; SIZE_HISTOGRAM_LEN])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SizeHistogram {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let values = Vec::<u64>::deserialize(deserializer)?;
|
||||||
|
if values.len() != SIZE_HISTOGRAM_LEN {
|
||||||
|
return Err(serde::de::Error::invalid_length(
|
||||||
|
values.len(),
|
||||||
|
&"exactly 11 object-size histogram buckets",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self(values))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SizeHistogram {
|
impl SizeHistogram {
|
||||||
pub fn add(&mut self, size: u64) {
|
pub fn add(&mut self, size: u64) {
|
||||||
let intervals = [
|
let intervals = [
|
||||||
(0, 1024), // LESS_THAN_1024_B
|
(0, 1024 - 1), // LESS_THAN_1024_B
|
||||||
(1024, 64 * 1024 - 1), // BETWEEN_1024_B_AND_64_KB
|
(1024, 64 * 1024 - 1), // BETWEEN_1024_B_AND_64_KB
|
||||||
(64 * 1024, 256 * 1024 - 1), // BETWEEN_64_KB_AND_256_KB
|
(64 * 1024, 256 * 1024 - 1), // BETWEEN_64_KB_AND_256_KB
|
||||||
(256 * 1024, 512 * 1024 - 1), // BETWEEN_256_KB_AND_512_KB
|
(256 * 1024, 512 * 1024 - 1), // BETWEEN_256_KB_AND_512_KB
|
||||||
@@ -290,7 +360,7 @@ impl SizeHistogram {
|
|||||||
// the sub-ranges in [1 KiB, 512 KiB).
|
// the sub-ranges in [1 KiB, 512 KiB).
|
||||||
const ONE_MIB: u64 = 1024 * 1024;
|
const ONE_MIB: u64 = 1024 * 1024;
|
||||||
let intervals = [
|
let intervals = [
|
||||||
(0, 1024), // LESS_THAN_1024_B
|
(0, 1024 - 1), // LESS_THAN_1024_B
|
||||||
(1024, 64 * 1024 - 1), // BETWEEN_1024_B_AND_64_KB
|
(1024, 64 * 1024 - 1), // BETWEEN_1024_B_AND_64_KB
|
||||||
(64 * 1024, 256 * 1024 - 1), // BETWEEN_64_KB_AND_256_KB
|
(64 * 1024, 256 * 1024 - 1), // BETWEEN_64_KB_AND_256_KB
|
||||||
(256 * 1024, 512 * 1024 - 1), // BETWEEN_256_KB_AND_512_KB
|
(256 * 1024, 512 * 1024 - 1), // BETWEEN_256_KB_AND_512_KB
|
||||||
@@ -325,7 +395,7 @@ impl SizeHistogram {
|
|||||||
.zip(names.iter())
|
.zip(names.iter())
|
||||||
.filter(|((_, (start, end)), name)| name != &&"BETWEEN_1024B_AND_1_MB" && *start >= 1024 && *end < ONE_MIB)
|
.filter(|((_, (start, end)), name)| name != &&"BETWEEN_1024B_AND_1_MB" && *start >= 1024 && *end < ONE_MIB)
|
||||||
.map(|((count, _), _)| *count)
|
.map(|((count, _), _)| *count)
|
||||||
.sum();
|
.fold(0, u64::saturating_add);
|
||||||
|
|
||||||
let mut res = HashMap::new();
|
let mut res = HashMap::new();
|
||||||
for (count, name) in self.0.iter().zip(names.iter()) {
|
for (count, name) in self.0.iter().zip(names.iter()) {
|
||||||
@@ -346,12 +416,30 @@ impl SizeHistogram {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Versions histogram for version count distribution
|
/// Versions histogram for version count distribution
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
const VERSIONS_HISTOGRAM_LEN: usize = 7;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct VersionsHistogram(Vec<u64>);
|
pub struct VersionsHistogram(Vec<u64>);
|
||||||
|
|
||||||
impl Default for VersionsHistogram {
|
impl Default for VersionsHistogram {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self(vec![0; 7]) // DATA_USAGE_VERSION_LEN = 7
|
Self(vec![0; VERSIONS_HISTOGRAM_LEN])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for VersionsHistogram {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let values = Vec::<u64>::deserialize(deserializer)?;
|
||||||
|
if values.len() != VERSIONS_HISTOGRAM_LEN {
|
||||||
|
return Err(serde::de::Error::invalid_length(
|
||||||
|
values.len(),
|
||||||
|
&"exactly 7 object-version histogram buckets",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self(values))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,8 +504,35 @@ pub struct ReplicationStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReplicationStats {
|
impl ReplicationStats {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
let Self {
|
||||||
|
pending_size,
|
||||||
|
replicated_size,
|
||||||
|
failed_size,
|
||||||
|
failed_count,
|
||||||
|
pending_count,
|
||||||
|
missed_threshold_size,
|
||||||
|
after_threshold_size,
|
||||||
|
missed_threshold_count,
|
||||||
|
after_threshold_count,
|
||||||
|
replicated_count,
|
||||||
|
} = self;
|
||||||
|
|
||||||
|
*pending_size == 0
|
||||||
|
&& *replicated_size == 0
|
||||||
|
&& *failed_size == 0
|
||||||
|
&& *failed_count == 0
|
||||||
|
&& *pending_count == 0
|
||||||
|
&& *missed_threshold_size == 0
|
||||||
|
&& *after_threshold_size == 0
|
||||||
|
&& *missed_threshold_count == 0
|
||||||
|
&& *after_threshold_count == 0
|
||||||
|
&& *replicated_count == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[deprecated(note = "use is_empty instead")]
|
||||||
pub fn empty(&self) -> bool {
|
pub fn empty(&self) -> bool {
|
||||||
self.replicated_size == 0 && self.failed_size == 0 && self.failed_count == 0
|
self.is_empty()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,16 +545,19 @@ pub struct ReplicationAllStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReplicationAllStats {
|
impl ReplicationAllStats {
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
let Self {
|
||||||
|
replica_size,
|
||||||
|
replica_count,
|
||||||
|
targets,
|
||||||
|
} = self;
|
||||||
|
|
||||||
|
*replica_size == 0 && *replica_count == 0 && targets.values().all(ReplicationStats::is_empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[deprecated(note = "use is_empty instead")]
|
||||||
pub fn empty(&self) -> bool {
|
pub fn empty(&self) -> bool {
|
||||||
if self.replica_size != 0 && self.replica_count != 0 {
|
self.is_empty()
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for v in self.targets.values() {
|
|
||||||
if !v.empty() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,13 +635,74 @@ impl DataUsageEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i, v) in other.obj_sizes.0.iter().enumerate() {
|
self.obj_sizes.merge_from(&other.obj_sizes);
|
||||||
self.obj_sizes.0[i] += v;
|
self.obj_versions.merge_from(&other.obj_versions);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (i, v) in other.obj_versions.0.iter().enumerate() {
|
pub fn checked_merge(&mut self, other: &DataUsageEntry) -> bool {
|
||||||
self.obj_versions.0[i] += v;
|
let scalar_counts_fit = self.objects.checked_add(other.objects).is_some()
|
||||||
|
&& self.versions.checked_add(other.versions).is_some()
|
||||||
|
&& self.delete_markers.checked_add(other.delete_markers).is_some()
|
||||||
|
&& self.size.checked_add(other.size).is_some()
|
||||||
|
&& self.failed_objects.checked_add(other.failed_objects).is_some();
|
||||||
|
let histograms_fit = self.obj_sizes.0.len() == SIZE_HISTOGRAM_LEN
|
||||||
|
&& other.obj_sizes.0.len() == SIZE_HISTOGRAM_LEN
|
||||||
|
&& self.obj_versions.0.len() == VERSIONS_HISTOGRAM_LEN
|
||||||
|
&& other.obj_versions.0.len() == VERSIONS_HISTOGRAM_LEN
|
||||||
|
&& self
|
||||||
|
.obj_sizes
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.zip(other.obj_sizes.0.iter())
|
||||||
|
.all(|(left, right)| left.checked_add(*right).is_some())
|
||||||
|
&& self
|
||||||
|
.obj_versions
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.zip(other.obj_versions.0.iter())
|
||||||
|
.all(|(left, right)| left.checked_add(*right).is_some());
|
||||||
|
let replication_fits = match (&self.replication_stats, &other.replication_stats) {
|
||||||
|
(_, None) | (None, Some(_)) => true,
|
||||||
|
(Some(left), Some(right)) => {
|
||||||
|
left.replica_size.checked_add(right.replica_size).is_some()
|
||||||
|
&& left.replica_count.checked_add(right.replica_count).is_some()
|
||||||
|
&& right.targets.iter().all(|(target, right_stats)| {
|
||||||
|
left.targets.get(target).is_none_or(|left_stats| {
|
||||||
|
left_stats.pending_size.checked_add(right_stats.pending_size).is_some()
|
||||||
|
&& left_stats.replicated_size.checked_add(right_stats.replicated_size).is_some()
|
||||||
|
&& left_stats.failed_size.checked_add(right_stats.failed_size).is_some()
|
||||||
|
&& left_stats.failed_count.checked_add(right_stats.failed_count).is_some()
|
||||||
|
&& left_stats.pending_count.checked_add(right_stats.pending_count).is_some()
|
||||||
|
&& left_stats
|
||||||
|
.missed_threshold_size
|
||||||
|
.checked_add(right_stats.missed_threshold_size)
|
||||||
|
.is_some()
|
||||||
|
&& left_stats
|
||||||
|
.after_threshold_size
|
||||||
|
.checked_add(right_stats.after_threshold_size)
|
||||||
|
.is_some()
|
||||||
|
&& left_stats
|
||||||
|
.missed_threshold_count
|
||||||
|
.checked_add(right_stats.missed_threshold_count)
|
||||||
|
.is_some()
|
||||||
|
&& left_stats
|
||||||
|
.after_threshold_count
|
||||||
|
.checked_add(right_stats.after_threshold_count)
|
||||||
|
.is_some()
|
||||||
|
&& left_stats
|
||||||
|
.replicated_count
|
||||||
|
.checked_add(right_stats.replicated_count)
|
||||||
|
.is_some()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !scalar_counts_fit || !histograms_fit || !replication_fits {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
self.merge(other);
|
||||||
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,6 +715,12 @@ pub struct DataUsageCacheInfo {
|
|||||||
pub skip_healing: bool,
|
pub skip_healing: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub failed_objects: HashMap<String, u64>,
|
pub failed_objects: HashMap<String, u64>,
|
||||||
|
/// Whether this per-set cache was produced by a completed scanner pass.
|
||||||
|
///
|
||||||
|
/// Older cache writers omit this field and therefore deserialize as
|
||||||
|
/// incomplete instead of exposing partial set totals as confirmed zeros.
|
||||||
|
#[serde(default)]
|
||||||
|
pub snapshot_complete: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Data usage cache
|
/// Data usage cache
|
||||||
@@ -626,7 +811,7 @@ impl DataUsageCache {
|
|||||||
return Some(root);
|
return Some(root);
|
||||||
}
|
}
|
||||||
let mut flat = self.flatten(&root);
|
let mut flat = self.flatten(&root);
|
||||||
if flat.replication_stats.as_ref().is_some_and(|stats| stats.empty()) {
|
if flat.replication_stats.as_ref().is_some_and(ReplicationAllStats::is_empty) {
|
||||||
flat.replication_stats = None;
|
flat.replication_stats = None;
|
||||||
}
|
}
|
||||||
Some(flat)
|
Some(flat)
|
||||||
@@ -855,6 +1040,7 @@ impl DataUsageCache {
|
|||||||
objects_total_size: flat.size as u64,
|
objects_total_size: flat.size as u64,
|
||||||
buckets_count: u64::try_from(buckets.len()).unwrap_or(u64::MAX),
|
buckets_count: u64::try_from(buckets.len()).unwrap_or(u64::MAX),
|
||||||
buckets_usage,
|
buckets_usage,
|
||||||
|
usage_snapshot_complete: self.info.snapshot_complete,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -922,9 +1108,39 @@ fn mark(duc: &DataUsageCache, entry: &DataUsageEntry, found: &mut HashSet<String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hash a path for data usage caching
|
fn clean_data_usage_path(data: &str) -> String {
|
||||||
|
let rooted = data.starts_with('/');
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
|
||||||
|
for part in data.split('/') {
|
||||||
|
match part {
|
||||||
|
"" | "." => {}
|
||||||
|
".." => {
|
||||||
|
if parts.last().is_some_and(|last| *last != "..") {
|
||||||
|
parts.pop();
|
||||||
|
} else if !rooted {
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => parts.push(part),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let clean = parts.join("/");
|
||||||
|
match (rooted, clean.is_empty()) {
|
||||||
|
(true, true) => "/".to_string(),
|
||||||
|
(true, false) => format!("/{clean}"),
|
||||||
|
(false, true) => ".".to_string(),
|
||||||
|
(false, false) => clean,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash a slash-separated path for data usage caching.
|
||||||
|
///
|
||||||
|
/// Cache identifiers are persisted and exchanged across nodes, so their
|
||||||
|
/// normalization must not depend on the host operating system.
|
||||||
pub fn hash_path(data: &str) -> DataUsageHash {
|
pub fn hash_path(data: &str) -> DataUsageHash {
|
||||||
DataUsageHash(Path::new(&data).clean().to_string_lossy().to_string())
|
DataUsageHash(clean_data_usage_path(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DataUsageInfo {
|
impl DataUsageInfo {
|
||||||
@@ -933,6 +1149,13 @@ impl DataUsageInfo {
|
|||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this snapshot authoritatively covers every reported bucket.
|
||||||
|
pub fn is_complete_bucket_usage_snapshot(&self) -> bool {
|
||||||
|
self.usage_snapshot_complete
|
||||||
|
&& self.last_update.is_some()
|
||||||
|
&& u64::try_from(self.buckets_usage.len()).ok() == Some(self.buckets_count)
|
||||||
|
}
|
||||||
|
|
||||||
/// Add object metadata to data usage statistics
|
/// Add object metadata to data usage statistics
|
||||||
pub fn add_object(&mut self, object_path: &str, meta_object: &rustfs_filemeta::MetaObject) {
|
pub fn add_object(&mut self, object_path: &str, meta_object: &rustfs_filemeta::MetaObject) {
|
||||||
// This method is kept for backward compatibility
|
// This method is kept for backward compatibility
|
||||||
@@ -1297,6 +1520,68 @@ pub struct CompressionTotalInfo {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LegacyUsageReader {
|
||||||
|
buckets_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hash_path_uses_portable_slash_semantics() {
|
||||||
|
for (input, expected) in [
|
||||||
|
("", "."),
|
||||||
|
(".", "."),
|
||||||
|
("/", "/"),
|
||||||
|
("//bucket///prefix/", "/bucket/prefix"),
|
||||||
|
("bucket/./prefix//object", "bucket/prefix/object"),
|
||||||
|
("bucket/a/../b", "bucket/b"),
|
||||||
|
("../bucket/..", ".."),
|
||||||
|
("/../../bucket", "/bucket"),
|
||||||
|
("bucket\\prefix/object", "bucket\\prefix/object"),
|
||||||
|
] {
|
||||||
|
assert_eq!(hash_path(input).key(), expected, "unexpected portable cache key for {input:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completeness_marker_is_additive_for_legacy_named_readers() {
|
||||||
|
let current = DataUsageInfo {
|
||||||
|
last_update: Some(SystemTime::UNIX_EPOCH),
|
||||||
|
usage_snapshot_complete: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let encoded = rmp_serde::to_vec_named(¤t).expect("encode current data usage snapshot");
|
||||||
|
let legacy: LegacyUsageReader = rmp_serde::from_slice(&encoded).expect("legacy reader should ignore additive fields");
|
||||||
|
|
||||||
|
assert_eq!(legacy.buckets_count, 0);
|
||||||
|
assert!(current.is_complete_bucket_usage_snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completeness_marker_requires_a_snapshot_timestamp() {
|
||||||
|
let untimestamped = DataUsageInfo {
|
||||||
|
usage_snapshot_complete: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!untimestamped.is_complete_bucket_usage_snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_usage_last_update_future_tolerance_boundary() {
|
||||||
|
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
|
||||||
|
|
||||||
|
// Within tolerance (including the exact boundary) the timestamp is trusted.
|
||||||
|
assert!(!usage_last_update_is_untrusted_future(now, now));
|
||||||
|
assert!(!usage_last_update_is_untrusted_future(now - Duration::from_secs(60), now));
|
||||||
|
assert!(!usage_last_update_is_untrusted_future(now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE, now));
|
||||||
|
|
||||||
|
// Beyond tolerance the persisted timestamp is untrustworthy.
|
||||||
|
assert!(usage_last_update_is_untrusted_future(
|
||||||
|
now + USAGE_LAST_UPDATE_FUTURE_TOLERANCE + Duration::from_secs(1),
|
||||||
|
now
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_data_usage_info_creation() {
|
fn test_data_usage_info_creation() {
|
||||||
let mut info = DataUsageInfo::new();
|
let mut info = DataUsageInfo::new();
|
||||||
@@ -1361,6 +1646,180 @@ mod tests {
|
|||||||
assert_eq!(map["BETWEEN_512_KB_AND_1_MB"], 1);
|
assert_eq!(map["BETWEEN_512_KB_AND_1_MB"], 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_histogram_classifies_adjacent_boundaries_once() {
|
||||||
|
let cases = [
|
||||||
|
(1023, 0),
|
||||||
|
(1024, 1),
|
||||||
|
(64 * 1024 - 1, 1),
|
||||||
|
(64 * 1024, 2),
|
||||||
|
(256 * 1024 - 1, 2),
|
||||||
|
(256 * 1024, 3),
|
||||||
|
(512 * 1024 - 1, 3),
|
||||||
|
(512 * 1024, 4),
|
||||||
|
(1024 * 1024 - 1, 4),
|
||||||
|
(1024 * 1024, 6),
|
||||||
|
(10 * 1024 * 1024 - 1, 6),
|
||||||
|
(10 * 1024 * 1024, 7),
|
||||||
|
(64 * 1024 * 1024 - 1, 7),
|
||||||
|
(64 * 1024 * 1024, 8),
|
||||||
|
(128 * 1024 * 1024 - 1, 8),
|
||||||
|
(128 * 1024 * 1024, 9),
|
||||||
|
(512 * 1024 * 1024 - 1, 9),
|
||||||
|
(512 * 1024 * 1024, 10),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (size, expected_bucket) in cases {
|
||||||
|
let mut hist = SizeHistogram::default();
|
||||||
|
hist.add(size);
|
||||||
|
|
||||||
|
assert_eq!(hist.0.iter().sum::<u64>(), 1, "size {size} must have exactly one physical bucket");
|
||||||
|
assert_eq!(hist.0[expected_bucket], 1, "size {size} must select the expected bucket");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_histogram_1024_bytes_contributes_to_compat_rollup() {
|
||||||
|
let mut hist = SizeHistogram::default();
|
||||||
|
hist.add(1024);
|
||||||
|
|
||||||
|
let map = hist.to_map();
|
||||||
|
assert_eq!(map["LESS_THAN_1024_B"], 0);
|
||||||
|
assert_eq!(map["BETWEEN_1024_B_AND_64_KB"], 1);
|
||||||
|
assert_eq!(map["BETWEEN_1024B_AND_1_MB"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_size_histogram_compat_rollup_saturates_on_corrupt_counts() {
|
||||||
|
let mut hist = SizeHistogram::default();
|
||||||
|
hist.0[1] = u64::MAX;
|
||||||
|
hist.0[2] = 1;
|
||||||
|
|
||||||
|
let map = hist.to_map();
|
||||||
|
|
||||||
|
assert_eq!(map["BETWEEN_1024B_AND_1_MB"], u64::MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replication_stats_empty_checks_every_field() {
|
||||||
|
type SetField = fn(&mut ReplicationStats);
|
||||||
|
|
||||||
|
let cases: [(&str, SetField); 10] = [
|
||||||
|
("pending_size", |stats| stats.pending_size = 1),
|
||||||
|
("replicated_size", |stats| stats.replicated_size = 1),
|
||||||
|
("failed_size", |stats| stats.failed_size = 1),
|
||||||
|
("failed_count", |stats| stats.failed_count = 1),
|
||||||
|
("pending_count", |stats| stats.pending_count = 1),
|
||||||
|
("missed_threshold_size", |stats| stats.missed_threshold_size = 1),
|
||||||
|
("after_threshold_size", |stats| stats.after_threshold_size = 1),
|
||||||
|
("missed_threshold_count", |stats| stats.missed_threshold_count = 1),
|
||||||
|
("after_threshold_count", |stats| stats.after_threshold_count = 1),
|
||||||
|
("replicated_count", |stats| stats.replicated_count = 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(ReplicationStats::default().is_empty());
|
||||||
|
for (field, set_nonzero) in cases {
|
||||||
|
let mut stats = ReplicationStats::default();
|
||||||
|
set_nonzero(&mut stats);
|
||||||
|
assert!(!stats.is_empty(), "{field} must make replication stats non-empty");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replication_all_stats_empty_checks_aggregate_fields_independently() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
"replica_size",
|
||||||
|
ReplicationAllStats {
|
||||||
|
replica_size: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"replica_count",
|
||||||
|
ReplicationAllStats {
|
||||||
|
replica_count: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(ReplicationAllStats::default().is_empty());
|
||||||
|
for (field, stats) in cases {
|
||||||
|
assert!(!stats.is_empty(), "{field} must make aggregate replication stats non-empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let empty_targets = ReplicationAllStats {
|
||||||
|
targets: HashMap::from([("arn:test:empty".to_string(), ReplicationStats::default())]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(empty_targets.is_empty(), "all-empty targets must keep aggregate stats empty");
|
||||||
|
|
||||||
|
let stats = ReplicationAllStats {
|
||||||
|
targets: HashMap::from([
|
||||||
|
("arn:test:empty".to_string(), ReplicationStats::default()),
|
||||||
|
(
|
||||||
|
"arn:test:non-empty".to_string(),
|
||||||
|
ReplicationStats {
|
||||||
|
pending_count: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!stats.is_empty(), "a non-empty target must make aggregate replication stats non-empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn size_recursive_prunes_empty_and_preserves_pending_replication_stats() {
|
||||||
|
let root = hash_path("bucket");
|
||||||
|
let child = hash_path("bucket/child");
|
||||||
|
let mut cache = DataUsageCache::default();
|
||||||
|
cache.replace_hashed(&root, &None, &DataUsageEntry::default());
|
||||||
|
cache.replace_hashed(
|
||||||
|
&child,
|
||||||
|
&Some(root.clone()),
|
||||||
|
&DataUsageEntry {
|
||||||
|
replication_stats: Some(ReplicationAllStats::default()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
cache
|
||||||
|
.size_recursive("bucket")
|
||||||
|
.expect("bucket usage should flatten")
|
||||||
|
.replication_stats
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
|
||||||
|
cache.replace_hashed(
|
||||||
|
&child,
|
||||||
|
&Some(root.clone()),
|
||||||
|
&DataUsageEntry {
|
||||||
|
replication_stats: Some(ReplicationAllStats {
|
||||||
|
targets: HashMap::from([(
|
||||||
|
"arn:test:pending".to_string(),
|
||||||
|
ReplicationStats {
|
||||||
|
pending_count: 1,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)]),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let flattened = cache.size_recursive("bucket").expect("bucket usage should flatten");
|
||||||
|
let replication = flattened
|
||||||
|
.replication_stats
|
||||||
|
.expect("pending-only replication stats must survive pruning");
|
||||||
|
|
||||||
|
assert_eq!(replication.targets["arn:test:pending"].pending_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_data_usage_cache_merge_adds_missing_child() {
|
fn test_data_usage_cache_merge_adds_missing_child() {
|
||||||
let mut base = DataUsageCache::default();
|
let mut base = DataUsageCache::default();
|
||||||
@@ -1674,4 +2133,86 @@ mod tests {
|
|||||||
assert!(cache.find("bucket/large/a").is_some());
|
assert!(cache.find("bucket/large/a").is_some());
|
||||||
assert!(cache.find("bucket/large/b").is_some());
|
assert!(cache.find("bucket/large/b").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checked_merge_rejects_scalar_and_replication_overflow_without_mutation() {
|
||||||
|
let mut entry = DataUsageEntry {
|
||||||
|
objects: usize::MAX,
|
||||||
|
replication_stats: Some(ReplicationAllStats {
|
||||||
|
replica_size: 7,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let other = DataUsageEntry {
|
||||||
|
objects: 1,
|
||||||
|
replication_stats: Some(ReplicationAllStats {
|
||||||
|
replica_size: u64::MAX,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!entry.checked_merge(&other));
|
||||||
|
assert_eq!(entry.objects, usize::MAX);
|
||||||
|
assert_eq!(entry.replication_stats.as_ref().map(|stats| stats.replica_size), Some(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checked_merge_accepts_valid_usage() {
|
||||||
|
let mut entry = DataUsageEntry {
|
||||||
|
objects: 2,
|
||||||
|
size: 20,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let other = DataUsageEntry {
|
||||||
|
objects: 3,
|
||||||
|
size: 30,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(entry.checked_merge(&other));
|
||||||
|
assert_eq!(entry.objects, 5);
|
||||||
|
assert_eq!(entry.size, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn histogram_deserialization_rejects_noncanonical_lengths() {
|
||||||
|
let invalid_sizes =
|
||||||
|
rmp_serde::to_vec(&vec![0_u64; SIZE_HISTOGRAM_LEN + 1]).expect("encode invalid object-size histogram fixture");
|
||||||
|
let invalid_versions =
|
||||||
|
rmp_serde::to_vec(&vec![0_u64; VERSIONS_HISTOGRAM_LEN - 1]).expect("encode invalid object-version histogram fixture");
|
||||||
|
|
||||||
|
assert!(rmp_serde::from_slice::<SizeHistogram>(&invalid_sizes).is_err());
|
||||||
|
assert!(rmp_serde::from_slice::<VersionsHistogram>(&invalid_versions).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replication_target_deserialization_preserves_large_historical_maps() {
|
||||||
|
let mut stats = ReplicationAllStats::default();
|
||||||
|
for index in 0..=1024 {
|
||||||
|
stats.targets.insert(format!("target-{index}"), ReplicationStats::default());
|
||||||
|
}
|
||||||
|
let encoded = rmp_serde::to_vec_named(&stats).expect("large replication target fixture should encode");
|
||||||
|
let decoded = rmp_serde::from_slice::<ReplicationAllStats>(&encoded)
|
||||||
|
.expect("historical replication target maps must remain readable");
|
||||||
|
|
||||||
|
assert_eq!(decoded.targets.len(), stats.targets.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checked_merge_rejects_noncanonical_histograms_without_mutation() {
|
||||||
|
let mut entry = DataUsageEntry {
|
||||||
|
objects: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let other = DataUsageEntry {
|
||||||
|
objects: 3,
|
||||||
|
obj_sizes: SizeHistogram(vec![0; SIZE_HISTOGRAM_LEN + 1]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!entry.checked_merge(&other));
|
||||||
|
assert_eq!(entry.objects, 2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,64 @@ workspace = true
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
|
hotpath = [
|
||||||
|
"hotpath/hotpath",
|
||||||
|
"hotpath/tokio",
|
||||||
|
"hotpath/futures",
|
||||||
|
"hotpath/reqwest-0-13",
|
||||||
|
"rustfs-config/hotpath",
|
||||||
|
"rustfs-credentials/hotpath",
|
||||||
|
"rustfs-data-usage/hotpath",
|
||||||
|
"rustfs-ecstore/hotpath",
|
||||||
|
"rustfs-filemeta/hotpath",
|
||||||
|
"rustfs-lock/hotpath",
|
||||||
|
"rustfs-madmin/hotpath",
|
||||||
|
"rustfs-protos/hotpath",
|
||||||
|
"rustfs-rio/hotpath",
|
||||||
|
"rustfs-signer/hotpath",
|
||||||
|
"rustfs-utils/hotpath",
|
||||||
|
]
|
||||||
|
hotpath-alloc = [
|
||||||
|
"hotpath",
|
||||||
|
"hotpath/hotpath-alloc",
|
||||||
|
"rustfs-config/hotpath-alloc",
|
||||||
|
"rustfs-credentials/hotpath-alloc",
|
||||||
|
"rustfs-data-usage/hotpath-alloc",
|
||||||
|
"rustfs-ecstore/hotpath-alloc",
|
||||||
|
"rustfs-filemeta/hotpath-alloc",
|
||||||
|
"rustfs-lock/hotpath-alloc",
|
||||||
|
"rustfs-madmin/hotpath-alloc",
|
||||||
|
"rustfs-protos/hotpath-alloc",
|
||||||
|
"rustfs-rio/hotpath-alloc",
|
||||||
|
"rustfs-signer/hotpath-alloc",
|
||||||
|
"rustfs-utils/hotpath-alloc",
|
||||||
|
]
|
||||||
|
hotpath-cpu = [
|
||||||
|
"hotpath",
|
||||||
|
"hotpath/hotpath-cpu",
|
||||||
|
"rustfs-config/hotpath-cpu",
|
||||||
|
"rustfs-credentials/hotpath-cpu",
|
||||||
|
"rustfs-data-usage/hotpath-cpu",
|
||||||
|
"rustfs-ecstore/hotpath-cpu",
|
||||||
|
"rustfs-filemeta/hotpath-cpu",
|
||||||
|
"rustfs-lock/hotpath-cpu",
|
||||||
|
"rustfs-madmin/hotpath-cpu",
|
||||||
|
"rustfs-protos/hotpath-cpu",
|
||||||
|
"rustfs-rio/hotpath-cpu",
|
||||||
|
"rustfs-signer/hotpath-cpu",
|
||||||
|
"rustfs-utils/hotpath-cpu",
|
||||||
|
]
|
||||||
ftps = []
|
ftps = []
|
||||||
sftp = []
|
sftp = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
hotpath.workspace = true
|
||||||
rustfs-config = { workspace = true, features = ["constants"] }
|
rustfs-config = { workspace = true, features = ["constants"] }
|
||||||
|
rustfs-credentials.workspace = true
|
||||||
rustfs-ecstore.workspace = true
|
rustfs-ecstore.workspace = true
|
||||||
rustfs-data-usage.workspace = true
|
rustfs-data-usage.workspace = true
|
||||||
rustfs-rio.workspace = true
|
rustfs-rio.workspace = true
|
||||||
|
rustfs-utils = { workspace = true, features = ["egress"] }
|
||||||
flatbuffers.workspace = true
|
flatbuffers.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
rustfs-lock.workspace = true
|
rustfs-lock.workspace = true
|
||||||
@@ -48,6 +98,7 @@ rustfs-filemeta.workspace = true
|
|||||||
bytes = { workspace = true, features = ["serde"] }
|
bytes = { workspace = true, features = ["serde"] }
|
||||||
serial_test = { workspace = true }
|
serial_test = { workspace = true }
|
||||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "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"] }
|
async-compression = { workspace = true, features = ["tokio", "bzip2", "xz"] }
|
||||||
@@ -57,7 +108,7 @@ http.workspace = true
|
|||||||
http-body-util.workspace = true
|
http-body-util.workspace = true
|
||||||
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
||||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||||
reqwest = { workspace = true, default-features = false, features = ["rustls", "charset", "http2", "system-proxy", "stream", "json", "multipart"] }
|
reqwest = { workspace = true, features = ["json", "multipart", "stream"] }
|
||||||
rustfs-signer.workspace = true
|
rustfs-signer.workspace = true
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
|
tracing-subscriber = { workspace = true, features = ["env-filter", "time"] }
|
||||||
@@ -67,7 +118,10 @@ walkdir.workspace = true
|
|||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
rand = { workspace = true, features = ["serde"] }
|
rand = { workspace = true, features = ["serde"] }
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
md5 = { workspace = true }
|
hex = { workspace = true }
|
||||||
|
md-5 = { workspace = true }
|
||||||
|
opentelemetry-proto = { workspace = true }
|
||||||
|
prost.workspace = true
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
astral-tokio-tar = { workspace = true }
|
astral-tokio-tar = { workspace = true }
|
||||||
s3s = { workspace = true, features = ["minio"] }
|
s3s = { workspace = true, features = ["minio"] }
|
||||||
|
|||||||
@@ -46,6 +46,45 @@ mod tests {
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info";
|
const ADMIN_INFO_PATH: &str = "/rustfs/admin/v3/info";
|
||||||
|
const ADMIN_MANUAL_TRANSITION_BUCKET: &str = "auth-deny-manual-transition";
|
||||||
|
const ADMIN_MANUAL_TRANSITION_PATH: &str =
|
||||||
|
"/rustfs/admin/v3/ilm/transition/run?bucket=auth-deny-manual-transition&maxObjects=1&mode=async";
|
||||||
|
|
||||||
|
fn assert_no_raw_manual_transition_markers(body: &str, context: &str) {
|
||||||
|
assert!(
|
||||||
|
!body.contains("\"marker\"") && !body.contains("\"versionMarker\"") && !body.contains("\"version_marker\""),
|
||||||
|
"{context} must not expose raw manual transition resume markers, body: {body}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_terminal_manual_transition_job(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
status_endpoint: &str,
|
||||||
|
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
loop {
|
||||||
|
let (status, body) =
|
||||||
|
signed_request(&env.url, http::Method::GET, status_endpoint, None, &env.access_key, &env.secret_key).await?;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
reqwest::StatusCode::OK,
|
||||||
|
"root credential must query manual transition job status, body: {body}"
|
||||||
|
);
|
||||||
|
assert_no_raw_manual_transition_markers(&body, "manual transition status response");
|
||||||
|
let value: serde_json::Value = serde_json::from_str(&body)?;
|
||||||
|
let job_status = value
|
||||||
|
.get("status")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.ok_or("manual transition job status response must include status")?;
|
||||||
|
if matches!(job_status, "completed" | "partial" | "cancelled" | "failed" | "unknown") {
|
||||||
|
return Ok(body);
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(format!("manual transition job did not reach terminal status within 30s; last={body}").into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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)`. Uses the `UNSIGNED_PAYLOAD` content hash so a
|
||||||
@@ -158,6 +197,130 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial]
|
||||||
|
async fn non_admin_credential_denied_on_manual_transition_run() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server(vec![]).await?;
|
||||||
|
|
||||||
|
let user_ak = "ilmtransitionlimited";
|
||||||
|
let user_sk = "ilmtransitionlimitedsecret";
|
||||||
|
create_limited_user(&env, user_ak, user_sk).await?;
|
||||||
|
env.create_s3_client()
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(ADMIN_MANUAL_TRANSITION_BUCKET)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (root_status, root_body) = signed_request(
|
||||||
|
&env.url,
|
||||||
|
http::Method::POST,
|
||||||
|
ADMIN_MANUAL_TRANSITION_PATH,
|
||||||
|
None,
|
||||||
|
&env.access_key,
|
||||||
|
&env.secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
root_status,
|
||||||
|
reqwest::StatusCode::ACCEPTED,
|
||||||
|
"root credential must reach the manual transition handler, body: {root_body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
root_body.contains("\"mode\":\"durable_job\""),
|
||||||
|
"root response should be the durable manual transition JSON contract, body: {root_body}"
|
||||||
|
);
|
||||||
|
assert_no_raw_manual_transition_markers(&root_body, "manual transition run response");
|
||||||
|
let root_value: serde_json::Value = serde_json::from_str(&root_body)?;
|
||||||
|
let job_id = root_value
|
||||||
|
.get("job_id")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.ok_or("manual transition async response must include job_id")?;
|
||||||
|
let status_endpoint = root_value
|
||||||
|
.get("status_endpoint")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.ok_or("manual transition async response must include status_endpoint")?;
|
||||||
|
let cancel_endpoint = root_value
|
||||||
|
.get("cancel_endpoint")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.ok_or("manual transition async response must include cancel_endpoint")?;
|
||||||
|
assert_eq!(
|
||||||
|
cancel_endpoint, status_endpoint,
|
||||||
|
"manual transition durable jobs currently use the same status/cancel endpoint"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
status_endpoint.ends_with(job_id),
|
||||||
|
"status endpoint must address the returned job id, job_id={job_id}, status_endpoint={status_endpoint}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let terminal_body = wait_for_terminal_manual_transition_job(&env, status_endpoint).await?;
|
||||||
|
let terminal: serde_json::Value = serde_json::from_str(&terminal_body)?;
|
||||||
|
assert_eq!(terminal.get("job_id").and_then(serde_json::Value::as_str), Some(job_id));
|
||||||
|
assert_eq!(
|
||||||
|
terminal
|
||||||
|
.get("report")
|
||||||
|
.and_then(|report| report.get("bucket"))
|
||||||
|
.and_then(serde_json::Value::as_str),
|
||||||
|
Some(ADMIN_MANUAL_TRANSITION_BUCKET)
|
||||||
|
);
|
||||||
|
|
||||||
|
let (root_status, root_body) =
|
||||||
|
signed_request(&env.url, http::Method::DELETE, status_endpoint, None, &env.access_key, &env.secret_key).await?;
|
||||||
|
assert_eq!(
|
||||||
|
root_status,
|
||||||
|
reqwest::StatusCode::OK,
|
||||||
|
"root credential must cancel/query a terminal manual transition job idempotently, body: {root_body}"
|
||||||
|
);
|
||||||
|
assert_no_raw_manual_transition_markers(&root_body, "manual transition cancel response");
|
||||||
|
let root_cancel: serde_json::Value = serde_json::from_str(&root_body)?;
|
||||||
|
assert_eq!(root_cancel.get("job_id").and_then(serde_json::Value::as_str), Some(job_id));
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
root_cancel.get("status").and_then(serde_json::Value::as_str),
|
||||||
|
Some("completed" | "partial" | "failed" | "unknown")
|
||||||
|
),
|
||||||
|
"terminal cancel must not rewrite the job into cancelled state, body: {root_body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let (status, body) =
|
||||||
|
signed_request(&env.url, http::Method::POST, ADMIN_MANUAL_TRANSITION_PATH, None, user_ak, user_sk).await?;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"non-admin credential must get 403 on manual transition run, body: {body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("AccessDenied"),
|
||||||
|
"manual transition rejection must carry the AccessDenied S3 error code, body: {body}"
|
||||||
|
);
|
||||||
|
let (status, body) = signed_request(&env.url, http::Method::GET, status_endpoint, None, user_ak, user_sk).await?;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"non-admin credential must get 403 on manual transition status, body: {body}"
|
||||||
|
);
|
||||||
|
assert_no_raw_manual_transition_markers(&body, "manual transition status rejection");
|
||||||
|
assert!(
|
||||||
|
body.contains("AccessDenied"),
|
||||||
|
"manual transition status rejection must carry the AccessDenied S3 error code, body: {body}"
|
||||||
|
);
|
||||||
|
let (status, body) = signed_request(&env.url, http::Method::DELETE, status_endpoint, None, user_ak, user_sk).await?;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"non-admin credential must get 403 on manual transition cancel, body: {body}"
|
||||||
|
);
|
||||||
|
assert_no_raw_manual_transition_markers(&body, "manual transition cancel rejection");
|
||||||
|
assert!(
|
||||||
|
body.contains("AccessDenied"),
|
||||||
|
"manual transition cancel rejection must carry the AccessDenied S3 error code, body: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Rotating the root credentials (restart with new `--access-key` /
|
/// Rotating the root credentials (restart with new `--access-key` /
|
||||||
/// `--secret-key` on the same data directory) takes effect: the new
|
/// `--secret-key` on the same data directory) takes effect: the new
|
||||||
/// credential is accepted and the old one is rejected, on both the S3 data
|
/// credential is accepted and the old one is rejected, on both the S3 data
|
||||||
|
|||||||
@@ -26,75 +26,16 @@
|
|||||||
//! Later batches tracked on backlog#1154: config get/set, info, pools status,
|
//! Later batches tracked on backlog#1154: config get/set, info, pools status,
|
||||||
//! group lifecycle, import/export IAM.
|
//! group lifecycle, import/export IAM.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, admin_ok, admin_request, init_logging};
|
||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::config::{Credentials, Region};
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use http::header::{CONTENT_TYPE, HOST};
|
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
|
||||||
use rustfs_signer::sign_v4;
|
|
||||||
use s3s::Body;
|
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use tokio::time::{Duration, sleep};
|
use tokio::time::{Duration, sleep};
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||||
type BoxError = Box<dyn Error + Send + Sync>;
|
|
||||||
|
|
||||||
/// Signs and sends an admin HTTP request with the given credential, returning
|
|
||||||
/// status and body. Native `/rustfs/admin/v3` requests and responses are plain
|
|
||||||
/// JSON (the MinIO-compat encryption applies only to `/minio/admin/v3` paths).
|
|
||||||
async fn admin_request(
|
|
||||||
base_url: &str,
|
|
||||||
method: http::Method,
|
|
||||||
path_and_query: &str,
|
|
||||||
body: Option<String>,
|
|
||||||
access_key: &str,
|
|
||||||
secret_key: &str,
|
|
||||||
) -> Result<(StatusCode, String), BoxError> {
|
|
||||||
let url = format!("{base_url}{path_and_query}");
|
|
||||||
let uri = url.parse::<http::Uri>()?;
|
|
||||||
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
|
||||||
let mut builder = http::Request::builder()
|
|
||||||
.method(method.clone())
|
|
||||||
.uri(uri)
|
|
||||||
.header(HOST, authority)
|
|
||||||
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
|
||||||
if body.is_some() {
|
|
||||||
builder = builder.header(CONTENT_TYPE, "application/json");
|
|
||||||
}
|
|
||||||
|
|
||||||
let content_len = body.as_ref().map(|b| b.len() as i64).unwrap_or_default();
|
|
||||||
let signed = sign_v4(builder.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
|
|
||||||
|
|
||||||
let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?;
|
|
||||||
let mut request = local_http_client().request(reqwest_method, &url);
|
|
||||||
for (name, value) in signed.headers() {
|
|
||||||
request = request.header(name, value);
|
|
||||||
}
|
|
||||||
if let Some(body) = body {
|
|
||||||
request = request.body(body);
|
|
||||||
}
|
|
||||||
let response = request.send().await?;
|
|
||||||
let status = response.status();
|
|
||||||
let text = response.text().await.unwrap_or_default();
|
|
||||||
Ok((status, text))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Root-credential admin request that must succeed; returns the response body.
|
|
||||||
async fn admin_ok(
|
|
||||||
env: &RustFSTestEnvironment,
|
|
||||||
method: http::Method,
|
|
||||||
path_and_query: &str,
|
|
||||||
body: Option<String>,
|
|
||||||
) -> Result<String, BoxError> {
|
|
||||||
let (status, text) = admin_request(&env.url, method.clone(), path_and_query, body, &env.access_key, &env.secret_key).await?;
|
|
||||||
if !status.is_success() {
|
|
||||||
return Err(format!("{method} {path_and_query} failed: {status} {text}").into());
|
|
||||||
}
|
|
||||||
Ok(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_s3_client(url: &str, access_key: &str, secret_key: &str) -> Client {
|
fn build_s3_client(url: &str, access_key: &str, secret_key: &str) -> Client {
|
||||||
let config = Config::builder()
|
let config = Config::builder()
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// 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 crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
|
use http::header::HOST;
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
|
use rustfs_signer::sign_v4;
|
||||||
|
use s3s::Body;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PoolListItem {
|
||||||
|
id: usize,
|
||||||
|
cmdline: String,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn signed_admin_get(env: &RustFSTestEnvironment, path: &str) -> Result<reqwest::Response, Box<dyn Error + Send + Sync>> {
|
||||||
|
let url = format!("{}{path}", env.url);
|
||||||
|
let uri = url.parse::<http::Uri>()?;
|
||||||
|
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
||||||
|
let request = http::Request::builder()
|
||||||
|
.method(http::Method::GET)
|
||||||
|
.uri(uri)
|
||||||
|
.header(HOST, authority)
|
||||||
|
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD)
|
||||||
|
.body(Body::empty())?;
|
||||||
|
let signed = sign_v4(request, 0, &env.access_key, &env.secret_key, "", "us-east-1");
|
||||||
|
|
||||||
|
let mut request = local_http_client().get(&url);
|
||||||
|
for (name, value) in signed.headers() {
|
||||||
|
request = request.header(name, value);
|
||||||
|
}
|
||||||
|
Ok(request.send().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn single_drive_pools_list_succeeds_without_enabling_decommission_status() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server(vec![]).await?;
|
||||||
|
|
||||||
|
let response = signed_admin_get(&env, "/rustfs/admin/v3/pools/list").await?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.bytes().await?;
|
||||||
|
|
||||||
|
assert_eq!(status, StatusCode::OK, "pools list failed: {}", String::from_utf8_lossy(&body));
|
||||||
|
let pools: Vec<PoolListItem> = serde_json::from_slice(&body)?;
|
||||||
|
assert_eq!(pools.len(), 1);
|
||||||
|
assert_eq!(pools[0].id, 0);
|
||||||
|
assert_eq!(pools[0].cmdline, env.temp_dir);
|
||||||
|
assert_eq!(pools[0].status, "active");
|
||||||
|
|
||||||
|
let response = signed_admin_get(&env, "/rustfs/admin/v3/decommission/status").await?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
|
"decommission status changed for a single pool: {body}"
|
||||||
|
);
|
||||||
|
assert!(body.contains("NotImplemented"), "unexpected decommission error body: {body}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -170,3 +170,81 @@ async fn test_anonymous_access_allowed_when_restrict_public_buckets_disabled()
|
|||||||
info!("Test passed: anonymous access allowed with RestrictPublicBuckets=false");
|
info!("Test passed: anonymous access allowed with RestrictPublicBuckets=false");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A policy granting anonymous `s3:ListBucket` also permits ListObjectVersions.
|
||||||
|
/// That grant must still be subject to RestrictPublicBuckets: the versions listing
|
||||||
|
/// reaches authorization through a fallback branch, and that branch has to apply the
|
||||||
|
/// same public-access gate as a direct grant.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn ghsa_x298_anonymous_list_object_versions_denied_when_restrict_public_buckets_enabled()
|
||||||
|
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
info!("Starting test: anonymous ListObjectVersions denied with RestrictPublicBuckets=true...");
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server(vec![]).await?;
|
||||||
|
|
||||||
|
let bucket_name = "anon-test-restrict-versions";
|
||||||
|
let admin_client = env.create_s3_client();
|
||||||
|
admin_client.create_bucket().bucket(bucket_name).send().await?;
|
||||||
|
|
||||||
|
let policy_json = serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Sid": "AllowAnonymousListBucket",
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": ["s3:ListBucket"],
|
||||||
|
"Resource": [format!("arn:aws:s3:::{}", bucket_name)]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
admin_client
|
||||||
|
.put_bucket_policy()
|
||||||
|
.bucket(bucket_name)
|
||||||
|
.policy(&policy_json)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
admin_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket_name)
|
||||||
|
.key("test.txt")
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"hello anonymous"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Without the public-access block the fallback grant is expected to work.
|
||||||
|
let versions_url = format!("{}/{}?versions=", env.url, bucket_name);
|
||||||
|
let resp = local_http_client().get(&versions_url).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status().as_u16(),
|
||||||
|
200,
|
||||||
|
"Anonymous ListObjectVersions should succeed via the s3:ListBucket grant"
|
||||||
|
);
|
||||||
|
|
||||||
|
admin_client
|
||||||
|
.put_public_access_block()
|
||||||
|
.bucket(bucket_name)
|
||||||
|
.public_access_block_configuration(
|
||||||
|
PublicAccessBlockConfiguration::builder()
|
||||||
|
.restrict_public_buckets(true)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let resp = local_http_client().get(&versions_url).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
resp.status().as_u16(),
|
||||||
|
403,
|
||||||
|
"Anonymous ListObjectVersions must be denied when RestrictPublicBuckets is true"
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("Test passed: anonymous ListObjectVersions denied with RestrictPublicBuckets=true");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,6 +86,52 @@ async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn api_rate_limit_bucket_dimension_throttles_per_bucket() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
// Bucket dimension only: the readiness-poll ListBuckets calls hit "/"
|
||||||
|
// (no bucket) and therefore do not consume any budget.
|
||||||
|
env.start_rustfs_server_with_env(
|
||||||
|
vec![],
|
||||||
|
&[
|
||||||
|
("RUSTFS_API_RATE_LIMIT_ENABLE", "true"),
|
||||||
|
("RUSTFS_API_RATE_LIMIT_BUCKET_RPM", "60"),
|
||||||
|
("RUSTFS_API_RATE_LIMIT_BUCKET_BURST", "5"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let client = local_http_client();
|
||||||
|
|
||||||
|
// Unauthenticated GETs are still counted arrivals (403, not 429, while
|
||||||
|
// within budget); the sixth rapid hit on the same bucket must throttle.
|
||||||
|
let mut throttled = false;
|
||||||
|
for i in 0..6 {
|
||||||
|
let response = client.get(format!("{}/hot-bucket/object-{i}", env.url)).send().await?;
|
||||||
|
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||||
|
throttled = true;
|
||||||
|
assert!(
|
||||||
|
response.headers().contains_key(reqwest::header::RETRY_AFTER),
|
||||||
|
"bucket-dimension 429 must carry Retry-After"
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(throttled, "6 rapid requests against burst 5 must trip the bucket dimension");
|
||||||
|
|
||||||
|
// A different bucket has its own budget.
|
||||||
|
let other = client.get(format!("{}/cold-bucket/object", env.url)).send().await?;
|
||||||
|
assert_ne!(
|
||||||
|
other.status(),
|
||||||
|
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"an unrelated bucket must not be throttled"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn api_rate_limit_stays_inert_by_default() -> TestResult {
|
async fn api_rate_limit_stays_inert_by_default() -> TestResult {
|
||||||
|
|||||||
@@ -24,9 +24,10 @@ mod tests {
|
|||||||
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
||||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
|
use md5::{Digest as Md5Digest, Md5};
|
||||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::Sha256;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
|
fn create_s3_client(env: &RustFSTestEnvironment) -> Client {
|
||||||
@@ -70,7 +71,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn content_md5_base64(body: &[u8]) -> String {
|
fn content_md5_base64(body: &[u8]) -> String {
|
||||||
let digest = md5::compute(body);
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(body);
|
||||||
|
let digest = hasher.finalize();
|
||||||
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
base64::engine::general_purpose::STANDARD.encode(digest.as_slice())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,12 @@
|
|||||||
use aws_sdk_s3::config::{Credentials, Region};
|
use aws_sdk_s3::config::{Credentials, Region};
|
||||||
use aws_sdk_s3::{Client, Config};
|
use aws_sdk_s3::{Client, Config};
|
||||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
use reqwest::Client as HttpClient;
|
use reqwest::Client as HttpClient;
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
|
use rustfs_signer::sign_v4;
|
||||||
|
use s3s::Body;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::fs as stdfs;
|
use std::fs as stdfs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -75,6 +80,58 @@ pub fn local_http_client() -> HttpClient {
|
|||||||
.expect("failed to build local reqwest client")
|
.expect("failed to build local reqwest client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Signs and sends an admin HTTP request with the given credentials.
|
||||||
|
pub(crate) async fn admin_request(
|
||||||
|
base_url: &str,
|
||||||
|
method: http::Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) -> Result<(StatusCode, String), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let url = format!("{base_url}{path_and_query}");
|
||||||
|
let uri = url.parse::<http::Uri>()?;
|
||||||
|
let authority = uri.authority().ok_or("admin URL missing authority")?.to_string();
|
||||||
|
let mut request = http::Request::builder()
|
||||||
|
.method(method.clone())
|
||||||
|
.uri(uri)
|
||||||
|
.header(HOST, authority)
|
||||||
|
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||||
|
if body.is_some() {
|
||||||
|
request = request.header(CONTENT_TYPE, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_length = i64::try_from(body.as_ref().map_or(0, String::len)).map_err(|_| "admin request body is too large")?;
|
||||||
|
let signed = sign_v4(request.body(Body::empty())?, content_length, access_key, secret_key, "", "us-east-1");
|
||||||
|
|
||||||
|
let mut request = local_http_client().request(method, &url);
|
||||||
|
for (name, value) in signed.headers() {
|
||||||
|
request = request.header(name, value);
|
||||||
|
}
|
||||||
|
if let Some(body) = body {
|
||||||
|
request = request.body(body);
|
||||||
|
}
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
|
Ok((status, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a root-credential admin request and returns its successful response body.
|
||||||
|
pub(crate) async fn admin_ok(
|
||||||
|
env: &RustFSTestEnvironment,
|
||||||
|
method: http::Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
body: Option<String>,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let (status, response_body) =
|
||||||
|
admin_request(&env.url, method.clone(), path_and_query, body, &env.access_key, &env.secret_key).await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("{method} {path_and_query} failed: {status} {response_body}").into());
|
||||||
|
}
|
||||||
|
Ok(response_body)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the RustFS binary relative to the workspace.
|
/// Resolve the RustFS binary relative to the workspace.
|
||||||
pub fn rustfs_binary_path() -> PathBuf {
|
pub fn rustfs_binary_path() -> PathBuf {
|
||||||
rustfs_binary_path_with_features(requested_rustfs_build_features().as_deref())
|
rustfs_binary_path_with_features(requested_rustfs_build_features().as_deref())
|
||||||
@@ -892,6 +949,7 @@ pub struct RustFSTestClusterEnvironment {
|
|||||||
pub access_key: String,
|
pub access_key: String,
|
||||||
pub secret_key: String,
|
pub secret_key: String,
|
||||||
pub extra_env: Vec<(String, String)>,
|
pub extra_env: Vec<(String, String)>,
|
||||||
|
pub node_extra_env: Vec<Vec<(String, String)>>,
|
||||||
pub topology: ClusterTopology,
|
pub topology: ClusterTopology,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,6 +1037,7 @@ impl RustFSTestClusterEnvironment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut extra_env = Vec::new();
|
let mut extra_env = Vec::new();
|
||||||
|
extra_env.push(("RUSTFS_RPC_SECRET".to_string(), String::new()));
|
||||||
if multidrive {
|
if multidrive {
|
||||||
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()));
|
||||||
}
|
}
|
||||||
@@ -986,9 +1045,10 @@ impl RustFSTestClusterEnvironment {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
nodes,
|
nodes,
|
||||||
temp_dir,
|
temp_dir,
|
||||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
access_key: "rustfs-cluster-test-access".to_string(),
|
||||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
secret_key: "rustfs-cluster-test-secret".to_string(),
|
||||||
extra_env,
|
extra_env,
|
||||||
|
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||||
topology,
|
topology,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1002,6 +1062,22 @@ impl RustFSTestClusterEnvironment {
|
|||||||
self.extra_env.push((key.into(), value.into()));
|
self.extra_env.push((key.into(), value.into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Add an extra environment variable applied to a single cluster node.
|
||||||
|
pub fn set_node_env<K, V>(
|
||||||
|
&mut self,
|
||||||
|
node_idx: usize,
|
||||||
|
key: K,
|
||||||
|
value: V,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||||
|
where
|
||||||
|
K: Into<String>,
|
||||||
|
V: Into<String>,
|
||||||
|
{
|
||||||
|
self.ensure_node_index(node_idx)?;
|
||||||
|
self.node_extra_env[node_idx].push((key.into(), value.into()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_node_index(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
fn ensure_node_index(&self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
if node_idx >= self.nodes.len() {
|
if node_idx >= self.nodes.len() {
|
||||||
return Err(format!("node_idx {node_idx} is invalid").into());
|
return Err(format!("node_idx {node_idx} is invalid").into());
|
||||||
@@ -1088,6 +1164,9 @@ impl RustFSTestClusterEnvironment {
|
|||||||
for (key, value) in &self.extra_env {
|
for (key, value) in &self.extra_env {
|
||||||
command.env(key, value);
|
command.env(key, value);
|
||||||
}
|
}
|
||||||
|
for (key, value) in &self.node_extra_env[i] {
|
||||||
|
command.env(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
let process = command.current_dir(&node.data_dir).spawn()?;
|
let process = command.current_dir(&node.data_dir).spawn()?;
|
||||||
|
|
||||||
@@ -1129,6 +1208,9 @@ impl RustFSTestClusterEnvironment {
|
|||||||
for (key, value) in &self.extra_env {
|
for (key, value) in &self.extra_env {
|
||||||
command.env(key, value);
|
command.env(key, value);
|
||||||
}
|
}
|
||||||
|
for (key, value) in &self.node_extra_env[node_idx] {
|
||||||
|
command.env(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -1370,6 +1452,7 @@ mod tests {
|
|||||||
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
access_key: DEFAULT_ACCESS_KEY.to_string(),
|
||||||
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
secret_key: DEFAULT_SECRET_KEY.to_string(),
|
||||||
extra_env: Vec::new(),
|
extra_env: Vec::new(),
|
||||||
|
node_extra_env: vec![Vec::new(); topology.node_count],
|
||||||
topology,
|
topology,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1454,4 +1537,24 @@ mod tests {
|
|||||||
assert!(ClusterTopology::single_pool_multidrive(4, 4).validate().is_ok());
|
assert!(ClusterTopology::single_pool_multidrive(4, 4).validate().is_ok());
|
||||||
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
|
assert!(ClusterTopology::single_pool_multidrive(1, 1).validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cluster_node_env_supports_per_node_overrides() {
|
||||||
|
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||||
|
env.set_node_env(2, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
env.node_extra_env[2].as_slice(),
|
||||||
|
[("RUSTFS_INTERNODE_RPC_MSGPACK_ONLY".to_string(), "true".to_string())]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cluster_node_env_rejects_invalid_index() {
|
||||||
|
let mut env = fake_cluster(ClusterTopology::single_pool(4));
|
||||||
|
let err = env
|
||||||
|
.set_node_env(4, "RUSTFS_INTERNODE_RPC_MSGPACK_ONLY", "true")
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("invalid"), "unexpected error: {err}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! E2E coverage for the opt-in global connection cap on the main API listener
|
||||||
|
//! (backlog#1191 follow-up, `RUSTFS_API_MAX_CONNECTIONS`): permits must be
|
||||||
|
//! released when connections close (no leak), and the cap must actually bound
|
||||||
|
//! concurrency — a queued connection is served only after a held one closes.
|
||||||
|
|
||||||
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
/// Open a TCP connection and write one unauthenticated `GET /` (any response,
|
||||||
|
/// e.g. 403, proves the connection was accepted and served).
|
||||||
|
async fn open_and_request(addr: &str, connection: &str) -> std::io::Result<TcpStream> {
|
||||||
|
let mut stream = TcpStream::connect(addr).await?;
|
||||||
|
let request = format!("GET / HTTP/1.1\r\nHost: {addr}\r\nConnection: {connection}\r\n\r\n");
|
||||||
|
stream.write_all(request.as_bytes()).await?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read until the response head is complete, or `None` on timeout/close —
|
||||||
|
/// a `None` on an open socket means the connection sits unaccepted in the
|
||||||
|
/// kernel backlog behind the cap.
|
||||||
|
async fn read_response_head(stream: &mut TcpStream, dur: Duration) -> Option<String> {
|
||||||
|
let deadline = tokio::time::Instant::now() + dur;
|
||||||
|
let mut buf = vec![0u8; 4096];
|
||||||
|
let mut collected = String::new();
|
||||||
|
loop {
|
||||||
|
let remaining = deadline.checked_duration_since(tokio::time::Instant::now())?;
|
||||||
|
match timeout(remaining, stream.read(&mut buf)).await {
|
||||||
|
Ok(Ok(0)) | Ok(Err(_)) | Err(_) => return None,
|
||||||
|
Ok(Ok(n)) => {
|
||||||
|
collected.push_str(&String::from_utf8_lossy(&buf[..n]));
|
||||||
|
if collected.contains("\r\n\r\n") {
|
||||||
|
return Some(collected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn connection_cap_releases_permits_on_close() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_API_MAX_CONNECTIONS", "2")])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Ten sequential connections against cap 2: if permits leaked, the third
|
||||||
|
// request would already hang in the backlog and time out.
|
||||||
|
for i in 0..10 {
|
||||||
|
let mut stream = open_and_request(&env.address, "close").await?;
|
||||||
|
let head = read_response_head(&mut stream, Duration::from_secs(10))
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|| panic!("request {i} got no response — a connection permit leaked"));
|
||||||
|
assert!(head.starts_with("HTTP/1.1"), "request {i} unexpected response: {head}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a TCP connection and send an INCOMPLETE request head. Once accepted
|
||||||
|
/// it pins a connection permit: hyper waits for the rest of the head (75s
|
||||||
|
/// default header timeout) until we close the socket.
|
||||||
|
async fn open_and_stall(addr: &str) -> std::io::Result<TcpStream> {
|
||||||
|
let mut stream = TcpStream::connect(addr).await?;
|
||||||
|
stream
|
||||||
|
.write_all(format!("GET / HTTP/1.1\r\nHost: {addr}\r\n").as_bytes())
|
||||||
|
.await?;
|
||||||
|
Ok(stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn connection_cap_blocks_excess_connections_until_permits_free() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_API_MAX_CONNECTIONS", "2")])
|
||||||
|
.await?;
|
||||||
|
// Let the readiness poller's pooled connection close and free its permit.
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
|
||||||
|
// Two stalled connections saturate cap 2 (a served-and-closed connection
|
||||||
|
// would release its permit immediately, so stalling is what makes the
|
||||||
|
// occupancy deterministic).
|
||||||
|
let stalled_a = open_and_stall(&env.address).await?;
|
||||||
|
let stalled_b = open_and_stall(&env.address).await?;
|
||||||
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
// A complete request now sits in the kernel backlog: connect() succeeds
|
||||||
|
// but no permit is available, so no response arrives.
|
||||||
|
let mut blocked = open_and_request(&env.address, "close").await?;
|
||||||
|
assert!(
|
||||||
|
read_response_head(&mut blocked, Duration::from_secs(3)).await.is_none(),
|
||||||
|
"cap 2 with two stalled connections must leave the third unserved"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dropping the stalled connections releases their permits (hyper sees
|
||||||
|
// EOF while reading the head); the queued request's bytes already sit in
|
||||||
|
// the socket buffer, so it must now be accepted and served.
|
||||||
|
drop(stalled_a);
|
||||||
|
drop(stalled_b);
|
||||||
|
let head = read_response_head(&mut blocked, Duration::from_secs(10))
|
||||||
|
.await
|
||||||
|
.expect("queued connection must be served after permits are released");
|
||||||
|
assert!(head.starts_with("HTTP/1.1"), "unexpected response: {head}");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -80,6 +80,25 @@ mod tests {
|
|||||||
assert_eq!(head_resp.content_encoding(), Some("zstd"), "HEAD should return Content-Encoding: zstd");
|
assert_eq!(head_resp.content_encoding(), Some("zstd"), "HEAD should return Content-Encoding: zstd");
|
||||||
assert_eq!(head_resp.content_type(), Some("text/plain"), "HEAD should return correct Content-Type");
|
assert_eq!(head_resp.content_type(), Some("text/plain"), "HEAD should return correct Content-Type");
|
||||||
|
|
||||||
|
client
|
||||||
|
.delete_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("DELETE object failed");
|
||||||
|
client
|
||||||
|
.delete_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("DELETE bucket failed");
|
||||||
|
client
|
||||||
|
.list_buckets()
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("RustFS must remain available after deleting a bucket");
|
||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,708 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! CopyObject checksum compatibility tests. Covers all supported algorithms,
|
||||||
|
//! source-checksum preservation, explicit override, and fail-closed handling of
|
||||||
|
//! unsupported algorithms before destination mutation.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
|
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{
|
||||||
|
BucketVersioningStatus, ChecksumAlgorithm, ChecksumMode, ChecksumType, CompletedMultipartUpload, CompletedPart,
|
||||||
|
VersioningConfiguration,
|
||||||
|
};
|
||||||
|
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||||
|
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||||
|
use serial_test::serial;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
async fn create_versioned_bucket(client: &aws_sdk_s3::Client, bucket: &str) {
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create bucket");
|
||||||
|
client
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(bucket)
|
||||||
|
.versioning_configuration(
|
||||||
|
VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Enabled)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to enable versioning");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_s3_client_no_auto_checksum(env: &RustFSTestEnvironment) -> aws_sdk_s3::Client {
|
||||||
|
let credentials = Credentials::new(&env.access_key, &env.secret_key, None, None, "copy-checksum-e2e");
|
||||||
|
let config = aws_sdk_s3::Config::builder()
|
||||||
|
.credentials_provider(credentials)
|
||||||
|
.region(Region::new("us-east-1"))
|
||||||
|
.endpoint_url(format!("http://{}", env.address))
|
||||||
|
.force_path_style(true)
|
||||||
|
.behavior_version_latest()
|
||||||
|
.request_checksum_calculation(RequestChecksumCalculation::WhenRequired)
|
||||||
|
.http_client(SmithyHttpClientBuilder::new().build_http())
|
||||||
|
.build();
|
||||||
|
aws_sdk_s3::Client::from_conf(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn algorithms() -> [(ChecksumAlgorithm, RioChecksumType); 10] {
|
||||||
|
[
|
||||||
|
(ChecksumAlgorithm::Crc32, RioChecksumType::CRC32),
|
||||||
|
(ChecksumAlgorithm::Crc32C, RioChecksumType::CRC32C),
|
||||||
|
(ChecksumAlgorithm::Crc64Nvme, RioChecksumType::CRC64_NVME),
|
||||||
|
(ChecksumAlgorithm::Sha1, RioChecksumType::SHA1),
|
||||||
|
(ChecksumAlgorithm::Sha256, RioChecksumType::SHA256),
|
||||||
|
(ChecksumAlgorithm::Md5, RioChecksumType::MD5),
|
||||||
|
(ChecksumAlgorithm::Sha512, RioChecksumType::SHA512),
|
||||||
|
(ChecksumAlgorithm::Xxhash3, RioChecksumType::XXHASH3),
|
||||||
|
(ChecksumAlgorithm::Xxhash64, RioChecksumType::XXHASH64),
|
||||||
|
(ChecksumAlgorithm::Xxhash128, RioChecksumType::XXHASH128),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn result_checksums(result: &aws_sdk_s3::types::CopyObjectResult) -> [Option<&str>; 10] {
|
||||||
|
[
|
||||||
|
result.checksum_crc32(),
|
||||||
|
result.checksum_crc32_c(),
|
||||||
|
result.checksum_crc64_nvme(),
|
||||||
|
result.checksum_sha1(),
|
||||||
|
result.checksum_sha256(),
|
||||||
|
result.checksum_md5(),
|
||||||
|
result.checksum_sha512(),
|
||||||
|
result.checksum_xxhash3(),
|
||||||
|
result.checksum_xxhash64(),
|
||||||
|
result.checksum_xxhash128(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn head_checksums(output: &aws_sdk_s3::operation::head_object::HeadObjectOutput) -> [Option<&str>; 10] {
|
||||||
|
[
|
||||||
|
output.checksum_crc32(),
|
||||||
|
output.checksum_crc32_c(),
|
||||||
|
output.checksum_crc64_nvme(),
|
||||||
|
output.checksum_sha1(),
|
||||||
|
output.checksum_sha256(),
|
||||||
|
output.checksum_md5(),
|
||||||
|
output.checksum_sha512(),
|
||||||
|
output.checksum_xxhash3(),
|
||||||
|
output.checksum_xxhash64(),
|
||||||
|
output.checksum_xxhash128(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_copy_supports_all_checksum_algorithms() {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
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_no_auto_checksum(&env);
|
||||||
|
let src_bucket = "copy-all-checksums-src";
|
||||||
|
let dst_bucket = "copy-all-checksums-dst";
|
||||||
|
let src_key = "objects/source.bin";
|
||||||
|
let content = b"deterministic CopyObject payload for all ten checksum algorithms";
|
||||||
|
|
||||||
|
create_versioned_bucket(&client, src_bucket).await;
|
||||||
|
create_versioned_bucket(&client, dst_bucket).await;
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(src_bucket)
|
||||||
|
.key(src_key)
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT source failed");
|
||||||
|
|
||||||
|
for (index, (sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
|
||||||
|
let expected = Checksum::new_from_data(rio_algorithm, content)
|
||||||
|
.expect("supported checksum must be computable")
|
||||||
|
.encoded;
|
||||||
|
let dst_key = format!("objects/destination-{index}.bin");
|
||||||
|
let copy = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(&dst_key)
|
||||||
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||||
|
.checksum_algorithm(sdk_algorithm)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject with supported checksum must succeed");
|
||||||
|
let result = copy.copy_object_result().expect("CopyObject result");
|
||||||
|
let checksums = result_checksums(result);
|
||||||
|
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: response checksum");
|
||||||
|
assert_eq!(
|
||||||
|
checksums.iter().filter(|checksum| checksum.is_some()).count(),
|
||||||
|
1,
|
||||||
|
"{rio_algorithm}: only the requested checksum may be returned"
|
||||||
|
);
|
||||||
|
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(&dst_key)
|
||||||
|
.checksum_mode(ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD destination failed");
|
||||||
|
let checksums = head_checksums(&head);
|
||||||
|
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: persisted checksum");
|
||||||
|
assert_eq!(
|
||||||
|
checksums.iter().filter(|checksum| checksum.is_some()).count(),
|
||||||
|
1,
|
||||||
|
"{rio_algorithm}: destination must persist only the requested checksum"
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(&dst_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET destination failed")
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("collect destination body")
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(body.as_ref(), content, "{rio_algorithm}: full copied body");
|
||||||
|
}
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_copy_without_algorithm_preserves_every_supported_source_checksum() {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
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_no_auto_checksum(&env);
|
||||||
|
let src_bucket = "copy-preserve-all-src";
|
||||||
|
let dst_bucket = "copy-preserve-all-dst";
|
||||||
|
let content = b"source checksum preservation payload for all ten algorithms";
|
||||||
|
|
||||||
|
create_versioned_bucket(&client, src_bucket).await;
|
||||||
|
create_versioned_bucket(&client, dst_bucket).await;
|
||||||
|
|
||||||
|
for (index, (_sdk_algorithm, rio_algorithm)) in algorithms().into_iter().enumerate() {
|
||||||
|
let expected = Checksum::new_from_data(rio_algorithm, content)
|
||||||
|
.expect("supported checksum must be computable")
|
||||||
|
.encoded;
|
||||||
|
let checksum_header = rio_algorithm.key().expect("supported checksum header");
|
||||||
|
let request_checksum = expected.clone();
|
||||||
|
let src_key = format!("objects/source-{index}.bin");
|
||||||
|
let dst_key = format!("objects/destination-{index}.bin");
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(src_bucket)
|
||||||
|
.key(&src_key)
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.customize()
|
||||||
|
.mutate_request(move |request| {
|
||||||
|
request.headers_mut().insert(checksum_header, request_checksum.clone());
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT checksummed source failed");
|
||||||
|
|
||||||
|
let copy = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(&dst_key)
|
||||||
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject without algorithm must succeed");
|
||||||
|
let result = copy.copy_object_result().expect("CopyObject result");
|
||||||
|
let checksums = result_checksums(result);
|
||||||
|
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved response checksum");
|
||||||
|
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
|
||||||
|
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(&dst_key)
|
||||||
|
.checksum_mode(ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD destination failed");
|
||||||
|
let checksums = head_checksums(&head);
|
||||||
|
assert_eq!(checksums[index], Some(expected.as_str()), "{rio_algorithm}: preserved stored checksum");
|
||||||
|
assert_eq!(checksums.iter().filter(|checksum| checksum.is_some()).count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_copy_without_algorithm_preserves_composite_checksum_type() {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
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_no_auto_checksum(&env);
|
||||||
|
let bucket = "copy-preserve-composite";
|
||||||
|
let source_key = "objects/multipart-source.bin";
|
||||||
|
let destination_key = "objects/copied-multipart.bin";
|
||||||
|
let content = b"multipart source checksum must remain composite";
|
||||||
|
|
||||||
|
create_versioned_bucket(&client, bucket).await;
|
||||||
|
let created = client
|
||||||
|
.create_multipart_upload()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source_key)
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CreateMultipartUpload failed");
|
||||||
|
let upload_id = created.upload_id().expect("multipart upload ID");
|
||||||
|
let checksum = Checksum::new_from_data(RioChecksumType::SHA256, content)
|
||||||
|
.expect("SHA256 checksum")
|
||||||
|
.encoded;
|
||||||
|
let uploaded = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(1)
|
||||||
|
.checksum_sha256(&checksum)
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("UploadPart failed");
|
||||||
|
let completed_part = CompletedPart::builder()
|
||||||
|
.part_number(1)
|
||||||
|
.e_tag(uploaded.e_tag().expect("part ETag"))
|
||||||
|
.checksum_sha256(uploaded.checksum_sha256().expect("part checksum"))
|
||||||
|
.build();
|
||||||
|
client
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CompleteMultipartUpload failed");
|
||||||
|
|
||||||
|
let source_head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source_key)
|
||||||
|
.checksum_mode(ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD multipart source failed");
|
||||||
|
let source_checksum = source_head.checksum_sha256().expect("multipart source checksum");
|
||||||
|
assert_eq!(source_head.checksum_type(), Some(&ChecksumType::Composite));
|
||||||
|
|
||||||
|
let copied = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(destination_key)
|
||||||
|
.copy_source(format!("{bucket}/{source_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject without algorithm failed");
|
||||||
|
let result = copied.copy_object_result().expect("CopyObject result");
|
||||||
|
assert_eq!(result.checksum_sha256(), Some(source_checksum));
|
||||||
|
assert_eq!(result.checksum_type(), Some(&ChecksumType::Composite));
|
||||||
|
|
||||||
|
let destination_head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(destination_key)
|
||||||
|
.checksum_mode(ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD copied multipart object failed");
|
||||||
|
assert_eq!(destination_head.checksum_sha256(), Some(source_checksum));
|
||||||
|
assert_eq!(destination_head.checksum_type(), Some(&ChecksumType::Composite));
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_copy_rejects_unknown_algorithm_without_destination_mutation() {
|
||||||
|
init_logging();
|
||||||
|
|
||||||
|
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 = env.create_s3_client();
|
||||||
|
let bucket = "copy-reject-unknown-checksum";
|
||||||
|
let src_key = "objects/source.bin";
|
||||||
|
let dst_key = "objects/destination.bin";
|
||||||
|
let source = b"source must never replace destination";
|
||||||
|
let destination = b"pre-existing destination must remain byte-for-byte unchanged";
|
||||||
|
let expected = Checksum::new_from_data(RioChecksumType::SHA256, destination)
|
||||||
|
.expect("SHA256 checksum")
|
||||||
|
.encoded;
|
||||||
|
|
||||||
|
create_versioned_bucket(&client, bucket).await;
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(src_key)
|
||||||
|
.body(ByteStream::from_static(source))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT source failed");
|
||||||
|
let original = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.metadata("state", "original")
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||||
|
.body(ByteStream::from_static(destination))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT destination failed");
|
||||||
|
let original_version = original.version_id().expect("versioned PUT must return a version id");
|
||||||
|
|
||||||
|
let missing_source_error = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.copy_source(format!("{bucket}/objects/missing-source.bin"))
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("checksum validation must precede source lookup");
|
||||||
|
assert_eq!(
|
||||||
|
missing_source_error.as_service_error().and_then(|value| value.code()),
|
||||||
|
Some("InvalidArgument")
|
||||||
|
);
|
||||||
|
assert_eq!(missing_source_error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||||
|
|
||||||
|
let error = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.copy_source(format!("{bucket}/{src_key}"))
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::from("BLAKE3"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("unsupported checksum algorithm must fail");
|
||||||
|
assert_eq!(error.as_service_error().and_then(|value| value.code()), Some("InvalidArgument"));
|
||||||
|
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||||
|
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.checksum_mode(ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD unchanged destination");
|
||||||
|
assert_eq!(head.version_id(), Some(original_version));
|
||||||
|
assert_eq!(
|
||||||
|
head.metadata().and_then(|metadata| metadata.get("state").map(String::as_str)),
|
||||||
|
Some("original")
|
||||||
|
);
|
||||||
|
assert_eq!(head.checksum_sha256(), Some(expected.as_str()));
|
||||||
|
|
||||||
|
let body = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET unchanged destination")
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("collect unchanged destination")
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(body.as_ref(), destination);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requested algorithm: a CopyObject asking for SHA256 must compute it over the copied
|
||||||
|
/// bytes, return it in `CopyObjectResult.ChecksumSHA256`, and persist it so a checksum-mode
|
||||||
|
/// HEAD on the destination returns the identical value.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_copy_with_checksum_algorithm_returns_and_persists_sha256() {
|
||||||
|
init_logging();
|
||||||
|
info!("Issue #4996: CopyObject with ChecksumAlgorithm=SHA256 must return and persist the checksum");
|
||||||
|
|
||||||
|
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 = env.create_s3_client();
|
||||||
|
let src_bucket = "copy-checksum-req-src";
|
||||||
|
let dst_bucket = "copy-checksum-req-dst";
|
||||||
|
let src_key = "objects/source.bin";
|
||||||
|
let dst_key = "objects/dest.bin";
|
||||||
|
|
||||||
|
create_versioned_bucket(&client, src_bucket).await;
|
||||||
|
create_versioned_bucket(&client, dst_bucket).await;
|
||||||
|
|
||||||
|
let content = b"deterministic synthetic payload for copy-object checksum #4996";
|
||||||
|
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||||
|
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(src_bucket)
|
||||||
|
.key(src_key)
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT source failed");
|
||||||
|
|
||||||
|
let copy_out = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject with ChecksumAlgorithm must succeed");
|
||||||
|
|
||||||
|
// (3) The response must carry the freshly computed SHA-256 of the copied bytes.
|
||||||
|
let result = copy_out
|
||||||
|
.copy_object_result()
|
||||||
|
.expect("issue #4996: CopyObject must return a CopyObjectResult");
|
||||||
|
assert_eq!(
|
||||||
|
result.checksum_sha256(),
|
||||||
|
Some(expected_sha256.as_str()),
|
||||||
|
"issue #4996: CopyObjectResult.ChecksumSHA256 must equal the SHA-256 of the copied bytes"
|
||||||
|
);
|
||||||
|
|
||||||
|
// (4) A checksum-mode HEAD on the destination must return the same SHA-256.
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.checksum_mode(ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD destination failed");
|
||||||
|
assert_eq!(
|
||||||
|
head.checksum_sha256(),
|
||||||
|
Some(expected_sha256.as_str()),
|
||||||
|
"issue #4996: destination checksum-mode HEAD must return the same SHA-256 the copy reported"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// No algorithm requested: when the source object already carries a checksum, the copy must
|
||||||
|
/// preserve it on the destination (AWS default), visible via a checksum-mode HEAD.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_copy_without_algorithm_preserves_source_checksum() {
|
||||||
|
init_logging();
|
||||||
|
info!("Issue #4996: CopyObject without ChecksumAlgorithm must preserve the source object's checksum");
|
||||||
|
|
||||||
|
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 = env.create_s3_client();
|
||||||
|
let src_bucket = "copy-checksum-preserve-src";
|
||||||
|
let dst_bucket = "copy-checksum-preserve-dst";
|
||||||
|
let src_key = "objects/source.bin";
|
||||||
|
let dst_key = "objects/dest.bin";
|
||||||
|
|
||||||
|
create_versioned_bucket(&client, src_bucket).await;
|
||||||
|
create_versioned_bucket(&client, dst_bucket).await;
|
||||||
|
|
||||||
|
let content = b"another deterministic payload whose source checksum must survive the copy";
|
||||||
|
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||||
|
|
||||||
|
// Store the source WITH a SHA-256 checksum so it has one to preserve.
|
||||||
|
let put_src = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(src_bucket)
|
||||||
|
.key(src_key)
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT source with checksum failed");
|
||||||
|
assert_eq!(
|
||||||
|
put_src.checksum_sha256(),
|
||||||
|
Some(expected_sha256.as_str()),
|
||||||
|
"source PUT must report the SHA-256 it stored"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Copy WITHOUT specifying a checksum algorithm.
|
||||||
|
let copy_out = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject without ChecksumAlgorithm must succeed");
|
||||||
|
|
||||||
|
// The response should echo the preserved source checksum.
|
||||||
|
let result = copy_out
|
||||||
|
.copy_object_result()
|
||||||
|
.expect("issue #4996: CopyObject must return a CopyObjectResult");
|
||||||
|
assert_eq!(
|
||||||
|
result.checksum_sha256(),
|
||||||
|
Some(expected_sha256.as_str()),
|
||||||
|
"issue #4996: a no-algorithm copy must preserve and report the source object's SHA-256"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And a checksum-mode HEAD on the destination must return that same preserved SHA-256.
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.checksum_mode(ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD destination failed");
|
||||||
|
assert_eq!(
|
||||||
|
head.checksum_sha256(),
|
||||||
|
Some(expected_sha256.as_str()),
|
||||||
|
"issue #4996: destination checksum-mode HEAD must return the preserved source SHA-256"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requested algorithm differs from the source's: a source stored with SHA256, copied while
|
||||||
|
/// requesting CRC32, must return/persist the freshly computed CRC32 and must NOT carry the
|
||||||
|
/// source's SHA256 through. Guards the request-over-source precedence and the destination's
|
||||||
|
/// checksum-not-inherited path, and exercises the CRC32 code path (a different branch of
|
||||||
|
/// ChecksumType::from_string than SHA256).
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_copy_requested_algorithm_overrides_source_checksum() {
|
||||||
|
init_logging();
|
||||||
|
info!("Issue #4996: a requested CopyObject checksum algorithm must override the source object's algorithm");
|
||||||
|
|
||||||
|
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 = env.create_s3_client();
|
||||||
|
let src_bucket = "copy-checksum-override-src";
|
||||||
|
let dst_bucket = "copy-checksum-override-dst";
|
||||||
|
let src_key = "objects/source.bin";
|
||||||
|
let ref_key = "objects/reference-crc32.bin";
|
||||||
|
let dst_key = "objects/dest.bin";
|
||||||
|
|
||||||
|
create_versioned_bucket(&client, src_bucket).await;
|
||||||
|
create_versioned_bucket(&client, dst_bucket).await;
|
||||||
|
|
||||||
|
let content = b"payload whose copy must be re-checksummed with a different algorithm";
|
||||||
|
let expected_sha256 = BASE64.encode(Sha256::digest(content));
|
||||||
|
|
||||||
|
// Source is stored WITH a SHA-256 checksum.
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(src_bucket)
|
||||||
|
.key(src_key)
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::Sha256)
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT source with SHA256 failed");
|
||||||
|
|
||||||
|
// Establish the canonical CRC32 the server computes for this content via a reference PUT,
|
||||||
|
// so the copy's CRC32 can be asserted against an exact server-computed value.
|
||||||
|
let ref_put = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(src_bucket)
|
||||||
|
.key(ref_key)
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||||
|
.body(ByteStream::from_static(content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("reference PUT with CRC32 failed");
|
||||||
|
let expected_crc32 = ref_put
|
||||||
|
.checksum_crc32()
|
||||||
|
.expect("reference PUT must report a CRC32")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Copy the SHA256 source while requesting CRC32.
|
||||||
|
let copy_out = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.copy_source(format!("{src_bucket}/{src_key}"))
|
||||||
|
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject requesting a different algorithm must succeed");
|
||||||
|
|
||||||
|
let result = copy_out
|
||||||
|
.copy_object_result()
|
||||||
|
.expect("issue #4996: CopyObject must return a CopyObjectResult");
|
||||||
|
// The requested CRC32 must be computed and returned.
|
||||||
|
assert_eq!(
|
||||||
|
result.checksum_crc32(),
|
||||||
|
Some(expected_crc32.as_str()),
|
||||||
|
"issue #4996: a requested CRC32 must be computed fresh over the copied bytes"
|
||||||
|
);
|
||||||
|
// The source's SHA256 must NOT leak through — the requested algorithm wins.
|
||||||
|
assert_eq!(
|
||||||
|
result.checksum_sha256(),
|
||||||
|
None,
|
||||||
|
"issue #4996: the source object's SHA256 must not be inherited when a different algorithm is requested"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
result.checksum_crc32(),
|
||||||
|
Some(expected_sha256.as_str()),
|
||||||
|
"sanity: CRC32 field must not carry the SHA256 value"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The destination must persist CRC32 (and only CRC32) for a checksum-mode HEAD.
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.checksum_mode(ChecksumMode::Enabled)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD destination failed");
|
||||||
|
assert_eq!(
|
||||||
|
head.checksum_crc32(),
|
||||||
|
Some(expected_crc32.as_str()),
|
||||||
|
"issue #4996: destination checksum-mode HEAD must return the requested CRC32"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
head.checksum_sha256(),
|
||||||
|
None,
|
||||||
|
"issue #4996: destination must not report the source's SHA256 after an override copy"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,14 +17,17 @@
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
use aws_sdk_s3::types::MetadataDirective;
|
use aws_sdk_s3::primitives::{ByteStream, DateTime, DateTimeFormat};
|
||||||
|
use aws_sdk_s3::types::{
|
||||||
|
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, MetadataDirective, StorageClass, VersioningConfiguration,
|
||||||
|
};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_self_copy_replace_metadata_preserves_readable_object() {
|
async fn copy_object_standard_metadata_copy_replace_and_clear() {
|
||||||
init_logging();
|
init_logging();
|
||||||
info!("Issue #2789: self-copy metadata replacement must preserve object data");
|
info!("Issue #2789: self-copy metadata replacement must preserve object data");
|
||||||
|
|
||||||
@@ -35,6 +38,14 @@ mod tests {
|
|||||||
let bucket = "self-copy-metadata-replace-test";
|
let bucket = "self-copy-metadata-replace-test";
|
||||||
let key = "assets/chunk-2F3R7JUG.js";
|
let key = "assets/chunk-2F3R7JUG.js";
|
||||||
let content = b"console.log('metadata replacement should keep object data readable');";
|
let content = b"console.log('metadata replacement should keep object data readable');";
|
||||||
|
let source_expires = DateTime::from_secs(1_893_456_000);
|
||||||
|
let source_expires_http_date = source_expires
|
||||||
|
.fmt(DateTimeFormat::HttpDate)
|
||||||
|
.expect("Test timestamp should format as an HTTP date");
|
||||||
|
let replacement_expires = DateTime::from_secs(1_924_992_000);
|
||||||
|
let replacement_expires_http_date = replacement_expires
|
||||||
|
.fmt(DateTimeFormat::HttpDate)
|
||||||
|
.expect("Test timestamp should format as an HTTP date");
|
||||||
|
|
||||||
client
|
client
|
||||||
.create_bucket()
|
.create_bucket()
|
||||||
@@ -47,7 +58,14 @@ mod tests {
|
|||||||
.put_object()
|
.put_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
|
.cache_control("max-age=60")
|
||||||
|
.content_disposition("inline; filename=source.js")
|
||||||
|
.content_encoding("br")
|
||||||
|
.content_language("en-US")
|
||||||
.content_type("text/javascript; charset=utf-8")
|
.content_type("text/javascript; charset=utf-8")
|
||||||
|
.expires(source_expires)
|
||||||
|
.website_redirect_location("/source.html")
|
||||||
|
.storage_class(StorageClass::ReducedRedundancy)
|
||||||
.metadata("mtime", "1777992333")
|
.metadata("mtime", "1777992333")
|
||||||
.metadata("stale", "must-be-removed")
|
.metadata("stale", "must-be-removed")
|
||||||
.body(ByteStream::from_static(content))
|
.body(ByteStream::from_static(content))
|
||||||
@@ -55,13 +73,137 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("PUT failed");
|
.expect("PUT failed");
|
||||||
|
|
||||||
|
let copied_key = "assets/default-copy.js";
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(copied_key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("default CopyObject failed");
|
||||||
|
|
||||||
|
let copied_head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(copied_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD failed after default copy");
|
||||||
|
assert_eq!(copied_head.cache_control(), Some("max-age=60"));
|
||||||
|
assert_eq!(copied_head.content_disposition(), Some("inline; filename=source.js"));
|
||||||
|
assert_eq!(copied_head.content_encoding(), Some("br"));
|
||||||
|
assert_eq!(copied_head.content_language(), Some("en-US"));
|
||||||
|
assert_eq!(copied_head.content_type(), Some("text/javascript; charset=utf-8"));
|
||||||
|
assert_eq!(copied_head.expires_string(), Some(source_expires_http_date.as_str()));
|
||||||
|
assert_eq!(
|
||||||
|
copied_head.storage_class(),
|
||||||
|
None,
|
||||||
|
"CopyObject without a storage class should write STANDARD"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
copied_head.website_redirect_location(),
|
||||||
|
Some("/source.html"),
|
||||||
|
"default CopyObject should preserve source metadata"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
copied_head.metadata().and_then(|metadata| metadata.get("stale")),
|
||||||
|
Some(&"must-be-removed".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("assets/explicit-copy.js")
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.metadata_directive(MetadataDirective::Copy)
|
||||||
|
.customize()
|
||||||
|
.mutate_request(|request| {
|
||||||
|
request.headers_mut().insert("content-type", "application/octet-stream");
|
||||||
|
request.headers_mut().insert("x-amz-meta-request-only", "ignored");
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("explicit COPY directive with request metadata failed");
|
||||||
|
let explicit_copy_head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("assets/explicit-copy.js")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD failed after explicit COPY");
|
||||||
|
assert_eq!(explicit_copy_head.cache_control(), Some("max-age=60"));
|
||||||
|
assert_eq!(explicit_copy_head.content_type(), Some("text/javascript; charset=utf-8"));
|
||||||
|
assert_eq!(
|
||||||
|
explicit_copy_head.metadata().and_then(|metadata| metadata.get("mtime")),
|
||||||
|
Some(&"1777992333".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
explicit_copy_head
|
||||||
|
.metadata()
|
||||||
|
.and_then(|metadata| metadata.get("request-only")),
|
||||||
|
None,
|
||||||
|
"COPY must ignore request metadata"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
explicit_copy_head.website_redirect_location(),
|
||||||
|
None,
|
||||||
|
"explicit COPY does not inherit website redirect metadata"
|
||||||
|
);
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("assets/explicit-copy-redirect.js")
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.metadata_directive(MetadataDirective::Copy)
|
||||||
|
.website_redirect_location("/explicit-copy.html")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("explicit COPY with redirect failed");
|
||||||
|
let explicit_redirect_head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("assets/explicit-copy-redirect.js")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD failed after explicit COPY with redirect");
|
||||||
|
assert_eq!(explicit_redirect_head.website_redirect_location(), Some("/explicit-copy.html"));
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("assets/explicit-storage-class.js")
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.storage_class(StorageClass::ReducedRedundancy)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject with an explicit storage class failed");
|
||||||
|
let explicit_storage_class_head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("assets/explicit-storage-class.js")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD failed after explicit storage class copy");
|
||||||
|
assert_eq!(
|
||||||
|
explicit_storage_class_head.storage_class().map(StorageClass::as_str),
|
||||||
|
Some("REDUCED_REDUNDANCY")
|
||||||
|
);
|
||||||
|
|
||||||
client
|
client
|
||||||
.copy_object()
|
.copy_object()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(key)
|
.key(key)
|
||||||
.copy_source(format!("{bucket}/{key}"))
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
.metadata_directive(MetadataDirective::Replace)
|
.metadata_directive(MetadataDirective::Replace)
|
||||||
.content_type("text/javascript; charset=utf-8")
|
.cache_control("no-cache")
|
||||||
|
.content_disposition("attachment; filename=replaced.js")
|
||||||
|
.content_encoding("gzip")
|
||||||
|
.content_language("fr-FR")
|
||||||
|
.content_type("application/javascript")
|
||||||
|
.expires(replacement_expires)
|
||||||
|
.website_redirect_location("/replaced.html")
|
||||||
.metadata("mtime", "1777992348")
|
.metadata("mtime", "1777992348")
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -85,6 +227,14 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
"HEAD should not return metadata omitted by REPLACE"
|
"HEAD should not return metadata omitted by REPLACE"
|
||||||
);
|
);
|
||||||
|
assert_eq!(head_resp.cache_control(), Some("no-cache"));
|
||||||
|
assert_eq!(head_resp.content_disposition(), Some("attachment; filename=replaced.js"));
|
||||||
|
assert_eq!(head_resp.content_encoding(), Some("gzip"));
|
||||||
|
assert_eq!(head_resp.content_language(), Some("fr-FR"));
|
||||||
|
assert_eq!(head_resp.content_type(), Some("application/javascript"));
|
||||||
|
assert_eq!(head_resp.expires_string(), Some(replacement_expires_http_date.as_str()));
|
||||||
|
assert_eq!(head_resp.website_redirect_location(), Some("/replaced.html"));
|
||||||
|
assert_eq!(head_resp.storage_class(), None, "REPLACE without a storage class should write STANDARD");
|
||||||
|
|
||||||
let get_resp = client
|
let get_resp = client
|
||||||
.get_object()
|
.get_object()
|
||||||
@@ -123,6 +273,13 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
"HEAD should not return metadata omitted by empty REPLACE"
|
"HEAD should not return metadata omitted by empty REPLACE"
|
||||||
);
|
);
|
||||||
|
assert_eq!(empty_head_resp.cache_control(), None);
|
||||||
|
assert_eq!(empty_head_resp.content_disposition(), None);
|
||||||
|
assert_eq!(empty_head_resp.content_encoding(), None);
|
||||||
|
assert_eq!(empty_head_resp.content_language(), None);
|
||||||
|
assert_eq!(empty_head_resp.content_type(), None);
|
||||||
|
assert_eq!(empty_head_resp.expires_string(), None);
|
||||||
|
assert_eq!(empty_head_resp.website_redirect_location(), None);
|
||||||
|
|
||||||
let empty_get_resp = client
|
let empty_get_resp = client
|
||||||
.get_object()
|
.get_object()
|
||||||
@@ -141,4 +298,319 @@ mod tests {
|
|||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn copy_object_replace_accepts_each_standard_field_independently() {
|
||||||
|
init_logging();
|
||||||
|
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 = env.create_s3_client();
|
||||||
|
let bucket = "copy-object-metadata-fields";
|
||||||
|
let source = "source.txt";
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create bucket");
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source)
|
||||||
|
.cache_control("source-cache")
|
||||||
|
.content_disposition("inline")
|
||||||
|
.content_encoding("br")
|
||||||
|
.content_language("en")
|
||||||
|
.content_type("text/source")
|
||||||
|
.expires(DateTime::from_secs(1_893_456_000))
|
||||||
|
.body(ByteStream::from_static(b"field-by-field"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT failed");
|
||||||
|
let replacement_expires = DateTime::from_secs(1_924_992_000);
|
||||||
|
let replacement_expires_http_date = replacement_expires
|
||||||
|
.fmt(DateTimeFormat::HttpDate)
|
||||||
|
.expect("Test timestamp should format as an HTTP date");
|
||||||
|
|
||||||
|
for field in [
|
||||||
|
"cache-control",
|
||||||
|
"content-disposition",
|
||||||
|
"content-encoding",
|
||||||
|
"content-language",
|
||||||
|
"content-type",
|
||||||
|
"expires",
|
||||||
|
"website-redirect",
|
||||||
|
] {
|
||||||
|
let destination = format!("{field}.txt");
|
||||||
|
let request = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(&destination)
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.metadata_directive(MetadataDirective::Replace);
|
||||||
|
let request = match field {
|
||||||
|
"cache-control" => request.cache_control("field-cache"),
|
||||||
|
"content-disposition" => request.content_disposition("attachment"),
|
||||||
|
"content-encoding" => request.content_encoding("gzip"),
|
||||||
|
"content-language" => request.content_language("de"),
|
||||||
|
"content-type" => request.content_type("text/field"),
|
||||||
|
"expires" => request.expires(replacement_expires),
|
||||||
|
"website-redirect" => request.website_redirect_location("/field.html"),
|
||||||
|
_ => unreachable!("field table contains only supported entries"),
|
||||||
|
};
|
||||||
|
request.send().await.expect("field-specific CopyObject failed");
|
||||||
|
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(&destination)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD failed");
|
||||||
|
assert_eq!(head.cache_control(), (field == "cache-control").then_some("field-cache"));
|
||||||
|
assert_eq!(head.content_disposition(), (field == "content-disposition").then_some("attachment"));
|
||||||
|
assert_eq!(head.content_encoding(), (field == "content-encoding").then_some("gzip"));
|
||||||
|
assert_eq!(head.content_language(), (field == "content-language").then_some("de"));
|
||||||
|
assert_eq!(head.content_type(), (field == "content-type").then_some("text/field"));
|
||||||
|
assert_eq!(
|
||||||
|
head.expires_string(),
|
||||||
|
(field == "expires").then_some(replacement_expires_http_date.as_str())
|
||||||
|
);
|
||||||
|
assert_eq!(head.website_redirect_location(), (field == "website-redirect").then_some("/field.html"));
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("user-metadata-collision.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.metadata_directive(MetadataDirective::Replace)
|
||||||
|
.metadata("content-type", "user-content-type")
|
||||||
|
.metadata("content-encoding", "user-content-encoding")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject should preserve user metadata namespaces");
|
||||||
|
let collision_head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("user-metadata-collision.txt")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD failed for metadata collision case");
|
||||||
|
assert_eq!(collision_head.content_type(), None);
|
||||||
|
assert_eq!(collision_head.content_encoding(), None);
|
||||||
|
assert_eq!(
|
||||||
|
collision_head.metadata().and_then(|metadata| metadata.get("content-type")),
|
||||||
|
Some(&"user-content-type".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
collision_head
|
||||||
|
.metadata()
|
||||||
|
.and_then(|metadata| metadata.get("content-encoding")),
|
||||||
|
Some(&"user-content-encoding".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn copy_object_replace_handles_versioned_multipart_source() {
|
||||||
|
init_logging();
|
||||||
|
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 = env.create_s3_client();
|
||||||
|
let bucket = "copy-object-metadata-multipart";
|
||||||
|
let source = "source.bin";
|
||||||
|
let multipart_body = b"multipart historical source";
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create bucket");
|
||||||
|
client
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(bucket)
|
||||||
|
.versioning_configuration(
|
||||||
|
VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Enabled)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to enable versioning");
|
||||||
|
|
||||||
|
let upload = client
|
||||||
|
.create_multipart_upload()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source)
|
||||||
|
.content_type("application/source")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create multipart upload");
|
||||||
|
let upload_id = upload.upload_id().expect("Multipart upload should return an ID");
|
||||||
|
let part = client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.part_number(1)
|
||||||
|
.body(ByteStream::from_static(multipart_body))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to upload multipart part");
|
||||||
|
let completed = client
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.multipart_upload(
|
||||||
|
CompletedMultipartUpload::builder()
|
||||||
|
.parts(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(1)
|
||||||
|
.e_tag(part.e_tag().expect("Uploaded part should return an ETag"))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to complete multipart upload");
|
||||||
|
let historical_version = completed
|
||||||
|
.version_id()
|
||||||
|
.expect("Versioned multipart upload should return a version ID")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source)
|
||||||
|
.body(ByteStream::from_static(b"new current version"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to write current version");
|
||||||
|
|
||||||
|
let copy = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("restored.bin")
|
||||||
|
.copy_source(format!("{bucket}/{source}?versionId={historical_version}"))
|
||||||
|
.metadata_directive(MetadataDirective::Replace)
|
||||||
|
.content_type("application/replaced")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to copy historical multipart version");
|
||||||
|
assert_eq!(copy.copy_source_version_id(), Some(historical_version.as_str()));
|
||||||
|
|
||||||
|
let restored = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("restored.bin")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to read copied multipart source");
|
||||||
|
assert_eq!(restored.content_type(), Some("application/replaced"));
|
||||||
|
assert_eq!(
|
||||||
|
restored
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("Failed to collect restored body")
|
||||||
|
.into_bytes()
|
||||||
|
.as_ref(),
|
||||||
|
multipart_body
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn invalid_replacement_metadata_does_not_mutate_destination() {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||||
|
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_REJECT_ARCHIVE_CONTENT_ENCODING", "true")])
|
||||||
|
.await
|
||||||
|
.expect("Failed to start RustFS");
|
||||||
|
|
||||||
|
let client = env.create_s3_client();
|
||||||
|
let bucket = "copy-object-invalid-metadata";
|
||||||
|
let key = "destination.zip";
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create bucket");
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.content_type("application/zip")
|
||||||
|
.metadata("state", "original")
|
||||||
|
.body(ByteStream::from_static(b"original destination"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to write destination");
|
||||||
|
|
||||||
|
let error = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.metadata_directive(MetadataDirective::Replace)
|
||||||
|
.content_type("application/zip")
|
||||||
|
.content_encoding("gzip")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("Invalid replacement metadata should be rejected");
|
||||||
|
assert_eq!(error.as_service_error().and_then(|err| err.code()), Some("InvalidArgument"));
|
||||||
|
|
||||||
|
let invalid_directive = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.copy_source(format!("{bucket}/{key}"))
|
||||||
|
.customize()
|
||||||
|
.mutate_request(|request| {
|
||||||
|
request.headers_mut().insert("x-amz-metadata-directive", "UNKNOWN");
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("Unknown metadata directives should be rejected");
|
||||||
|
assert_eq!(
|
||||||
|
invalid_directive.as_service_error().and_then(|error| error.code()),
|
||||||
|
Some("InvalidArgument")
|
||||||
|
);
|
||||||
|
|
||||||
|
let unchanged = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Destination should remain readable");
|
||||||
|
assert_eq!(unchanged.content_type(), Some("application/zip"));
|
||||||
|
assert_eq!(
|
||||||
|
unchanged.metadata().and_then(|metadata| metadata.get("state")),
|
||||||
|
Some(&"original".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
unchanged
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("Failed to collect destination body")
|
||||||
|
.into_bytes()
|
||||||
|
.as_ref(),
|
||||||
|
b"original destination"
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,468 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! CopyObject tagging directive regression tests.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::types::{BucketVersioningStatus, MetadataDirective, TaggingDirective, VersioningConfiguration};
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
async fn object_tags(client: &Client, bucket: &str, key: &str) -> BTreeMap<String, String> {
|
||||||
|
client
|
||||||
|
.get_object_tagging()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GetObjectTagging should succeed")
|
||||||
|
.tag_set()
|
||||||
|
.iter()
|
||||||
|
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn copy_object_applies_copy_replace_and_empty_tagging_directives() {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new()
|
||||||
|
.await
|
||||||
|
.expect("test environment should initialize");
|
||||||
|
env.start_rustfs_server(vec![]).await.expect("RustFS should start");
|
||||||
|
|
||||||
|
let client = env.create_s3_client();
|
||||||
|
let bucket = "copy-object-tagging-directive";
|
||||||
|
let source = "source.txt";
|
||||||
|
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("bucket creation should succeed");
|
||||||
|
client
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(bucket)
|
||||||
|
.versioning_configuration(
|
||||||
|
VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Enabled)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("versioning should be enabled");
|
||||||
|
|
||||||
|
let first_version = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source)
|
||||||
|
.tagging("project=rustfs&stage=first")
|
||||||
|
.body(ByteStream::from_static(b"first"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("first source version should be written")
|
||||||
|
.version_id()
|
||||||
|
.expect("versioned PUT should return a version ID")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source)
|
||||||
|
.tagging("project=rustfs&stage=current")
|
||||||
|
.body(ByteStream::from_static(b"current"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("current source version should be written");
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("default-copy.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("default CopyObject should preserve current source tags");
|
||||||
|
assert_eq!(
|
||||||
|
object_tags(&client, bucket, "default-copy.txt").await,
|
||||||
|
BTreeMap::from([
|
||||||
|
("project".to_string(), "rustfs".to_string()),
|
||||||
|
("stage".to_string(), "current".to_string()),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("explicit-copy.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}?versionId={first_version}"))
|
||||||
|
.tagging_directive(TaggingDirective::Copy)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("COPY should preserve the selected historical version's tags");
|
||||||
|
assert_eq!(
|
||||||
|
object_tags(&client, bucket, "explicit-copy.txt").await,
|
||||||
|
BTreeMap::from([
|
||||||
|
("project".to_string(), "rustfs".to_string()),
|
||||||
|
("stage".to_string(), "first".to_string()),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("replace.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.tagging_directive(TaggingDirective::Replace)
|
||||||
|
.tagging("project=cli&label=copy%20test")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("REPLACE should atomically apply requested tags");
|
||||||
|
assert_eq!(
|
||||||
|
object_tags(&client, bucket, "replace.txt").await,
|
||||||
|
BTreeMap::from([
|
||||||
|
("label".to_string(), "copy test".to_string()),
|
||||||
|
("project".to_string(), "cli".to_string()),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
let replace_head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("replace.txt")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD should succeed after tag replacement");
|
||||||
|
assert_eq!(replace_head.tag_count(), Some(2));
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("empty-replace.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.tagging_directive(TaggingDirective::Replace)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("REPLACE without Tagging should clear the destination tag set");
|
||||||
|
assert!(object_tags(&client, bucket, "empty-replace.txt").await.is_empty());
|
||||||
|
let empty_head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("empty-replace.txt")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD should succeed after empty tag replacement");
|
||||||
|
assert_eq!(empty_head.tag_count(), None);
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("metadata-replace-tag-copy.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.metadata_directive(MetadataDirective::Replace)
|
||||||
|
.metadata("updated", "true")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("metadata REPLACE must preserve tags under the default COPY directive");
|
||||||
|
assert_eq!(
|
||||||
|
object_tags(&client, bucket, "metadata-replace-tag-copy.txt").await,
|
||||||
|
BTreeMap::from([
|
||||||
|
("project".to_string(), "rustfs".to_string()),
|
||||||
|
("stage".to_string(), "current".to_string()),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("combined-replace.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.metadata_directive(MetadataDirective::Replace)
|
||||||
|
.metadata("updated", "true")
|
||||||
|
.tagging_directive(TaggingDirective::Replace)
|
||||||
|
.tagging("project=combined")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("metadata and tagging REPLACE directives must be independent");
|
||||||
|
assert_eq!(
|
||||||
|
object_tags(&client, bucket, "combined-replace.txt").await,
|
||||||
|
BTreeMap::from([("project".to_string(), "combined".to_string())])
|
||||||
|
);
|
||||||
|
|
||||||
|
client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source)
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.tagging_directive(TaggingDirective::Replace)
|
||||||
|
.tagging("project=self-copy")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("self-copy with tag replacement should update tags atomically");
|
||||||
|
assert_eq!(
|
||||||
|
object_tags(&client, bucket, source).await,
|
||||||
|
BTreeMap::from([("project".to_string(), "self-copy".to_string())])
|
||||||
|
);
|
||||||
|
let self_copy_body = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(source)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("self-copy destination should remain readable")
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("self-copy body should be complete")
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(self_copy_body.as_ref(), b"current", "tag-only self-copy must preserve the object body");
|
||||||
|
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("malformed.txt")
|
||||||
|
.tagging("state=original")
|
||||||
|
.body(ByteStream::from_static(b"original destination"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("preexisting malformed-test destination should be written");
|
||||||
|
|
||||||
|
let malformed = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("malformed.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.tagging_directive(TaggingDirective::Replace)
|
||||||
|
.tagging("project=rustfs%ZZ")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("malformed tags must fail CopyObject");
|
||||||
|
assert_eq!(malformed.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidTag"));
|
||||||
|
assert_eq!(
|
||||||
|
object_tags(&client, bucket, "malformed.txt").await,
|
||||||
|
BTreeMap::from([("state".to_string(), "original".to_string())])
|
||||||
|
);
|
||||||
|
let preserved_body = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("malformed.txt")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("malformed tags must not replace an existing destination")
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.expect("preserved destination body should be readable")
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(
|
||||||
|
preserved_body.as_ref(),
|
||||||
|
b"original destination",
|
||||||
|
"malformed tags must leave destination data unchanged"
|
||||||
|
);
|
||||||
|
|
||||||
|
let discarded = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("discarded.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.tagging("project=must-not-be-discarded")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("Tagging without REPLACE must fail instead of discarding requested tags");
|
||||||
|
assert_eq!(discarded.as_service_error().and_then(ProvideErrorMetadata::code), Some("InvalidRequest"));
|
||||||
|
|
||||||
|
let invalid_directive = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key("invalid-directive.txt")
|
||||||
|
.copy_source(format!("{bucket}/{source}"))
|
||||||
|
.tagging_directive(TaggingDirective::from("UNKNOWN"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an unknown TaggingDirective must fail");
|
||||||
|
assert_eq!(
|
||||||
|
invalid_directive.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("InvalidArgument")
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn copy_object_tag_replacement_honors_request_tag_policy_denial() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||||
|
{
|
||||||
|
init_logging();
|
||||||
|
let source_bucket = "copy-tags-policy-source";
|
||||||
|
let destination_bucket = "copy-tags-policy-destination";
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server(vec![]).await?;
|
||||||
|
let admin = env.create_s3_client();
|
||||||
|
admin.create_bucket().bucket(source_bucket).send().await?;
|
||||||
|
admin.create_bucket().bucket(destination_bucket).send().await?;
|
||||||
|
admin
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("source.txt")
|
||||||
|
.tagging("source=allowed")
|
||||||
|
.body(ByteStream::from_static(b"source"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
admin
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("conditioned.txt")
|
||||||
|
.body(ByteStream::from_static(b"conditioned source"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let source_policy = serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": [format!("arn:aws:s3:::{source_bucket}/source.txt")]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": ["s3:GetObject"],
|
||||||
|
"Resource": [format!("arn:aws:s3:::{source_bucket}/conditioned.txt")],
|
||||||
|
"Condition": {
|
||||||
|
"StringEquals": {
|
||||||
|
"s3:RequestObjectTag/classification": "public"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
admin
|
||||||
|
.put_bucket_policy()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.policy(source_policy)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let destination_policy = serde_json::json!({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [
|
||||||
|
{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": ["s3:PutObject"],
|
||||||
|
"Resource": [format!("arn:aws:s3:::{destination_bucket}/*")]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Effect": "Deny",
|
||||||
|
"Principal": "*",
|
||||||
|
"Action": ["s3:PutObject"],
|
||||||
|
"Resource": [format!("arn:aws:s3:::{destination_bucket}/*")],
|
||||||
|
"Condition": {
|
||||||
|
"StringEquals": {
|
||||||
|
"s3:RequestObjectTag/classification": "restricted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
admin
|
||||||
|
.put_bucket_policy()
|
||||||
|
.bucket(destination_bucket)
|
||||||
|
.policy(destination_policy)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let copy_source = format!("/{source_bucket}/source.txt");
|
||||||
|
let allowed = local_http_client()
|
||||||
|
.put(format!("{}/{destination_bucket}/allowed.txt", env.url))
|
||||||
|
.header("x-amz-copy-source", ©_source)
|
||||||
|
.header("x-amz-tagging-directive", "REPLACE")
|
||||||
|
.header("x-amz-tagging", "classification=public")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
allowed.status(),
|
||||||
|
reqwest::StatusCode::OK,
|
||||||
|
"a tag set allowed by the request-tag policy should copy successfully"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
object_tags(&admin, destination_bucket, "allowed.txt").await,
|
||||||
|
BTreeMap::from([("classification".to_string(), "public".to_string())])
|
||||||
|
);
|
||||||
|
|
||||||
|
let denied = local_http_client()
|
||||||
|
.put(format!("{}/{destination_bucket}/denied.txt", env.url))
|
||||||
|
.header("x-amz-copy-source", copy_source)
|
||||||
|
.header("x-amz-tagging-directive", "REPLACE")
|
||||||
|
.header("x-amz-tagging", "classification=restricted")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
denied.status(),
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"CopyObject must honor a request-tag policy Deny"
|
||||||
|
);
|
||||||
|
|
||||||
|
let source_condition_bypass = local_http_client()
|
||||||
|
.put(format!("{}/{destination_bucket}/source-condition.txt", env.url))
|
||||||
|
.header("x-amz-copy-source", format!("/{source_bucket}/conditioned.txt"))
|
||||||
|
.header("x-amz-tagging-directive", "REPLACE")
|
||||||
|
.header("x-amz-tagging", "classification=public")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_eq!(
|
||||||
|
source_condition_bypass.status(),
|
||||||
|
reqwest::StatusCode::FORBIDDEN,
|
||||||
|
"destination request tags must not satisfy source GetObject policy conditions"
|
||||||
|
);
|
||||||
|
|
||||||
|
let missing_destination = admin
|
||||||
|
.head_object()
|
||||||
|
.bucket(destination_bucket)
|
||||||
|
.key("denied.txt")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an access-denied copy must not create a destination object");
|
||||||
|
assert_eq!(
|
||||||
|
missing_destination.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NotFound")
|
||||||
|
);
|
||||||
|
let missing_bypass_destination = admin
|
||||||
|
.head_object()
|
||||||
|
.bucket(destination_bucket)
|
||||||
|
.key("source-condition.txt")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a source authorization denial must not create a destination object");
|
||||||
|
assert_eq!(
|
||||||
|
missing_bypass_destination
|
||||||
|
.as_service_error()
|
||||||
|
.and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NotFound")
|
||||||
|
);
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,4 +160,146 @@ mod tests {
|
|||||||
|
|
||||||
env.stop_server();
|
env.stop_server();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for Issue #4976: a versioned-source CopyObject must echo the exact source
|
||||||
|
/// version copied via `x-amz-copy-source-version-id` (SDK `CopySourceVersionId`), kept distinct
|
||||||
|
/// from the newly created destination `x-amz-version-id`.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_copy_of_non_latest_source_version_returns_copy_source_version_id() {
|
||||||
|
init_logging();
|
||||||
|
info!("Issue #4976: versioned CopyObject must return x-amz-copy-source-version-id for the exact source version");
|
||||||
|
|
||||||
|
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 = env.create_s3_client();
|
||||||
|
let src_bucket = "copy-source-version-header-src";
|
||||||
|
let dst_bucket = "copy-source-version-header-dst";
|
||||||
|
let src_key = "reports/quarantined.bin";
|
||||||
|
let dst_key = "reports/promoted.bin";
|
||||||
|
|
||||||
|
for bucket in [src_bucket, dst_bucket] {
|
||||||
|
client
|
||||||
|
.create_bucket()
|
||||||
|
.bucket(bucket)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to create bucket");
|
||||||
|
client
|
||||||
|
.put_bucket_versioning()
|
||||||
|
.bucket(bucket)
|
||||||
|
.versioning_configuration(
|
||||||
|
VersioningConfiguration::builder()
|
||||||
|
.status(BucketVersioningStatus::Enabled)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("Failed to enable versioning");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source version 1: the exact (non-latest) version we will copy.
|
||||||
|
let v1_content = b"quarantined payload -- source version one (target of the copy)";
|
||||||
|
let put_v1 = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(src_bucket)
|
||||||
|
.key(src_key)
|
||||||
|
.body(ByteStream::from_static(v1_content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT source v1 failed");
|
||||||
|
let v1_id = put_v1.version_id().expect("source v1 must have a version id").to_string();
|
||||||
|
|
||||||
|
// Source version 2: becomes the latest, so v1 is deliberately NOT the current version.
|
||||||
|
let v2_content = b"quarantined payload -- source version two (now current, must be ignored)";
|
||||||
|
let put_v2 = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(src_bucket)
|
||||||
|
.key(src_key)
|
||||||
|
.body(ByteStream::from_static(v2_content))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("PUT source v2 failed");
|
||||||
|
let v2_id = put_v2.version_id().expect("source v2 must have a version id").to_string();
|
||||||
|
assert_ne!(v1_id, v2_id, "the two source puts must produce distinct versions");
|
||||||
|
|
||||||
|
// Copy the exact NON-LATEST source version into the destination bucket.
|
||||||
|
let copy_out = client
|
||||||
|
.copy_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.copy_source(format!("{src_bucket}/{src_key}?versionId={v1_id}"))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("CopyObject of a versioned source must succeed");
|
||||||
|
|
||||||
|
// (3) The response must echo the exact source version copied.
|
||||||
|
let copy_source_version_id = copy_out
|
||||||
|
.copy_source_version_id()
|
||||||
|
.expect("issue #4976: response must include x-amz-copy-source-version-id for a versioned source");
|
||||||
|
assert_eq!(
|
||||||
|
copy_source_version_id, v1_id,
|
||||||
|
"x-amz-copy-source-version-id must equal the exact source version requested, not the latest source version"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
copy_source_version_id, v2_id,
|
||||||
|
"x-amz-copy-source-version-id must not be the latest source version"
|
||||||
|
);
|
||||||
|
|
||||||
|
// (4) The destination header must identify a distinct, newly created version.
|
||||||
|
let dst_version_id = copy_out
|
||||||
|
.version_id()
|
||||||
|
.expect("destination copy must create a new version id")
|
||||||
|
.to_string();
|
||||||
|
assert!(!dst_version_id.is_empty(), "destination version id must be present");
|
||||||
|
assert_ne!(dst_version_id, v1_id, "destination version must be distinct from the source version");
|
||||||
|
assert_ne!(
|
||||||
|
dst_version_id, v2_id,
|
||||||
|
"destination version must be distinct from the source latest version"
|
||||||
|
);
|
||||||
|
|
||||||
|
// (5) The destination must hold the exact bytes/size of the copied (v1) source version.
|
||||||
|
let head = client
|
||||||
|
.head_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("HEAD destination failed");
|
||||||
|
assert_eq!(
|
||||||
|
head.content_length(),
|
||||||
|
Some(v1_content.len() as i64),
|
||||||
|
"destination size must equal the copied source version (v1)"
|
||||||
|
);
|
||||||
|
|
||||||
|
let get_dst = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(dst_bucket)
|
||||||
|
.key(dst_key)
|
||||||
|
.version_id(&dst_version_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET destination failed");
|
||||||
|
let dst_body = get_dst.body.collect().await.expect("collect destination body").into_bytes();
|
||||||
|
assert_eq!(
|
||||||
|
dst_body.as_ref(),
|
||||||
|
v1_content,
|
||||||
|
"destination bytes must exactly equal the copied source version (v1), not the latest (v2)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// (6) The source version copied from must remain present and independently readable.
|
||||||
|
let get_src_v1 = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(src_bucket)
|
||||||
|
.key(src_key)
|
||||||
|
.version_id(&v1_id)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("GET source v1 failed after copy");
|
||||||
|
let src_v1_body = get_src_v1.body.collect().await.expect("collect source v1 body").into_bytes();
|
||||||
|
assert_eq!(src_v1_body.as_ref(), v1_content, "source v1 must remain intact after the copy");
|
||||||
|
|
||||||
|
env.stop_server();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,21 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn assert_current_list_hides_delete_marker(client: &Client, bucket: &str, key: &str) {
|
||||||
|
let listed = client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(bucket)
|
||||||
|
.prefix(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("list current objects after delete marker");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
listed.contents().iter().all(|object| object.key() != Some(key)),
|
||||||
|
"ListObjectsV2 must hide an object whose latest version is a delete marker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn test_versioning_only_delete_marker_has_minio_compatible_visibility_for_migration_proof() {
|
async fn test_versioning_only_delete_marker_has_minio_compatible_visibility_for_migration_proof() {
|
||||||
@@ -94,6 +109,7 @@ mod tests {
|
|||||||
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
||||||
assert_eq!(markers[0].is_latest(), Some(true));
|
assert_eq!(markers[0].is_latest(), Some(true));
|
||||||
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
||||||
|
assert_current_list_hides_delete_marker(&client, bucket, key).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -118,6 +134,17 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("put historical version");
|
.expect("put historical version");
|
||||||
let data_version_id = put.version_id().expect("put should return data version id");
|
let data_version_id = put.version_id().expect("put should return data version id");
|
||||||
|
let listed_before_delete = client
|
||||||
|
.list_objects_v2()
|
||||||
|
.bucket(bucket)
|
||||||
|
.prefix(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("list current object before creating delete marker");
|
||||||
|
assert!(
|
||||||
|
listed_before_delete.contents().iter().any(|object| object.key() == Some(key)),
|
||||||
|
"ListObjectsV2 must include the current object before it is deleted"
|
||||||
|
);
|
||||||
|
|
||||||
let delete_marker = client
|
let delete_marker = client
|
||||||
.delete_object()
|
.delete_object()
|
||||||
@@ -145,6 +172,7 @@ mod tests {
|
|||||||
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
||||||
assert_eq!(markers[0].is_latest(), Some(true));
|
assert_eq!(markers[0].is_latest(), Some(true));
|
||||||
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
||||||
|
assert_current_list_hides_delete_marker(&client, bucket, key).await;
|
||||||
|
|
||||||
let historical = client
|
let historical = client
|
||||||
.get_object()
|
.get_object()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use hyper::body::Incoming;
|
|||||||
use hyper::server::conn::http1;
|
use hyper::server::conn::http1;
|
||||||
use hyper::service::service_fn;
|
use hyper::service::service_fn;
|
||||||
use hyper_util::rt::{TokioIo, TokioTimer};
|
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||||
|
use md5::{Digest as Md5Digest, Md5};
|
||||||
use s3s::access::{S3Access, S3AccessContext};
|
use s3s::access::{S3Access, S3AccessContext};
|
||||||
use s3s::auth::SimpleAuth;
|
use s3s::auth::SimpleAuth;
|
||||||
use s3s::dto::{
|
use s3s::dto::{
|
||||||
@@ -827,13 +828,25 @@ fn ensure_body_growth(current: usize, added: usize) -> S3Result {
|
|||||||
|
|
||||||
async fn md5_digest(body: Bytes, permit: OwnedSemaphorePermit) -> S3Result<([u8; 16], OwnedSemaphorePermit)> {
|
async fn md5_digest(body: Bytes, permit: OwnedSemaphorePermit) -> S3Result<([u8; 16], OwnedSemaphorePermit)> {
|
||||||
if body.len() < 1024 * 1024 {
|
if body.len() < 1024 * 1024 {
|
||||||
return Ok((md5::compute(body).0, permit));
|
return Ok((md5_bytes(body), permit));
|
||||||
}
|
}
|
||||||
tokio::task::spawn_blocking(move || (md5::compute(body).0, permit))
|
tokio::task::spawn_blocking(move || (md5_bytes(body), permit))
|
||||||
.await
|
.await
|
||||||
.map_err(|error| s3s::s3_error!(InternalError, "MD5 worker failed: {error}"))
|
.map_err(|error| s3s::s3_error!(InternalError, "MD5 worker failed: {error}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn md5_bytes(input: impl AsRef<[u8]>) -> [u8; 16] {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(input.as_ref());
|
||||||
|
hasher.finalize().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn md5_hex(input: impl AsRef<[u8]>) -> String {
|
||||||
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(input.as_ref());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result {
|
fn ensure_store_budget(state: &StoreState, removed_bytes: usize, added_bytes: usize, adds_version: bool) -> S3Result {
|
||||||
let total_bytes = state
|
let total_bytes = state
|
||||||
.total_bytes
|
.total_bytes
|
||||||
@@ -1005,7 +1018,7 @@ impl S3 for FakeBackend {
|
|||||||
Some(value) => value,
|
Some(value) => value,
|
||||||
None => {
|
None => {
|
||||||
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
||||||
format!("{:x}", md5::Digest(digest))
|
hex::encode(digest)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let version = ObjectVersion {
|
let version = ObjectVersion {
|
||||||
@@ -1208,7 +1221,7 @@ impl S3 for FakeBackend {
|
|||||||
}
|
}
|
||||||
let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?;
|
let body = collect_stream(input.body, input.content_length, fault.as_ref(), &self.control).await?;
|
||||||
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
let (digest, _body_permit) = md5_digest(body.clone(), _body_permit).await?;
|
||||||
let e_tag = format!("{:x}", md5::Digest(digest));
|
let e_tag = hex::encode(digest);
|
||||||
let mut state = lock(&self.store);
|
let mut state = lock(&self.store);
|
||||||
let existing_bytes = state
|
let existing_bytes = state
|
||||||
.uploads
|
.uploads
|
||||||
@@ -1336,7 +1349,7 @@ impl S3 for FakeBackend {
|
|||||||
.collect();
|
.collect();
|
||||||
let (body, digests, _body_permits) = assemble_multipart(assembly_parts, total_len, _body_permits).await?;
|
let (body, digests, _body_permits) = assemble_multipart(assembly_parts, total_len, _body_permits).await?;
|
||||||
let part_count = requested.len();
|
let part_count = requested.len();
|
||||||
let e_tag = source_etag(&headers)?.unwrap_or_else(|| format!("{:x}-{part_count}", md5::compute(digests)));
|
let e_tag = source_etag(&headers)?.unwrap_or_else(|| format!("{}-{part_count}", md5_hex(digests)));
|
||||||
let version = ObjectVersion {
|
let version = ObjectVersion {
|
||||||
version_id: upload.version_id.clone(),
|
version_id: upload.version_id.clone(),
|
||||||
body,
|
body,
|
||||||
|
|||||||
@@ -45,10 +45,10 @@
|
|||||||
//! * Parity reconstruction: one data disk is taken offline
|
//! * Parity reconstruction: one data disk is taken offline
|
||||||
//! (`take_disk_offline`) and the SAME object matrix is GET both ways while
|
//! (`take_disk_offline`) and the SAME object matrix is GET both ways while
|
||||||
//! the EC 2+2 set rebuilds each large object from the surviving shards. The
|
//! the EC 2+2 set rebuilds each large object from the surviving shards. The
|
||||||
//! codec-streaming reader gate never inspects drive health, so the codec
|
//! eager first/single-part setup may keep its conservative whole-request
|
||||||
//! fast path is exercised end-to-end through reconstruction; the test
|
//! fallback when shard placement makes codec streaming unsafe, so this phase
|
||||||
//! asserts byte- and header-equality vs the legacy path AND that the codec
|
//! asserts byte- and header-equality vs the legacy path rather than requiring
|
||||||
//! phase never fell back to a duplex pipe while reconstructing.
|
//! zero duplex fallbacks under degraded drive health.
|
||||||
//! * Missing object: a GET for an absent key is compared across both phases
|
//! * Missing object: a GET for an absent key is compared across both phases
|
||||||
//! to prove the error semantics (HTTP status + S3 error code) are identical
|
//! to prove the error semantics (HTTP status + S3 error code) are identical
|
||||||
//! — the codec env must not perturb the NoSuchKey negative path.
|
//! — the codec env must not perturb the NoSuchKey negative path.
|
||||||
@@ -475,15 +475,14 @@ mod tests {
|
|||||||
"ranged GET length diverged with codec streaming enabled"
|
"ranged GET length diverged with codec streaming enabled"
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Phase B degraded: the same reconstruction, now on the codec path ----
|
// ---- Phase B degraded: the same reconstruction, with codec gates open ----
|
||||||
// Re-run the reconstruction A/B with the codec-streaming gates still
|
// Re-run the reconstruction A/B with codec-streaming enabled. If eager
|
||||||
// open. The reader gate decision is independent of drive health (it
|
// first/single-part setup cannot prove the codec path is safe for the
|
||||||
// never inspects disk state), so the codec fast path is exercised
|
// surviving shards, the implementation intentionally preserves the
|
||||||
// end-to-end while the EC set rebuilds each large object from the
|
// whole-request legacy fallback; later multipart parts can degrade in
|
||||||
// surviving shards — this is a real codec-vs-legacy reconstruction test,
|
// place. This phase verifies parity-reconstructed bytes and headers,
|
||||||
// not legacy-vs-legacy. Snapshot the duplex count first (the range GET
|
// while the healthy phase above remains the strict zero-duplex path
|
||||||
// above already used the duplex path) so we can measure only the markers
|
// confirmation.
|
||||||
// these degraded codec GETs add.
|
|
||||||
let dup_codec_before_degraded = count_marker(&codec_log, DUPLEX_MARKER);
|
let dup_codec_before_degraded = count_marker(&codec_log, DUPLEX_MARKER);
|
||||||
harness.take_disk_offline(0)?;
|
harness.take_disk_offline(0)?;
|
||||||
let mut codec_degraded: BTreeMap<String, GetView> = BTreeMap::new();
|
let mut codec_degraded: BTreeMap<String, GetView> = BTreeMap::new();
|
||||||
@@ -511,16 +510,11 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path confirmation under reconstruction: the codec fast path must have
|
// Keep degraded duplex markers as diagnostic evidence only: eager setup
|
||||||
// served the reconstructed large objects without ever falling back to
|
// may fall back before streaming when shard safety cannot be proven.
|
||||||
// the legacy duplex pipe. Without this, the equivalence above could be
|
|
||||||
// legacy-vs-legacy and prove nothing about codec reconstruction.
|
|
||||||
sleep(Duration::from_millis(300)).await;
|
sleep(Duration::from_millis(300)).await;
|
||||||
let dup_codec_degraded = count_marker(&codec_log, DUPLEX_MARKER).saturating_sub(dup_codec_before_degraded);
|
let dup_codec_degraded = count_marker(&codec_log, DUPLEX_MARKER).saturating_sub(dup_codec_before_degraded);
|
||||||
assert_eq!(
|
info!(dup_codec_degraded, "codec phase degraded-read legacy duplex marker count");
|
||||||
dup_codec_degraded, 0,
|
|
||||||
"codec phase created {dup_codec_degraded} duplex pipe(s) while reconstructing large objects with disk0 offline; the codec fast path was not exercised under degraded reads (see {codec_log})"
|
|
||||||
);
|
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
objects = baseline.len(),
|
objects = baseline.len(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,776 @@
|
|||||||
|
#![cfg(test)]
|
||||||
|
// Copyright 2024 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
//! Cross-process replay / tamper acceptance for internode NodeService RPC
|
||||||
|
//! signatures (<https://github.com/rustfs/backlog/issues/1327>,
|
||||||
|
//! <https://github.com/rustfs/backlog/issues/1542>).
|
||||||
|
//!
|
||||||
|
//! # Why this exists on top of the in-process tests
|
||||||
|
//!
|
||||||
|
//! `http_auth.rs` unit-tests the signature algebra by calling the verifier
|
||||||
|
//! directly. That proves the crypto, but it cannot prove that a *deployed*
|
||||||
|
//! server actually reaches it: the request has to survive the hybrid HTTP/gRPC
|
||||||
|
//! router, `check_auth`, tonic's own metadata handling, and finally the
|
||||||
|
//! per-handler body-digest gate. A handler that forgets its
|
||||||
|
//! `verify_disk_mutation_digest` call, or a router change that bypasses
|
||||||
|
//! `check_auth`, is invisible in-process and wide open in production. These
|
||||||
|
//! tests drive a real `rustfs` child process over a real TCP socket, so every
|
||||||
|
//! one of those layers is in the path.
|
||||||
|
//!
|
||||||
|
//! # Attacker model
|
||||||
|
//!
|
||||||
|
//! The adversary is on-path: it observed one legitimately signed request and
|
||||||
|
//! can resend, retarget, or edit those bytes — including individual headers.
|
||||||
|
//! It does **not** hold the RPC secret. The test process does hold the secret,
|
||||||
|
//! but uses it for exactly one purpose: minting the request that stands in for
|
||||||
|
//! the captured one. Every attack then only *reuses or edits* an already-minted
|
||||||
|
//! header set; no attack step ever re-signs. If any of these tests could pass
|
||||||
|
//! by re-signing, it would be testing nothing.
|
||||||
|
//!
|
||||||
|
//! # Isolating one variable at a time
|
||||||
|
//!
|
||||||
|
//! Each rejection is paired with an acceptance that differs in exactly one
|
||||||
|
//! respect, because a misconfigured harness (wrong audience, dead server,
|
||||||
|
//! ambient strict env) would otherwise make every "rejected" assertion pass
|
||||||
|
//! vacuously. Two pairings carry most of the weight:
|
||||||
|
//!
|
||||||
|
//! - Editing the body alone is caught by the *handler* (`PermissionDenied`);
|
||||||
|
//! editing the body **and** repairing the digest header to match is caught by
|
||||||
|
//! the *signature* (`Unauthenticated`). The second only fails closed if the
|
||||||
|
//! digest is genuinely inside the signed scope, so the pair pins both layers.
|
||||||
|
//! - Replaying a captured nonce is caught by the replay cache; swapping in a
|
||||||
|
//! fresh nonce is caught by the signature. Again, only the pair proves the
|
||||||
|
//! nonce is signed rather than merely cached.
|
||||||
|
//!
|
||||||
|
//! # Why `MakeVolume` against a non-existent disk
|
||||||
|
//!
|
||||||
|
//! Every covered handler checks the digest before touching storage, and
|
||||||
|
//! `MakeVolume` resolves its disk *after* that check. Aiming at a disk that
|
||||||
|
//! cannot exist gives three cleanly separable outcomes with zero side effects
|
||||||
|
//! on the server's real data:
|
||||||
|
//!
|
||||||
|
//! - `Err(Unauthenticated)` — rejected by `check_auth` (signature layer).
|
||||||
|
//! - `Err(PermissionDenied)` — rejected by the handler's body-digest gate.
|
||||||
|
//! - `Ok(success: false)` — **authentication passed**; the request reached
|
||||||
|
//! handler logic and only then failed on the bogus disk.
|
||||||
|
//!
|
||||||
|
//! # Coverage of the issue's acceptance matrix
|
||||||
|
//!
|
||||||
|
//! | Acceptance item | Test |
|
||||||
|
//! |---|---|
|
||||||
|
//! | replay a signature onto another method → reject | [`cross_method_signature_transplant_is_rejected`] |
|
||||||
|
//! | replay same method + body after nonce consumed → reject | [`nonce_replay_of_a_captured_mutation_is_rejected`] |
|
||||||
|
//! | nonce is signed, not just cached → reject a swapped nonce | [`swapping_in_a_fresh_nonce_is_rejected`] |
|
||||||
|
//! | tamper one byte of the body → reject | [`tampered_mutation_body_is_rejected`] |
|
||||||
|
//! | body digest is inside the signed scope → reject a repaired digest | [`rewriting_the_digest_to_match_a_tampered_body_is_rejected`] |
|
||||||
|
//! | wrong destination node identity → reject | [`signature_minted_for_another_node_is_rejected`] |
|
||||||
|
//! | mixed version: legacy-only still served, not blocked | [`legacy_only_signature_is_accepted_in_default_posture`] |
|
||||||
|
//! | strict flip closes the signature downgrade | [`signature_strict_rejects_legacy_only_downgrade`] |
|
||||||
|
//! | strict flip closes the body-digest downgrade, incl. v1 | [`body_digest_strict_rejects_digestless_mutation`] |
|
||||||
|
//! | replay scope binds every RPC and rejects restart replay | [`replay_scope_rejects_replay_path_transplant_and_stale_epoch_e2e`] |
|
||||||
|
//! | strict replay scope allows only Ping bootstrap before v3 | [`replay_scope_strict_requires_v3_after_ping_bootstrap_e2e`] |
|
||||||
|
//!
|
||||||
|
//! Two acceptance items are deliberately left to the in-process tests. A stale
|
||||||
|
//! timestamp cannot be forged from outside — it is inside the HMAC — so
|
||||||
|
//! observing it would mean idling out the full freshness window. And the
|
||||||
|
//! `signature_v1_fallback_total` / `body_digest_fallback_total` counter deltas
|
||||||
|
//! that gate the strict flips are asserted directly in `http_auth.rs`; the
|
||||||
|
//! legacy test below proves only the *accepted* half of that behaviour.
|
||||||
|
|
||||||
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
|
use crate::storage_api::internode_rpc_signature::{
|
||||||
|
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
||||||
|
node_service_time_out_client_no_auth, verify_tonic_boot_epoch_response,
|
||||||
|
};
|
||||||
|
use http::{HeaderMap, Method};
|
||||||
|
use rustfs_config::{
|
||||||
|
ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT,
|
||||||
|
ENV_INTERNODE_RPC_SIGNATURE_STRICT,
|
||||||
|
};
|
||||||
|
use rustfs_protos::canonical_make_volume_request_body;
|
||||||
|
use rustfs_protos::proto_gen::node_service::{MakeVolumeRequest, MakeVolumeResponse, PingRequest, PingResponse};
|
||||||
|
use serial_test::serial;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::error::Error;
|
||||||
|
use tonic::{Code, Request, Response, Status};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||||
|
|
||||||
|
/// Shared internode secret handed to both the child server and this process.
|
||||||
|
///
|
||||||
|
/// Must not be the default credential: `resolve_rpc_secret` fails closed on
|
||||||
|
/// defaults (GHSA-r5qv), so a default here would break every request rather
|
||||||
|
/// than test anything.
|
||||||
|
const TEST_RPC_SECRET: &str = "rustfs-internode-signature-e2e-secret";
|
||||||
|
|
||||||
|
/// A disk path the server cannot possibly have configured, so a request that
|
||||||
|
/// clears authentication stops harmlessly at `find_disk`.
|
||||||
|
const ABSENT_DISK: &str = "/nonexistent/rustfs-signature-e2e-disk";
|
||||||
|
|
||||||
|
/// Wire names of the v2 and replay-scope headers these black-box tests edit. They are
|
||||||
|
/// `pub(crate)` in ecstore, so they are repeated here rather than imported.
|
||||||
|
/// [`overwrite_header`] asserts the header it replaces was actually present, which turns a
|
||||||
|
/// rename into a loud failure instead of silently reducing an attack to a no-op.
|
||||||
|
const CONTENT_SHA256_HEADER: &str = "x-rustfs-content-sha256";
|
||||||
|
const NONCE_HEADER: &str = "x-rustfs-rpc-nonce";
|
||||||
|
const TIMESTAMP_HEADER: &str = "x-rustfs-timestamp";
|
||||||
|
const BOOT_EPOCH_CHALLENGE_HEADER: &str = "x-rustfs-rpc-boot-epoch-challenge";
|
||||||
|
|
||||||
|
/// gRPC service name carried in the signed scope, i.e. `TONIC_RPC_PREFIX`
|
||||||
|
/// without its leading `/`.
|
||||||
|
fn node_service_name() -> &'static str {
|
||||||
|
TONIC_RPC_PREFIX.trim_start_matches('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the RPC secret of this test process match the child server's.
|
||||||
|
///
|
||||||
|
/// The secret lands in a process-wide `OnceLock`, so the first writer wins for
|
||||||
|
/// the whole test binary. Every test here uses the same constant, and the
|
||||||
|
/// assertion turns a cross-test collision into an explicit failure instead of a
|
||||||
|
/// confusing wall of signature rejections.
|
||||||
|
fn align_rpc_secret_with_server() {
|
||||||
|
let _ = rustfs_credentials::set_global_rpc_secret(TEST_RPC_SECRET.to_string());
|
||||||
|
let effective = rustfs_credentials::try_get_rpc_token().expect("RPC secret must resolve in the test process");
|
||||||
|
assert_eq!(
|
||||||
|
effective, TEST_RPC_SECRET,
|
||||||
|
"another test in this binary already fixed a different process-wide RPC secret; \
|
||||||
|
the signature tests cannot mint requests the child server will accept"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a `rustfs` child process sharing [`TEST_RPC_SECRET`], with the rollout
|
||||||
|
/// posture pinned explicitly.
|
||||||
|
///
|
||||||
|
/// The child inherits the ambient environment, so the strict gates and the
|
||||||
|
/// replay-cache capacity are set here rather than assumed: a developer or CI
|
||||||
|
/// runner exporting `RUSTFS_INTERNODE_RPC_*` would otherwise silently flip the
|
||||||
|
/// posture and fail these tests for a non-security reason. `extra_env` is
|
||||||
|
/// applied last so the strict tests can still override.
|
||||||
|
///
|
||||||
|
/// Uses the no-cleanup spawn so a `pkill` pattern cannot reap servers belonging
|
||||||
|
/// to other tests running in the same binary.
|
||||||
|
fn server_env(extra_env: &[(&'static str, &'static str)]) -> Vec<(&'static str, &'static str)> {
|
||||||
|
let mut child_env = vec![
|
||||||
|
("RUSTFS_RPC_SECRET", TEST_RPC_SECRET),
|
||||||
|
(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "false"),
|
||||||
|
(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "false"),
|
||||||
|
(ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT, "false"),
|
||||||
|
(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "1048576"),
|
||||||
|
];
|
||||||
|
child_env.extend_from_slice(extra_env);
|
||||||
|
child_env
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_server_with_env(child_env: &[(&str, &str)]) -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server_without_cleanup_with_env(child_env).await?;
|
||||||
|
Ok(env)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_server(extra_env: &[(&'static str, &'static str)]) -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
|
||||||
|
start_server_with_env(&server_env(extra_env)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop the child and drop the cached gRPC channel for its address.
|
||||||
|
///
|
||||||
|
/// `node_service_time_out_client_no_auth` memoises channels in a process-global
|
||||||
|
/// map keyed by URL, and ports handed out by `find_available_port` can recur
|
||||||
|
/// within one test binary. Evicting here keeps a later test from inheriting a
|
||||||
|
/// channel aimed at this test's dead server.
|
||||||
|
async fn stop_server(mut env: RustFSTestEnvironment, url: &str) {
|
||||||
|
env.stop_server();
|
||||||
|
rustfs_protos::evict_failed_connection(url).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The audience the server binds into the v2 signature: its own node authority.
|
||||||
|
///
|
||||||
|
/// A single-node server started with `--address 127.0.0.1:PORT` over filesystem
|
||||||
|
/// endpoints has no URL peer set, so `init_local_peer` falls back to
|
||||||
|
/// `host:port` — exactly the address we dialed. The positive controls below
|
||||||
|
/// fail loudly if that ever stops holding.
|
||||||
|
fn audience_of(env: &RustFSTestEnvironment) -> String {
|
||||||
|
env.address.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_sha256(bytes: &[u8]) -> String {
|
||||||
|
Sha256::digest(bytes).iter().fold(String::new(), |mut acc, byte| {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(acc, "{byte:02x}");
|
||||||
|
acc
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_volume_request(volume: &str) -> MakeVolumeRequest {
|
||||||
|
MakeVolumeRequest {
|
||||||
|
disk: ABSENT_DISK.to_string(),
|
||||||
|
volume: volume.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonical_digest(request: &MakeVolumeRequest) -> String {
|
||||||
|
hex_sha256(&canonical_make_volume_request_body(request).expect("canonical body must encode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a full v2 header set for `(audience, rpc_method, content_sha256)`.
|
||||||
|
///
|
||||||
|
/// This is the only place a signature is produced. Tests treat the returned map
|
||||||
|
/// as an opaque captured artifact.
|
||||||
|
fn mint_v2_headers(audience: &str, rpc_method: &str, content_sha256: Option<&str>) -> HeaderMap {
|
||||||
|
gen_tonic_signature_headers(audience, node_service_name(), rpc_method, content_sha256)
|
||||||
|
.expect("minting a v2 signature must succeed once the RPC secret is aligned")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint the pre-v2 header set: a signature over the fixed
|
||||||
|
/// `TONIC_RPC_PREFIX|GET|timestamp` constant, with no v2 headers at all. This is
|
||||||
|
/// both what an un-upgraded peer sends and what an attacker sends to force a
|
||||||
|
/// downgrade.
|
||||||
|
fn mint_legacy_only_headers() -> HeaderMap {
|
||||||
|
gen_signature_headers(TONIC_RPC_PREFIX, &Method::GET).expect("minting a legacy signature must succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace one header of a captured set, asserting it was there to begin with.
|
||||||
|
fn overwrite_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
|
||||||
|
assert!(
|
||||||
|
headers.contains_key(name),
|
||||||
|
"minted headers must carry {name}; the wire contract changed and this attack would edit nothing"
|
||||||
|
);
|
||||||
|
headers.insert(name, value.parse().expect("header value must be valid"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send `request` to the server's NodeService with exactly `headers` attached
|
||||||
|
/// and nothing else — no interceptor adds or rewrites auth metadata, so the
|
||||||
|
/// bytes on the wire are the ones the test chose.
|
||||||
|
async fn call_make_volume(url: &str, request: MakeVolumeRequest, headers: HeaderMap) -> Result<MakeVolumeResponse, Status> {
|
||||||
|
call_make_volume_response(url, request, headers)
|
||||||
|
.await
|
||||||
|
.map(Response::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_make_volume_response(
|
||||||
|
url: &str,
|
||||||
|
request: MakeVolumeRequest,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response<MakeVolumeResponse>, Status> {
|
||||||
|
let mut client = node_service_time_out_client_no_auth(&url.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
||||||
|
let mut rpc_request = Request::new(request);
|
||||||
|
rpc_request.metadata_mut().as_mut().extend(headers);
|
||||||
|
client.make_volume(rpc_request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call_ping_response(url: &str, headers: HeaderMap) -> Result<Response<PingResponse>, Status> {
|
||||||
|
let mut client = node_service_time_out_client_no_auth(&url.to_string())
|
||||||
|
.await
|
||||||
|
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
||||||
|
let mut rpc_request = Request::new(PingRequest {
|
||||||
|
version: 1,
|
||||||
|
body: bytes::Bytes::new(),
|
||||||
|
});
|
||||||
|
rpc_request.metadata_mut().as_mut().extend(headers);
|
||||||
|
client.ping(rpc_request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_boot_epoch_challenge(headers: &mut HeaderMap) -> Uuid {
|
||||||
|
let challenge = Uuid::new_v4();
|
||||||
|
headers.insert(
|
||||||
|
BOOT_EPOCH_CHALLENGE_HEADER,
|
||||||
|
challenge.to_string().parse().expect("UUID must be a valid header value"),
|
||||||
|
);
|
||||||
|
challenge
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mint_replay_scope_headers(audience: &str, path: &str, content_sha256: &str, boot_epoch: Uuid) -> HeaderMap {
|
||||||
|
let mut headers = mint_v2_headers(audience, "MakeVolume", Some(content_sha256));
|
||||||
|
let timestamp = headers
|
||||||
|
.get(TIMESTAMP_HEADER)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.expect("v2 headers must carry a timestamp")
|
||||||
|
.to_string();
|
||||||
|
headers.extend(
|
||||||
|
gen_tonic_replay_scope_headers(audience, path, ×tamp, content_sha256, boot_epoch)
|
||||||
|
.expect("replay-scope headers must mint with the aligned RPC secret"),
|
||||||
|
);
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn learn_boot_epoch_from_make_volume(url: &str, audience: &str) -> Uuid {
|
||||||
|
let request = make_volume_request("signature-e2e-epoch-bootstrap");
|
||||||
|
let mut headers = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||||
|
let challenge = attach_boot_epoch_challenge(&mut headers);
|
||||||
|
let response = call_make_volume_response(url, request, headers)
|
||||||
|
.await
|
||||||
|
.expect("v2 request with epoch challenge must clear default authentication");
|
||||||
|
let boot_epoch = verify_tonic_boot_epoch_response(audience, challenge, response.metadata().as_ref())
|
||||||
|
.expect("server must HMAC-authenticate the advertised boot epoch");
|
||||||
|
assert_authenticated(
|
||||||
|
Ok(response.into_inner()),
|
||||||
|
"a v2 epoch-challenge request in the default replay-scope posture",
|
||||||
|
);
|
||||||
|
boot_epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn learn_boot_epoch_from_ping(url: &str, audience: &str) -> Uuid {
|
||||||
|
let mut headers = mint_v2_headers(audience, "Ping", None);
|
||||||
|
let challenge = attach_boot_epoch_challenge(&mut headers);
|
||||||
|
let response = call_ping_response(url, headers)
|
||||||
|
.await
|
||||||
|
.expect("v2 Ping with an epoch challenge must bootstrap strict replay scope");
|
||||||
|
verify_tonic_boot_epoch_response(audience, challenge, response.metadata().as_ref())
|
||||||
|
.expect("strict replay-scope Ping must return a valid boot epoch proof")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert a call cleared authentication.
|
||||||
|
///
|
||||||
|
/// Receiving *any* `Ok` response is the load-bearing signal: both auth layers
|
||||||
|
/// reject with a `Status`, so an `Ok` means the request reached handler logic.
|
||||||
|
/// The failed disk lookup underneath is what keeps it side-effect free.
|
||||||
|
fn assert_authenticated(result: Result<MakeVolumeResponse, Status>, context: &str) {
|
||||||
|
match result {
|
||||||
|
Ok(response) => {
|
||||||
|
assert!(
|
||||||
|
!response.success,
|
||||||
|
"{context}: the absent disk {ABSENT_DISK} must not yield a successful volume creation"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
response.error.is_some(),
|
||||||
|
"{context}: expected the request to reach disk lookup and fail there, got no error"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(status) => panic!(
|
||||||
|
"{context}: the request must clear authentication, but was rejected with {:?}: {}",
|
||||||
|
status.code(),
|
||||||
|
status.message()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert a call was rejected, optionally pinning which check spoke.
|
||||||
|
///
|
||||||
|
/// `PermissionDenied` responses carry the reason on the wire, so the digest
|
||||||
|
/// tests pin it and cannot be satisfied by an unrelated digest-gate failure.
|
||||||
|
/// `Unauthenticated` is deliberately generic on the wire; those tests pin their
|
||||||
|
/// cause structurally instead, by differing from a passing request in exactly
|
||||||
|
/// one respect.
|
||||||
|
fn assert_rejected(result: Result<MakeVolumeResponse, Status>, expected: Code, expected_message: Option<&str>, context: &str) {
|
||||||
|
match result {
|
||||||
|
Ok(response) => panic!(
|
||||||
|
"{context}: the request must be rejected, but the server accepted it and ran the handler \
|
||||||
|
(success={}, error={:?})",
|
||||||
|
response.success, response.error
|
||||||
|
),
|
||||||
|
Err(status) => {
|
||||||
|
assert_eq!(
|
||||||
|
status.code(),
|
||||||
|
expected,
|
||||||
|
"{context}: expected {expected:?}, got {:?}: {}",
|
||||||
|
status.code(),
|
||||||
|
status.message()
|
||||||
|
);
|
||||||
|
if let Some(needle) = expected_message {
|
||||||
|
assert!(
|
||||||
|
status.message().contains(needle),
|
||||||
|
"{context}: expected the rejection to cite {needle:?}, got {:?}",
|
||||||
|
status.message()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default posture (both strict gates off): the protections that hold without
|
||||||
|
/// any operator flip.
|
||||||
|
///
|
||||||
|
/// Grouped into one server start because each case is independent and spawning
|
||||||
|
/// a `rustfs` process per assertion would dominate the runtime.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn internode_rpc_signature_default_posture_e2e() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
align_rpc_secret_with_server();
|
||||||
|
let env = start_server(&[]).await?;
|
||||||
|
let url = env.url.clone();
|
||||||
|
let audience = audience_of(&env);
|
||||||
|
|
||||||
|
signed_mutations_are_accepted(&url, &audience).await;
|
||||||
|
unsigned_request_is_rejected(&url).await;
|
||||||
|
cross_method_signature_transplant_is_rejected(&url, &audience).await;
|
||||||
|
nonce_replay_of_a_captured_mutation_is_rejected(&url, &audience).await;
|
||||||
|
swapping_in_a_fresh_nonce_is_rejected(&url, &audience).await;
|
||||||
|
tampered_mutation_body_is_rejected(&url, &audience).await;
|
||||||
|
rewriting_the_digest_to_match_a_tampered_body_is_rejected(&url, &audience).await;
|
||||||
|
signature_minted_for_another_node_is_rejected(&url).await;
|
||||||
|
legacy_only_signature_is_accepted_in_default_posture(&url).await;
|
||||||
|
|
||||||
|
stop_server(env, &url).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A replay-scoped signature is usable exactly once against the exact gRPC path and the server
|
||||||
|
/// process epoch that minted it. This crosses the child-process boundary twice: the HMAC-protected
|
||||||
|
/// epoch is learned from a real response, then the same server is restarted in place to prove its
|
||||||
|
/// replacement epoch rejects the captured request even though the nonce cache is necessarily new.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn replay_scope_rejects_replay_path_transplant_and_stale_epoch_e2e() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
align_rpc_secret_with_server();
|
||||||
|
let child_env = server_env(&[]);
|
||||||
|
let mut env = start_server_with_env(&child_env).await?;
|
||||||
|
let url = env.url.clone();
|
||||||
|
let audience = audience_of(&env);
|
||||||
|
let boot_epoch = learn_boot_epoch_from_make_volume(&url, &audience).await;
|
||||||
|
|
||||||
|
let request = make_volume_request("replay-scope-e2e-once");
|
||||||
|
let captured = mint_replay_scope_headers(
|
||||||
|
&audience,
|
||||||
|
&format!("{TONIC_RPC_PREFIX}/MakeVolume"),
|
||||||
|
&canonical_digest(&request),
|
||||||
|
boot_epoch,
|
||||||
|
);
|
||||||
|
assert_authenticated(
|
||||||
|
call_make_volume(&url, request.clone(), captured.clone()).await,
|
||||||
|
"the first replay-scoped mutation delivery",
|
||||||
|
);
|
||||||
|
assert_rejected(
|
||||||
|
call_make_volume(&url, request.clone(), captured).await,
|
||||||
|
Code::Unauthenticated,
|
||||||
|
None,
|
||||||
|
"the same replay-scoped mutation delivered twice",
|
||||||
|
);
|
||||||
|
|
||||||
|
let transplanted =
|
||||||
|
mint_replay_scope_headers(&audience, &format!("{TONIC_RPC_PREFIX}/Ping"), &canonical_digest(&request), boot_epoch);
|
||||||
|
assert_rejected(
|
||||||
|
call_make_volume(&url, request.clone(), transplanted).await,
|
||||||
|
Code::Unauthenticated,
|
||||||
|
None,
|
||||||
|
"a replay-scoped Ping signature transplanted onto MakeVolume",
|
||||||
|
);
|
||||||
|
|
||||||
|
let stale_epoch = mint_replay_scope_headers(
|
||||||
|
&audience,
|
||||||
|
&format!("{TONIC_RPC_PREFIX}/MakeVolume"),
|
||||||
|
&canonical_digest(&request),
|
||||||
|
boot_epoch,
|
||||||
|
);
|
||||||
|
env.restart_server_preserving_data(Vec::new(), &child_env).await?;
|
||||||
|
rustfs_protos::evict_failed_connection(&url).await;
|
||||||
|
assert_rejected(
|
||||||
|
call_make_volume(&url, request.clone(), stale_epoch).await,
|
||||||
|
Code::Unauthenticated,
|
||||||
|
None,
|
||||||
|
"a replay-scoped signature captured before the receiving process restart",
|
||||||
|
);
|
||||||
|
|
||||||
|
let restarted_epoch = learn_boot_epoch_from_make_volume(&url, &audience).await;
|
||||||
|
assert_ne!(boot_epoch, restarted_epoch, "a restarted child process must advertise a new boot epoch");
|
||||||
|
let fresh_epoch = mint_replay_scope_headers(
|
||||||
|
&audience,
|
||||||
|
&format!("{TONIC_RPC_PREFIX}/MakeVolume"),
|
||||||
|
&canonical_digest(&request),
|
||||||
|
restarted_epoch,
|
||||||
|
);
|
||||||
|
assert_authenticated(
|
||||||
|
call_make_volume(&url, request, fresh_epoch).await,
|
||||||
|
"a replay-scoped mutation signed with the replacement process epoch",
|
||||||
|
);
|
||||||
|
|
||||||
|
stop_server(env, &url).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict replay scope leaves one authenticated v2 bootstrap: `Ping` carrying a fresh challenge.
|
||||||
|
/// A mutating v2 request cannot use that lane; once the epoch proof is returned, the first v3
|
||||||
|
/// mutation succeeds. This protects a server restart without reopening a general downgrade path.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn replay_scope_strict_requires_v3_after_ping_bootstrap_e2e() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
align_rpc_secret_with_server();
|
||||||
|
let env = start_server(&[(ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT, "true")]).await?;
|
||||||
|
let url = env.url.clone();
|
||||||
|
let audience = audience_of(&env);
|
||||||
|
|
||||||
|
let v2_request = make_volume_request("replay-scope-e2e-strict-v2");
|
||||||
|
assert_rejected(
|
||||||
|
call_make_volume(
|
||||||
|
&url,
|
||||||
|
v2_request.clone(),
|
||||||
|
mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&v2_request))),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Code::Unauthenticated,
|
||||||
|
None,
|
||||||
|
"a v2 mutation after replay-scope strictness is enabled",
|
||||||
|
);
|
||||||
|
|
||||||
|
let boot_epoch = learn_boot_epoch_from_ping(&url, &audience).await;
|
||||||
|
let request = make_volume_request("replay-scope-e2e-strict-v3");
|
||||||
|
let replay_scoped = mint_replay_scope_headers(
|
||||||
|
&audience,
|
||||||
|
&format!("{TONIC_RPC_PREFIX}/MakeVolume"),
|
||||||
|
&canonical_digest(&request),
|
||||||
|
boot_epoch,
|
||||||
|
);
|
||||||
|
assert_authenticated(
|
||||||
|
call_make_volume(&url, request, replay_scoped).await,
|
||||||
|
"a replay-scoped mutation after Ping bootstrap under strict replay scope",
|
||||||
|
);
|
||||||
|
|
||||||
|
stop_server(env, &url).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Baseline: correctly signed mutations are accepted, both with and without a
|
||||||
|
/// body digest.
|
||||||
|
///
|
||||||
|
/// These anchor every rejection below. The body-bound case proves the audience
|
||||||
|
/// the server verifies against really is the address we dialed. The digestless
|
||||||
|
/// case is the control the transplant test needs: without it, a regression that
|
||||||
|
/// rejected every `UNSIGNED-PAYLOAD` request would make the transplant
|
||||||
|
/// assertion pass for entirely the wrong reason. It also documents that the
|
||||||
|
/// default posture still serves digestless mutations.
|
||||||
|
async fn signed_mutations_are_accepted(url: &str, audience: &str) {
|
||||||
|
let bound = make_volume_request("signature-e2e-control-bound");
|
||||||
|
let bound_headers = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&bound)));
|
||||||
|
assert_authenticated(
|
||||||
|
call_make_volume(url, bound, bound_headers).await,
|
||||||
|
"a correctly signed body-bound mutation",
|
||||||
|
);
|
||||||
|
|
||||||
|
let digestless = make_volume_request("signature-e2e-control-digestless");
|
||||||
|
let digestless_headers = mint_v2_headers(audience, "MakeVolume", None);
|
||||||
|
assert_authenticated(
|
||||||
|
call_make_volume(url, digestless, digestless_headers).await,
|
||||||
|
"a correctly signed digestless mutation in the default posture",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A request with no auth metadata at all must never reach a handler.
|
||||||
|
async fn unsigned_request_is_rejected(url: &str) {
|
||||||
|
let result = call_make_volume(url, make_volume_request("signature-e2e-unsigned"), HeaderMap::new()).await;
|
||||||
|
assert_rejected(result, Code::Unauthenticated, None, "an entirely unsigned mutation");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GHSA-c667 class: a signature captured from one gRPC method must not be
|
||||||
|
/// replayable onto another.
|
||||||
|
///
|
||||||
|
/// Before method-path binding every NodeService call signed the same constant,
|
||||||
|
/// so a captured `Ping` — the cheapest, least privileged call on the service —
|
||||||
|
/// authenticated a `MakeVolume` just as well. The captured `Ping` signature is
|
||||||
|
/// transplanted verbatim; the server recomputes the scope with
|
||||||
|
/// `rpc_method = MakeVolume` and the HMAC no longer matches. It differs from the
|
||||||
|
/// accepted digestless control above only in the method it was minted for.
|
||||||
|
async fn cross_method_signature_transplant_is_rejected(url: &str, audience: &str) {
|
||||||
|
let captured_ping = mint_v2_headers(audience, "Ping", None);
|
||||||
|
let result = call_make_volume(url, make_volume_request("signature-e2e-transplant"), captured_ping).await;
|
||||||
|
assert_rejected(
|
||||||
|
result,
|
||||||
|
Code::Unauthenticated,
|
||||||
|
None,
|
||||||
|
"a Ping signature transplanted onto a MakeVolume mutation",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A body-bound mutation must be consumable exactly once.
|
||||||
|
///
|
||||||
|
/// The first send establishes that the captured artifact is genuinely valid —
|
||||||
|
/// without it, the second rejection could just mean the headers were malformed
|
||||||
|
/// all along. The replay reuses the identical `(signature, timestamp, nonce)`
|
||||||
|
/// well inside the freshness window, so only the server's replay cache can
|
||||||
|
/// stop it.
|
||||||
|
async fn nonce_replay_of_a_captured_mutation_is_rejected(url: &str, audience: &str) {
|
||||||
|
let request = make_volume_request("signature-e2e-replay");
|
||||||
|
let captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||||
|
|
||||||
|
let first = call_make_volume(url, request.clone(), captured.clone()).await;
|
||||||
|
assert_authenticated(first, "the captured mutation on its first delivery");
|
||||||
|
|
||||||
|
let replayed = call_make_volume(url, request, captured).await;
|
||||||
|
assert_rejected(
|
||||||
|
replayed,
|
||||||
|
Code::Unauthenticated,
|
||||||
|
None,
|
||||||
|
"the same captured mutation replayed after its nonce was consumed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The nonce must be *signed*, not merely remembered.
|
||||||
|
///
|
||||||
|
/// A replay cache alone would be trivially defeated: swap in a fresh UUID and
|
||||||
|
/// the cache has never seen it. This request is byte-identical to one the server
|
||||||
|
/// would accept apart from that one header, so it can only be stopped by the
|
||||||
|
/// nonce being inside the signed scope.
|
||||||
|
async fn swapping_in_a_fresh_nonce_is_rejected(url: &str, audience: &str) {
|
||||||
|
let request = make_volume_request("signature-e2e-nonce-swap");
|
||||||
|
let mut captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||||
|
overwrite_header(&mut captured, NONCE_HEADER, &Uuid::new_v4().to_string());
|
||||||
|
|
||||||
|
let result = call_make_volume(url, request, captured).await;
|
||||||
|
assert_rejected(
|
||||||
|
result,
|
||||||
|
Code::Unauthenticated,
|
||||||
|
None,
|
||||||
|
"a captured mutation resent under a freshly minted nonce",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Editing the body of a captured request must invalidate it, in the default
|
||||||
|
/// posture, with no operator flip required.
|
||||||
|
///
|
||||||
|
/// The headers are left byte-identical — including the signed digest of the
|
||||||
|
/// original body — so `check_auth` still passes. Only the handler, recomputing
|
||||||
|
/// the canonical body from the fields it actually received, can catch this. It
|
||||||
|
/// is the test that fails if a handler ever loses its digest gate.
|
||||||
|
async fn tampered_mutation_body_is_rejected(url: &str, audience: &str) {
|
||||||
|
let signed = make_volume_request("signature-e2e-tamper-a");
|
||||||
|
let captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&signed)));
|
||||||
|
|
||||||
|
// Exactly one byte of the volume name differs from what the digest covers.
|
||||||
|
let tampered = make_volume_request("signature-e2e-tamper-b");
|
||||||
|
let result = call_make_volume(url, tampered, captured).await;
|
||||||
|
assert_rejected(
|
||||||
|
result,
|
||||||
|
Code::PermissionDenied,
|
||||||
|
Some("RPC content SHA-256 mismatch"),
|
||||||
|
"a mutation whose body was edited after signing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The body digest must be *inside the signed scope*, not merely cross-checked
|
||||||
|
/// by the handler.
|
||||||
|
///
|
||||||
|
/// This is the same tampered body as above, except the attacker also repairs the
|
||||||
|
/// digest header so it matches what it sends — defeating the handler's
|
||||||
|
/// comparison. The only thing left standing is the signature, which covers the
|
||||||
|
/// digest header itself. Drop `content_sha256` from `update_signature_v2` and
|
||||||
|
/// this is the test that goes green when it should not.
|
||||||
|
async fn rewriting_the_digest_to_match_a_tampered_body_is_rejected(url: &str, audience: &str) {
|
||||||
|
let signed = make_volume_request("signature-e2e-scope-a");
|
||||||
|
let mut captured = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&signed)));
|
||||||
|
|
||||||
|
let tampered = make_volume_request("signature-e2e-scope-b");
|
||||||
|
overwrite_header(&mut captured, CONTENT_SHA256_HEADER, &canonical_digest(&tampered));
|
||||||
|
|
||||||
|
let result = call_make_volume(url, tampered, captured).await;
|
||||||
|
assert_rejected(
|
||||||
|
result,
|
||||||
|
Code::Unauthenticated,
|
||||||
|
None,
|
||||||
|
"a tampered mutation whose digest header was repaired to match",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A signature is bound to its destination node, so a request captured against
|
||||||
|
/// one node cannot be aimed at another.
|
||||||
|
///
|
||||||
|
/// `127.0.0.1:1` stands in for a different peer; the audience is inside the
|
||||||
|
/// HMAC, so the server's own authority no longer reproduces it.
|
||||||
|
async fn signature_minted_for_another_node_is_rejected(url: &str) {
|
||||||
|
let request = make_volume_request("signature-e2e-wrong-node");
|
||||||
|
let headers = mint_v2_headers("127.0.0.1:1", "MakeVolume", Some(&canonical_digest(&request)));
|
||||||
|
let result = call_make_volume(url, request, headers).await;
|
||||||
|
assert_rejected(result, Code::Unauthenticated, None, "a signature minted for a different node");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rolling-upgrade compatibility: a peer that predates v2 must still be served
|
||||||
|
/// while the strict gates are off.
|
||||||
|
///
|
||||||
|
/// This is the case the issue insists must not fail closed during an upgrade.
|
||||||
|
/// It is also, honestly, the open downgrade window: an attacker can strip the
|
||||||
|
/// v2 headers and land here too. That window is what
|
||||||
|
/// [`signature_strict_rejects_legacy_only_downgrade`] closes.
|
||||||
|
async fn legacy_only_signature_is_accepted_in_default_posture(url: &str) {
|
||||||
|
let result = call_make_volume(url, make_volume_request("signature-e2e-legacy"), mint_legacy_only_headers()).await;
|
||||||
|
assert_authenticated(result, "a legacy-only signature in the default posture");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With `RUSTFS_INTERNODE_RPC_SIGNATURE_STRICT` on, the legacy downgrade lane is
|
||||||
|
/// closed: the exact request accepted in the default posture is now refused.
|
||||||
|
///
|
||||||
|
/// The paired v2 positive control rules out "strict simply breaks everything".
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn signature_strict_rejects_legacy_only_downgrade() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
align_rpc_secret_with_server();
|
||||||
|
let env = start_server(&[(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "true")]).await?;
|
||||||
|
let url = env.url.clone();
|
||||||
|
let audience = audience_of(&env);
|
||||||
|
|
||||||
|
let downgraded = call_make_volume(&url, make_volume_request("signature-e2e-strict-legacy"), mint_legacy_only_headers()).await;
|
||||||
|
assert_rejected(
|
||||||
|
downgraded,
|
||||||
|
Code::Unauthenticated,
|
||||||
|
None,
|
||||||
|
"a legacy-only signature once signature-strict is enabled",
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = make_volume_request("signature-e2e-strict-v2");
|
||||||
|
let signed = mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||||
|
assert_authenticated(
|
||||||
|
call_make_volume(&url, request, signed).await,
|
||||||
|
"a v2-signed mutation under signature-strict",
|
||||||
|
);
|
||||||
|
|
||||||
|
stop_server(env, &url).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With `RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT` on, any mutation that arrives
|
||||||
|
/// without a body digest is refused — including one that downgraded all the way
|
||||||
|
/// to the legacy signature.
|
||||||
|
///
|
||||||
|
/// This gate converges independently of the signature gate, so it is exercised
|
||||||
|
/// on its own server with signature-strict left off. Both rejected requests
|
||||||
|
/// clear `check_auth` on their own terms (one is properly v2-signed, the other
|
||||||
|
/// takes the still-open legacy lane), which is what pins the rejection to the
|
||||||
|
/// handler's digest gate; the cited message confirms which check spoke.
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn body_digest_strict_rejects_digestless_mutation() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
align_rpc_secret_with_server();
|
||||||
|
let env = start_server(&[(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "true")]).await?;
|
||||||
|
let url = env.url.clone();
|
||||||
|
let audience = audience_of(&env);
|
||||||
|
|
||||||
|
let digestless = mint_v2_headers(&audience, "MakeVolume", None);
|
||||||
|
assert_rejected(
|
||||||
|
call_make_volume(&url, make_volume_request("signature-e2e-digestless"), digestless).await,
|
||||||
|
Code::PermissionDenied,
|
||||||
|
Some("RPC mutation requires a body-bound v2 signature"),
|
||||||
|
"a v2-signed but digestless mutation once body-digest-strict is enabled",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_rejected(
|
||||||
|
call_make_volume(&url, make_volume_request("signature-e2e-digestless-legacy"), mint_legacy_only_headers()).await,
|
||||||
|
Code::PermissionDenied,
|
||||||
|
Some("RPC mutation requires a body-bound v2 signature"),
|
||||||
|
"a v1-downgraded mutation once body-digest-strict is enabled",
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = make_volume_request("signature-e2e-digest-bound");
|
||||||
|
let bound = mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&request)));
|
||||||
|
assert_authenticated(
|
||||||
|
call_make_volume(&url, request, bound).await,
|
||||||
|
"a body-bound mutation under body-digest-strict",
|
||||||
|
);
|
||||||
|
|
||||||
|
stop_server(env, &url).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -29,6 +29,11 @@ use aws_sdk_s3::Client;
|
|||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::ServerSideEncryption;
|
use aws_sdk_s3::types::ServerSideEncryption;
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||||
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
|
use md5::{Digest as Md5Digest, Md5};
|
||||||
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
|
use rustfs_signer::sign_v4;
|
||||||
|
use s3s::Body;
|
||||||
use serde_json;
|
use serde_json;
|
||||||
use std::process::{Child, Command};
|
use std::process::{Child, Command};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -64,7 +69,52 @@ pub fn skip_if_kms_admin_tool_unavailable(test_name: &str) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sse_customer_key_md5_base64(key: &str) -> String {
|
pub fn sse_customer_key_md5_base64(key: &str) -> String {
|
||||||
BASE64.encode(md5::compute(key).0)
|
let mut hasher = Md5::new();
|
||||||
|
hasher.update(key.as_bytes());
|
||||||
|
BASE64.encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn kms_admin_request(
|
||||||
|
base_url: &str,
|
||||||
|
method: http::Method,
|
||||||
|
path_and_query: &str,
|
||||||
|
body: Option<&str>,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let url = format!("{base_url}{path_and_query}");
|
||||||
|
let uri = url.parse::<http::Uri>()?;
|
||||||
|
let authority = uri.authority().ok_or("KMS admin URL missing authority")?.to_string();
|
||||||
|
let mut builder = http::Request::builder()
|
||||||
|
.method(method.clone())
|
||||||
|
.uri(uri)
|
||||||
|
.header(HOST, authority)
|
||||||
|
.header("x-amz-content-sha256", UNSIGNED_PAYLOAD);
|
||||||
|
if body.is_some() {
|
||||||
|
builder = builder.header(CONTENT_TYPE, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
let content_len = match body {
|
||||||
|
Some(value) => i64::try_from(value.len())?,
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
let signed = sign_v4(builder.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1");
|
||||||
|
|
||||||
|
let mut request = local_http_client().request(method.clone(), &url);
|
||||||
|
for (name, value) in signed.headers() {
|
||||||
|
request = request.header(name, value);
|
||||||
|
}
|
||||||
|
if let Some(value) = body {
|
||||||
|
request = request.body(value.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = request.send().await?;
|
||||||
|
let status = response.status();
|
||||||
|
let response_body = response.text().await?;
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("{method} {path_and_query} failed with {status}: {response_body}").into());
|
||||||
|
}
|
||||||
|
Ok(response_body)
|
||||||
}
|
}
|
||||||
|
|
||||||
// KMS-specific helper functions
|
// KMS-specific helper functions
|
||||||
@@ -75,8 +125,19 @@ pub async fn configure_kms(
|
|||||||
access_key: &str,
|
access_key: &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!("{base_url}/rustfs/admin/v3/kms/configure");
|
let response = kms_admin_request(
|
||||||
awscurl_post(&url, config_json, access_key, secret_key).await?;
|
base_url,
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/configure",
|
||||||
|
Some(config_json),
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||||
|
if response["success"] != true {
|
||||||
|
return Err(format!("KMS configuration failed: {}", response["message"].as_str().unwrap_or("unknown error")).into());
|
||||||
|
}
|
||||||
info!("KMS configured successfully");
|
info!("KMS configured successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -87,8 +148,19 @@ pub async fn start_kms(
|
|||||||
access_key: &str,
|
access_key: &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!("{base_url}/rustfs/admin/v3/kms/start");
|
let response = kms_admin_request(
|
||||||
awscurl_post(&url, "{}", access_key, secret_key).await?;
|
base_url,
|
||||||
|
http::Method::POST,
|
||||||
|
"/rustfs/admin/v3/kms/start",
|
||||||
|
Some("{}"),
|
||||||
|
access_key,
|
||||||
|
secret_key,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let response: serde_json::Value = serde_json::from_str(&response)?;
|
||||||
|
if response["success"] != true {
|
||||||
|
return Err(format!("KMS start failed: {}", response["message"].as_str().unwrap_or("unknown error")).into());
|
||||||
|
}
|
||||||
info!("KMS started successfully");
|
info!("KMS started successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -99,8 +171,8 @@ pub async fn get_kms_status(
|
|||||||
access_key: &str,
|
access_key: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let url = format!("{base_url}/rustfs/admin/v3/kms/status");
|
let status =
|
||||||
let status = awscurl_get(&url, access_key, secret_key).await?;
|
kms_admin_request(base_url, http::Method::GET, "/rustfs/admin/v3/kms/status", None, access_key, secret_key).await?;
|
||||||
info!("KMS status retrieved: {}", status);
|
info!("KMS status retrieved: {}", status);
|
||||||
Ok(status)
|
Ok(status)
|
||||||
}
|
}
|
||||||
@@ -508,7 +580,8 @@ impl VaultTestEnvironment {
|
|||||||
},
|
},
|
||||||
"mount_path": VAULT_TRANSIT_PATH,
|
"mount_path": VAULT_TRANSIT_PATH,
|
||||||
"default_key_id": VAULT_KEY_NAME,
|
"default_key_id": VAULT_KEY_NAME,
|
||||||
"skip_tls_verify": true
|
"skip_tls_verify": true,
|
||||||
|
"allow_insecure_dev_defaults": true
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
@@ -657,14 +730,19 @@ pub async fn test_multipart_upload_with_config(
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
info!("🔗 Completing multipart upload");
|
info!("🔗 Completing multipart upload");
|
||||||
let complete_output = s3_client
|
let mut complete_request = s3_client
|
||||||
.complete_multipart_upload()
|
.complete_multipart_upload()
|
||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(&config.object_key)
|
.key(&config.object_key)
|
||||||
.upload_id(upload_id)
|
.upload_id(upload_id)
|
||||||
.multipart_upload(completed_multipart_upload)
|
.multipart_upload(completed_multipart_upload);
|
||||||
.send()
|
if let EncryptionType::SSEC { .. } = &config.encryption_type {
|
||||||
.await?;
|
complete_request = complete_request
|
||||||
|
.sse_customer_algorithm("AES256")
|
||||||
|
.sse_customer_key(sse_c_key_b64.as_ref().unwrap())
|
||||||
|
.sse_customer_key_md5(sse_c_key_md5.as_ref().unwrap());
|
||||||
|
}
|
||||||
|
let complete_output = complete_request.send().await?;
|
||||||
|
|
||||||
debug!("Multipart upload finalized with ETag {:?}", complete_output.e_tag());
|
debug!("Multipart upload finalized with ETag {:?}", complete_output.e_tag());
|
||||||
|
|
||||||
@@ -796,7 +874,8 @@ impl LocalKMSTestEnvironment {
|
|||||||
"backend_type": "Local",
|
"backend_type": "Local",
|
||||||
"key_dir": self.kms_keys_dir,
|
"key_dir": self.kms_keys_dir,
|
||||||
"file_permissions": 0o600,
|
"file_permissions": 0o600,
|
||||||
"default_key_id": default_key_id
|
"default_key_id": default_key_id,
|
||||||
|
"allow_insecure_dev_defaults": true
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user