mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 20:46:11 +00:00
Compare commits
331 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e82083de02 | |||
| 056f41a15e | |||
| 4dddcec1ba | |||
| 7c85c72fd1 | |||
| 27d167f7b6 | |||
| 1a5e2b6256 | |||
| 474fcf78fb | |||
| 752d4a81ab | |||
| 0686277ee4 | |||
| 7373a5902e | |||
| e95a0ed6d9 | |||
| f0e0f5307d | |||
| 6855640192 | |||
| f11697d6e2 | |||
| 736b6a366c | |||
| 7b40b9503b | |||
| e22b879996 | |||
| b0dac1ac24 | |||
| 90e3ed701e | |||
| fd92853ac4 | |||
| bc0f608431 | |||
| f20a575994 | |||
| cc91483fac | |||
| c8fc50ada2 | |||
| 9badf3939c | |||
| c71c686e2a | |||
| 45f57ca57a | |||
| 265f429358 | |||
| 47f640f23e | |||
| 237c96dd3b | |||
| 647fe1a292 | |||
| 0c6314babc | |||
| fc76099280 | |||
| 899f81f3ad | |||
| 37bda24e1c | |||
| 4c2a0cdf9a | |||
| b370e746a0 | |||
| 95e6c89c1c | |||
| aafa7e2b7f | |||
| 5211b56277 | |||
| c9c6bb7a24 | |||
| ad0c44dc63 | |||
| 2d6f9417ff | |||
| 638da7a2e3 | |||
| 7c7b48aaca | |||
| c40988dae7 | |||
| 481c1b7939 | |||
| 26d827f946 | |||
| 376cb037a0 | |||
| 64829c704d | |||
| 17ba30f648 | |||
| 21015cfac8 | |||
| c04cd089e9 | |||
| bffdf0809f | |||
| 19a69ee897 | |||
| 1499295393 | |||
| 4c4dcb6f5e | |||
| 672087ec0d | |||
| 9dbeae1b45 | |||
| 6018dd372f | |||
| d633a635ec | |||
| fbd30a6f43 | |||
| a32360198c | |||
| 05efc584b0 | |||
| a70c96d520 | |||
| b0f68f5d0a | |||
| df554439b0 | |||
| 3b404e56c0 | |||
| ff00872922 | |||
| 4d1ce9618a | |||
| a2bad0953f | |||
| 14c99a994c | |||
| f6c2a9bfe0 | |||
| 640d7e0e3c | |||
| 409ac3de66 | |||
| 2a63fcbea6 | |||
| f4049598e4 | |||
| 1651541d38 | |||
| 85849788af | |||
| 975983abdd | |||
| cf1c45eb91 | |||
| 536c283716 | |||
| a04ddc237e | |||
| 7de6ac82e1 | |||
| 7993e11058 | |||
| 37ead2f69c | |||
| 1b1e590df7 | |||
| 086ee8e48a | |||
| 22bff27aee | |||
| 8a20498705 | |||
| c3e6d90c6f | |||
| d2af8f073e | |||
| 2baba1bda7 | |||
| bf2ca9113f | |||
| f474ea30d4 | |||
| 002ac9544c | |||
| d74d970e24 | |||
| cc15eae479 | |||
| c130d00d4b | |||
| e49d9cdea2 | |||
| f5778d8b97 | |||
| 9da42a899a | |||
| 3d46ed312a | |||
| 3496277e7c | |||
| 0b72f39023 | |||
| 30a0937a7d | |||
| 5b962b6c58 | |||
| 0ee5408b94 | |||
| cb3100a252 | |||
| 3a4afe9b38 | |||
| 71859ff83c | |||
| cb72df269a | |||
| d7b7d1835f | |||
| b92392a04a | |||
| a40ec8a6f3 | |||
| c99efd9477 | |||
| 27d593fa35 | |||
| 0137d14064 | |||
| 1dddf357cd | |||
| 395ba797fc | |||
| 51893abfbf | |||
| 92c17af8e3 | |||
| f17f31a3df | |||
| 1a88870809 | |||
| 760c9d65be | |||
| 08283d1fdc | |||
| 6a323c3e91 | |||
| 1650f3c2a6 | |||
| 38611d2510 | |||
| 1a459d650f | |||
| 5aef1796cc | |||
| a8aaadb886 | |||
| 71f1dcf859 | |||
| d44244f60f | |||
| d3884ed3ea | |||
| 282d6d5efe | |||
| f0d865728c | |||
| 3df39ef4d7 | |||
| b0c73c1224 | |||
| 54c11ef28b | |||
| e99c41a9bf | |||
| fddecf0afe | |||
| 55fd73ed48 | |||
| 1ae80c41ec | |||
| 9fc9b5e69c | |||
| 61e0edce16 | |||
| 2b5c739343 | |||
| 5dca076efe | |||
| 07833379b4 | |||
| 6655272c90 | |||
| 0a5d4cef0e | |||
| 941fae61a0 | |||
| 03fa62cc7d | |||
| b59dea826f | |||
| 081a8b61d8 | |||
| 159dc13548 | |||
| bde4b78a9f | |||
| c861fe3a57 | |||
| e1608fbd9c | |||
| eb1b17802c | |||
| 112f70914d | |||
| ea9aa53fd8 | |||
| dd368f0f5b | |||
| 6d8606412e | |||
| 037354cec0 | |||
| 30ab919bb3 | |||
| 8fc1c9281e | |||
| 14cef91423 | |||
| c9acc33720 | |||
| 955d491174 | |||
| 1c4e9f1b65 | |||
| a9f01dbbdb | |||
| 35aefbb2a5 | |||
| 8fb335cf19 | |||
| 8f763fb1a2 | |||
| a6b5da64f2 | |||
| 1210428b6d | |||
| d5426f59ec | |||
| f54323b062 | |||
| cc5060ac20 | |||
| 188f380b3b | |||
| e2a921bc16 | |||
| 447f3c704b | |||
| d915f9565e | |||
| 55ad7508b9 | |||
| 33fd056000 | |||
| af2e9df821 | |||
| 0a92a7d98c | |||
| c589fd2439 | |||
| 3e5d4ebb09 | |||
| 3677871468 | |||
| cc1ec6b992 | |||
| 9e2545244c | |||
| f053862aad | |||
| e8a7f4bc4a | |||
| 7ba5cd6888 | |||
| acfeef55ab | |||
| 0d1b312673 | |||
| 42c32381b6 | |||
| e6bf2a4646 | |||
| cf9688898d | |||
| 2d159635ed | |||
| 2f02d1d2d8 | |||
| 8ae8fb7eea | |||
| bbd7b9ef17 | |||
| 15e9bc5ed0 | |||
| 882d9ca8a4 | |||
| 19a29a7027 | |||
| 2e4ab045b6 | |||
| cbfd5b92f4 | |||
| 971f9acdf4 | |||
| a6589c19e3 | |||
| 0885c721fe | |||
| eaf5159d0f | |||
| 2477e31059 | |||
| d8c3b1bb26 | |||
| a3b8183be9 | |||
| 4dbc58887a | |||
| 123967e729 | |||
| 4b0d597d4d | |||
| 13e6424e99 | |||
| b33693fc19 | |||
| 9ed1d46090 | |||
| 6eb60f8e72 | |||
| 8dd3cabd41 | |||
| e648f683bf | |||
| 193b1b7d3f | |||
| 3b920c7999 | |||
| 5f8b097172 | |||
| 445114577f | |||
| 923bde6904 | |||
| 10ccf7c31a | |||
| 146003a426 | |||
| 65ed86f76e | |||
| 3da3bcd9ee | |||
| 20e4fd7de6 | |||
| da28f8c843 | |||
| 507447da12 | |||
| 81014fd233 | |||
| ff3c5a4989 | |||
| 4d226998e2 | |||
| f6bed1a73a | |||
| 7ff578ff20 | |||
| f16a30b231 | |||
| 6f717b2752 | |||
| 3654c147e2 | |||
| a9373288f2 | |||
| fdb28e611f | |||
| 5f739944a3 | |||
| 2bc88418f5 | |||
| 9d10d69a6d | |||
| 9863f4848d | |||
| 7dfc2ee5f0 | |||
| 80c88a9031 | |||
| 3005efe845 | |||
| 3a914b429d | |||
| 8a8616b8f7 | |||
| 62e66baf89 | |||
| 53cabe9274 | |||
| 0f272ddb14 | |||
| 0181a583a6 | |||
| 0713a723cd | |||
| a6cb34c7a4 | |||
| 703766677b | |||
| f7c51271ee | |||
| 0e6ee3bf62 | |||
| 3ab7a1921f | |||
| 86ebcb325c | |||
| 8023cf3e26 | |||
| 54a7e9f307 | |||
| 5e58b1d3a2 | |||
| df30dff1a7 | |||
| d011ec9952 | |||
| 1747ed0292 | |||
| 74be040c62 | |||
| df4fdef1d8 | |||
| 759a395fec | |||
| d04611ba38 | |||
| 37344a84da | |||
| c1070f1447 | |||
| 8bf569899a | |||
| 98f7e63396 | |||
| 07dce1cab0 | |||
| b727e669db | |||
| 0fe6cc3641 | |||
| 5be9d255e6 | |||
| 817adb127c | |||
| e3349f5f30 | |||
| a5bde8b0af | |||
| a23d4b05a3 | |||
| 183b5c9ede | |||
| 1ab6405ac9 | |||
| 9e0663cbba | |||
| ba20af77bb | |||
| 2231633ae1 | |||
| 2e2bc814b1 | |||
| 1dd81cf276 | |||
| 01db1f6644 | |||
| 7e1f261e38 | |||
| 99f85ca2b1 | |||
| afc66b7182 | |||
| 922552083f | |||
| 32eb116cbc | |||
| 68f47b9219 | |||
| de7422b508 | |||
| 1bbfa71b11 | |||
| c066faf07a | |||
| ca8bbbf8f3 | |||
| bc789332b6 | |||
| b422d1fea9 | |||
| 87bc9d14ea | |||
| fddaeba247 | |||
| b46a8164f8 | |||
| b1faaafb1f | |||
| 0a975f2fe2 | |||
| ceeff52229 | |||
| e04e15aed1 | |||
| afa84fa988 | |||
| 7aaed4d67b | |||
| 36e07e104a | |||
| b03804566c | |||
| 40a2470feb | |||
| 7dcfdb3320 | |||
| 397dbcf102 | |||
| 99073938ae | |||
| d22991f33b | |||
| 194c8643c0 | |||
| 5720c5c748 | |||
| 1941189499 | |||
| bba934723a | |||
| cebe57a2f0 |
@@ -0,0 +1,33 @@
|
||||
# Adversarial Review Shape
|
||||
|
||||
Use when root `AGENTS.md` triggers adversarial validation or for a substantial PR
|
||||
review. Paths below are repository-relative. The root finding standard and
|
||||
completion rule apply; selecting a lens does not require finding a defect.
|
||||
|
||||
Risk and review shape:
|
||||
|
||||
- **Exempt:** documentation, comments, formatting, or typos with no runtime,
|
||||
build, test, or agent-execution effect.
|
||||
- **Mechanical:** renames, moves, test/tooling-only changes, and agent-rule
|
||||
changes. Run correctness and simplicity lenses.
|
||||
- **Standard:** localized behavior changes. Run one integrated final-diff pass
|
||||
covering correctness, simplicity, and test coverage; add only domain lenses
|
||||
matched by the diff.
|
||||
- **High risk / substantial PR review:** high risk includes locking,
|
||||
erasure/quorum/heal, replication, multipart, RPC, lifecycle/tiering,
|
||||
persistence/fsync, IAM/KMS/auth, cryptography, on-disk/on-wire formats, and
|
||||
S3-visible semantics. Cover all applicable lenses using exactly two
|
||||
independent reviewers when delegation is explicitly authorized. Split the
|
||||
lenses between them. Otherwise perform two fresh sequential passes.
|
||||
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
|
||||
the remote SDK configuration sends to every replication or migration target
|
||||
is high risk for every target class even when the change fixes one. Follow
|
||||
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
|
||||
run the outbound target matrix, document each new env knob in the same PR,
|
||||
and list verified and unverified target classes in the PR Impact section.
|
||||
|
||||
Available domain lenses are security, concurrency/durability, compatibility,
|
||||
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
||||
explicit adversarial request, a high-risk change, or a substantial PR review;
|
||||
then read only its matching role references. A routine standard pass does not
|
||||
load the playbook unless the reviewer needs a RustFS-specific probe.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Implementation Rules
|
||||
|
||||
Applies when changing code or running artifact-heavy work. Paths below
|
||||
are repository-relative. Read only the relevant sections during read-only review.
|
||||
|
||||
## Worktree and Disk Hygiene
|
||||
|
||||
- Start implementation from the latest `origin/main` and confirm the requested
|
||||
change is not already present.
|
||||
- An existing clean, isolated task worktree is sufficient. Create another
|
||||
worktree only when the current checkout is shared, dirty with unrelated work,
|
||||
or belongs to another task.
|
||||
- Never commit from a shared checkout.
|
||||
- Use a task-specific branch named `<type>/<topic>`, such as `fix/...`,
|
||||
`feat/...`, `test/...`, or `docs/...`, unless the user specifies a name.
|
||||
- Do not include agent, tool, contributor, account, or organization names in
|
||||
branch names.
|
||||
- Push to the user-requested remote or the repository's configured push remote.
|
||||
Do not hard-code or infer a remote from an account name.
|
||||
- Check free space before artifact-heavy builds, tests, coverage, or downloads.
|
||||
Re-check before a broad gate when space is tight.
|
||||
- Remove only task-owned temporary/build artifacts. Never delete another task's
|
||||
worktree or uncommitted data.
|
||||
- At handoff, mention disk or cleanup details only when they affected execution
|
||||
or artifacts/worktrees remain intentionally.
|
||||
|
||||
## Change Style
|
||||
|
||||
- Preserve existing control flow unless changing it is required for correctness.
|
||||
- Prefer a direct local edit over new files, wrappers, managers, or speculative
|
||||
abstractions.
|
||||
- Add a helper only when it removes current duplication, names a real domain
|
||||
boundary, or isolates a non-trivial invariant.
|
||||
- Remove an in-scope path superseded by the change. If compatibility requires it,
|
||||
adapt at the boundary to one canonical core and use the repository's
|
||||
`RUSTFS_COMPAT_TODO` policy.
|
||||
- Comments explain non-obvious invariants or reasons. Do not narrate code or
|
||||
record change history.
|
||||
- Mention unrelated problems when useful; do not fix them in a narrow task.
|
||||
|
||||
## Reuse and Boundary Rules
|
||||
|
||||
- Before adding helpers, constants, fixtures, or wrappers, search the touched
|
||||
crate, the domain-owning crate, `crates/utils`, `crates/common`, and relevant
|
||||
direct dependencies.
|
||||
- Reuse requires matching semantics: normalization, error types, deadlines,
|
||||
durability, and compatibility must fit the call site. A narrowly named local
|
||||
helper is better than forced reuse with different semantics.
|
||||
- Validate untrusted input at its trust boundary, then trust the validated type.
|
||||
Values crossing disk, RPC, persistence, or version boundaries remain
|
||||
untrusted at every consumer.
|
||||
- Re-check boundary values immediately before destructive actions such as
|
||||
delete, overwrite, or quorum decisions.
|
||||
- Every new branch needs a concrete triggering input/state. For decoded or peer
|
||||
data, corruption and mixed-version input are valid triggers.
|
||||
- Required values must return a typed error when absent or corrupt; do not use a
|
||||
default that converts corruption into a plausible result.
|
||||
- Attach error context once where it is actionable. Do not erase typed errors
|
||||
below aggregation or quorum layers.
|
||||
|
||||
## Naming
|
||||
|
||||
Use Rust API naming: `SCREAMING_SNAKE_CASE` constants/statics, `snake_case`
|
||||
functions/variables, and `PascalCase` types. Do not rename unrelated existing
|
||||
violations.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Git and Pull Request Rules
|
||||
|
||||
Applies to commits, pushes, PRs, and issue/discussion actions. Paths below are
|
||||
repository-relative. User authorization and root `AGENTS.md` still govern scope.
|
||||
|
||||
## Final PR Preflight
|
||||
|
||||
Before creating or updating a PR, reuse completed review and verification:
|
||||
|
||||
- Verify the actual base (normally `origin/main`) and the complete task diff,
|
||||
including file names and whitespace. Exclude secrets, logs, generated
|
||||
artifacts, and unrelated edits. Retain an existing PR's base unless requested.
|
||||
- Confirm the final diff passed the root verification tier; fix task-owned
|
||||
failures and run missing scoped checks. Report unresolved required checks or
|
||||
authority without expanding the task. Do not start another general review.
|
||||
- Keep the English Conventional Commit title at most 72 characters. Use the
|
||||
template headings, actual checks, material risks, and rollback notes.
|
||||
- Immediately before writing to GitHub, confirm the head and task diff are
|
||||
unchanged. Rerun only checks invalidated by edits or relevant state changes.
|
||||
|
||||
## Pull Request Lifecycle
|
||||
|
||||
- Creating or updating a PR includes one immediate snapshot of checks,
|
||||
mergeability, reviews, and unresolved threads.
|
||||
- Unless the user explicitly requests monitoring, a release workflow requires
|
||||
it, or an automation already owns it, hand off after the PR is open with the
|
||||
current state and next event to watch. Do not delay ordinary handoff with
|
||||
fixed quiet-period sleeps.
|
||||
- For requested monitoring, use event-driven or bounded waits. Report only state
|
||||
changes, actionable failures, or a meaningful prolonged delay.
|
||||
- Investigate failures/comments before changing code. Fix task-attributable
|
||||
issues, rerun affected verification, push, reply or resolve the thread, then
|
||||
resume the requested monitor.
|
||||
- Never merge without required reviewer approval or explicit authority.
|
||||
- After an observed merge, verify the commit reached the base, then clean the
|
||||
task worktree/branch when safe. Preserve unmerged work for closed PRs unless
|
||||
deletion was explicitly authorized.
|
||||
|
||||
## Git and PR Baseline
|
||||
|
||||
- Follow Conventional Commits; keep the subject at most 72 characters.
|
||||
- Source comments, commits, PR titles, and PR bodies are in English.
|
||||
- Keep every heading from `.github/pull_request_template.md`; use `N/A` where
|
||||
needed and include commands actually run.
|
||||
- Use `--body-file` for multiline `gh pr create`/`gh pr edit` content.
|
||||
- PR/issue/discussion content must not contain the literal sequence `\n` or
|
||||
hard-wrapped prose paragraphs.
|
||||
- Do not include local absolute paths or tool-specific labels/prefixes in GitHub
|
||||
content.
|
||||
- Resolve review threads after the underlying issue is fixed. If declining a
|
||||
suggestion, reply with a short evidence-based reason.
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: adversarial-validation
|
||||
description: Review a final RustFS diff adversarially when the user requests adversarial review, the root AGENTS.md classifies the change as high risk, or a substantial PR is being reviewed. Do not use for ordinary questions, diagnosis, planning, status, documentation-only work, or routine low-risk implementation.
|
||||
description: Review RustFS diffs or designs for explicit adversarial requests, high-risk changes under the repository review policy, or substantial PR reviews. Skip ordinary questions, diagnosis, planning, status, routine low-risk implementation, and prose with no execution effect.
|
||||
---
|
||||
|
||||
# RustFS Adversarial Validation
|
||||
|
||||
Use the risk tier and review shape defined in the root `AGENTS.md`. This skill
|
||||
Use the [repository risk tiers and review shape](../../references/adversarial-validation.md). This skill
|
||||
routes a review to RustFS-specific probes without loading unrelated domains.
|
||||
|
||||
## Select Lenses
|
||||
@@ -31,15 +31,18 @@ adversarial review.
|
||||
|
||||
## Review Protocol
|
||||
|
||||
1. Freeze the exact final diff/head and list the selected lenses.
|
||||
2. Run the review shape required by root `AGENTS.md`.
|
||||
1. Freeze the exact final diff/head (or the design under review) and list the
|
||||
selected lenses.
|
||||
2. Run the review shape required by the repository risk tier.
|
||||
3. For each selected lens, either report a concrete finding or a null verdict
|
||||
naming the attacks performed.
|
||||
4. A finding needs `file:line`, a triggering input/state/interleaving, the wrong
|
||||
outcome, and a focused fix or missing regression check.
|
||||
5. Fix or rebut every finding with code-path, test, or invariant evidence.
|
||||
4. Apply root `AGENTS.md`'s finding standard. Test each candidate against callers,
|
||||
existing coverage, and invariants before accepting it; an adversarial role
|
||||
does not have to produce a defect.
|
||||
5. Fix or rebut supported findings with code-path, test, or invariant evidence.
|
||||
6. After a non-trivial edit, rerun only lenses affected by that edit against the
|
||||
new exact diff.
|
||||
|
||||
Do not turn a null verdict into a long checklist. Record concise evidence that
|
||||
the relevant failure classes were attacked.
|
||||
the relevant failure classes were attacked, then stop under the root completion
|
||||
rule. Keep the required per-lens verdicts for high-risk PRs.
|
||||
|
||||
@@ -22,3 +22,17 @@
|
||||
fixtures and encrypted migration data.
|
||||
- Compatibility shims use `RUSTFS_COMPAT_TODO(<task-id>)`, have a removal
|
||||
condition, and default toward reading old data safely.
|
||||
|
||||
## Outbound targets
|
||||
|
||||
- A change to what the replication or migration client sends by default
|
||||
(checksum policy, payload framing, headers, version-id addressing) is judged
|
||||
against every target class, not the one it fixes. Name each target-side rule
|
||||
the current default satisfies — checksum required with Object Lock
|
||||
parameters, `aws-chunked` decoding, version-id adoption, ETag equals content
|
||||
MD5 — and show which cell of
|
||||
`crates/e2e_test/src/replication_target_matrix_test.rs` covers each.
|
||||
- A test that asserts the fix ("no trailer header") is not evidence; the
|
||||
matrix cell that asserts the target accepted and stored the object is.
|
||||
- Every new environment escape hatch appears in
|
||||
`docs/operations/replication-outbound-transport.md` in the same diff.
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
- For every behavior claim, name the focused test/check that fails if the
|
||||
changed hunk is reverted. If none is practical, require the reason and
|
||||
residual risk.
|
||||
- Confirm tests exercise the real production path and assert returned values,
|
||||
exact bytes, stored state, or the specific error variant—not only success,
|
||||
`is_err()`, or no panic.
|
||||
- Confirm tests exercise the real production path and distinguish the intended
|
||||
behavior from the named regression. A success, `is_err()`, or no-panic check
|
||||
can be sufficient when that is the actual contract; require exact values,
|
||||
bytes, state, or error variants when those distinctions matter to the change.
|
||||
- For new flags/modes, verify each branch and ask which test fails if the branch
|
||||
is inverted.
|
||||
- For new error propagation, inject the failure and assert the caller observes
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
---
|
||||
name: arch-checks
|
||||
description: Resolve failures from the repository's architecture guard scripts — check_layer_dependencies.sh, check_architecture_migration_rules.sh, check_unsafe_code_allowances.sh, check_logging_guardrails.sh, check_doc_paths.sh. Use when make pre-commit / pre-pr or CI fails on one of these checks.
|
||||
description: Diagnose failures from check_layer_dependencies.sh, check_architecture_migration_rules.sh, check_unsafe_code_allowances.sh, check_logging_guardrails.sh, check_doc_paths.sh, or check_no_planning_docs.sh. Use when one of these guards fails, not for every architecture question or documentation edit.
|
||||
---
|
||||
|
||||
# Architecture Guard Checks
|
||||
|
||||
All five run in `make pre-commit` / `make pre-pr` and in CI. Fix the cause;
|
||||
never weaken a check to get green.
|
||||
Read only the section for the failing guard. Use `.config/make/` and the current
|
||||
workflow to verify its wiring; not every guard is part of every gate. Fix the
|
||||
cause and rerun the failed guard; never weaken a check to get green.
|
||||
|
||||
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
|
||||
|
||||
@@ -50,10 +51,12 @@ consider adding it to the script's `checked_files` list.
|
||||
|
||||
## `check_doc_paths.sh`
|
||||
|
||||
Instruction/architecture docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`,
|
||||
`docs/architecture/*.md`) must not reference repo file paths that no longer
|
||||
exist. If your refactor moved code, update the docs that point at it — the
|
||||
error message lists `doc -> stale-path` pairs.
|
||||
Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
|
||||
Markdown file under `docs/` (architecture, operations, testing, index) must not
|
||||
reference repo file paths that no longer exist. If your refactor moved code,
|
||||
update the docs that point at it — the error message lists `doc -> stale-path`
|
||||
pairs. In durable docs, cite paths plus symbol names rather than line numbers
|
||||
(see `docs/architecture/README.md`). Review findings still need `file:line`.
|
||||
|
||||
## `check_no_planning_docs.sh`
|
||||
|
||||
|
||||
@@ -8,19 +8,11 @@ description: Review a commit, PR, or merged patch when the user requests ordinar
|
||||
Use this skill for an ordinary requested review. If the root policy or user calls
|
||||
for adversarial validation, use `adversarial-validation` instead of running both.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Read the scope: commit, PR, patch, or file list.
|
||||
2. Map each changed area by risk and user impact.
|
||||
3. Inspect each risky change in context.
|
||||
4. Report findings first, ordered by severity.
|
||||
5. Close with residual risks and verification recommendations.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
### 1) Scope and assumptions
|
||||
- Confirm change source (diff, commit, PR, files), target branch, language/runtime, and version.
|
||||
- If context is missing, state assumptions before deeper analysis.
|
||||
- Derive the change source, target branch, and relevant runtime/version from the
|
||||
supplied diff and metadata. Ask only when missing context could change the verdict.
|
||||
- Focus only on requested scope; avoid reviewing unrelated files.
|
||||
|
||||
### 2) Risk map
|
||||
@@ -40,43 +32,20 @@ for adversarial validation, use `adversarial-validation` instead of running both
|
||||
- unchecked assumptions and null/empty/error-path handling
|
||||
- stale tests, fixtures, and configs
|
||||
- hidden coupling to shared helpers/constants/features
|
||||
- If a point is uncertain, mark it as an open question instead of guessing.
|
||||
- Apply root `AGENTS.md`'s finding standard: try to disprove a candidate before
|
||||
reporting it. Mention an unresolved question only when it could materially
|
||||
change the verdict; do not fill the report with speculative possibilities.
|
||||
|
||||
#### Rust-specific checks (apply to all Rust changes)
|
||||
#### Rust-specific checks
|
||||
|
||||
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.
|
||||
For changed Rust behavior, use the matching sections of [rust-code-quality](../rust-code-quality/SKILL.md). Reuse checks already performed by the selected review workflow. Comment-only or formatting-only Rust diffs do not require the full Rust checklist. Carry its P0–P3 ratings over unchanged and use this skill's output format.
|
||||
|
||||
### 4) Findings-first output
|
||||
- Order findings by severity:
|
||||
- P0: critical failure, security breach, or data loss risk
|
||||
- P1: high-impact regression
|
||||
- P2: medium risk correctness gap
|
||||
- P3: low risk/quality debt
|
||||
- For each finding include:
|
||||
- Severity
|
||||
- `path:line` reference
|
||||
- concise issue statement
|
||||
- impact and likely failure mode
|
||||
- specific fix or mitigation
|
||||
- validation step to confirm
|
||||
- If no issues exist, explicitly state `No findings` and why.
|
||||
- Order supported findings by P0–P3 severity; preserve the Rust ratings above.
|
||||
Include `path:line`, the failure and impact, a focused fix, and its validation.
|
||||
- If no supported issues remain, state `No findings` with the reviewed scope and
|
||||
any material verification limitation. Do not append optional improvements to
|
||||
make a clean review look productive.
|
||||
|
||||
### 5) Close
|
||||
- Report assumptions and unknowns.
|
||||
- Suggest targeted checks (tests, canary checks, logs/metrics, migration validation).
|
||||
|
||||
## Output Template
|
||||
|
||||
1. Findings
|
||||
2. No findings (if applicable)
|
||||
3. Assumptions / Unknowns
|
||||
4. Recommended verification steps
|
||||
|
||||
## Finding Template
|
||||
|
||||
- `[P1] Missing timeout for downstream call`
|
||||
- Location: `path/to/file.rs:123`
|
||||
- Issue: ...
|
||||
- Impact: ...
|
||||
- Fix suggestion: ...
|
||||
- Validation: ...
|
||||
Close after the required review. Recommend additional verification only for an
|
||||
identified unresolved risk or required gate; reuse evidence for unchanged code.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: issue-triage
|
||||
description: Assess whether a GitHub issue is fixed, needs implementation, or can be closed by checking related work and current code. Use for issue completion/triage requests. Status questions are read-only; comment, close, or change labels only when the conversation authorizes that action.
|
||||
---
|
||||
|
||||
# Issue Triage
|
||||
|
||||
Use this skill when the user provides a GitHub issue URL and asks "can this be closed?", "is this already implemented?", "check completion status", or similar triage questions.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Fetch issue context
|
||||
|
||||
```bash
|
||||
gh issue view <N> --repo <owner/repo> --json title,body,state,comments,labels,updatedAt
|
||||
```
|
||||
|
||||
Read the issue body to understand what was requested. Extract:
|
||||
- The specific feature/fix/behavior described.
|
||||
- Any linked PRs or commits mentioned in the body or comments.
|
||||
- Any checklist items or sub-issues.
|
||||
|
||||
Resolve the issue repository and implementation repository separately (for example, `rustfs/backlog` tracks work in `rustfs/rustfs`). Pass the implementation repository explicitly to PR queries; the current checkout may belong to another repository.
|
||||
|
||||
### 2. Search for related work
|
||||
|
||||
Search git history for commits referencing the issue:
|
||||
```bash
|
||||
git log --oneline --all --grep="<N>" | head -30
|
||||
```
|
||||
|
||||
Search for related PRs:
|
||||
```bash
|
||||
gh pr list --repo <implementation-repo> --search "<issue-url>" --state all --json number,title,state,mergedAt
|
||||
```
|
||||
|
||||
Also search qualified issue references and subject keywords; for same-repository
|
||||
issues, include `#<N>`. Follow explicit links even without a text match. A search
|
||||
page with no match does not prove the work is absent.
|
||||
|
||||
If the issue mentions specific PRs, check their status:
|
||||
```bash
|
||||
gh pr view <PR_N> --repo <implementation-repo> --json state,mergedAt,title,mergeCommit,baseRefName
|
||||
```
|
||||
|
||||
### 3. Verify implementation
|
||||
|
||||
Fetch the implementation repository's current base branch. For each merged candidate, verify its merge commit is present and inspect the current code for the claimed behavior; a commit message match alone is not proof:
|
||||
```bash
|
||||
git fetch <implementation-remote> <base-branch>
|
||||
git merge-base --is-ancestor <merge-commit> <implementation-remote>/<base-branch>
|
||||
```
|
||||
|
||||
If the issue describes a specific defect, inspect the fetched base's code rather than assuming the current checkout contains it:
|
||||
```bash
|
||||
git show <implementation-remote>/<base-branch>:crates/<relevant>/src/<file>.rs
|
||||
```
|
||||
|
||||
For issues with checklists, verify each item individually. If sub-items are tracked as separate issues, check those too:
|
||||
```bash
|
||||
gh issue view <SUB_N> --repo <owner/repo> --json state
|
||||
```
|
||||
|
||||
### 4. Determine verdict
|
||||
|
||||
- **All items fixed and merged**: Recommend closing; name the verified PRs and behavior.
|
||||
- **Some items fixed, some remaining**: Keep open; report each remaining item.
|
||||
- **Not yet implemented**: Keep open; report what remains.
|
||||
- **Superseded or no longer relevant**: Recommend closing with evidence.
|
||||
|
||||
### 5. Take action
|
||||
|
||||
For a status-only request, return the assessment without GitHub writes. If commenting, closing, or label edits are authorized, perform only those actions; do not ask again for authority already given. Prepare the final assessment before asking for any missing authority. Write `rustfs/backlog` issue content in Chinese.
|
||||
|
||||
Close with comment:
|
||||
```bash
|
||||
gh issue close <N> --repo <owner/repo> --comment "<body>"
|
||||
```
|
||||
|
||||
Comment without closing:
|
||||
```bash
|
||||
gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md
|
||||
```
|
||||
|
||||
Update labels only when label changes are authorized, using existing repository labels; never add tool-specific labels:
|
||||
```bash
|
||||
gh issue edit <N> --repo <owner/repo> --add-label "<existing-label>"
|
||||
```
|
||||
|
||||
Always use `--body-file` for multiline content, never inline `--body`.
|
||||
|
||||
### 6. Handle multi-issue batches
|
||||
|
||||
When the user asks to check multiple issues (e.g., "check all issues by user X" or "scan backlog for closable issues"):
|
||||
1. List the full requested scope with pagination (for example `gh api --paginate 'repos/<repo>/issues?state=open&per_page=100'`, excluding entries with `pull_request`). Add an author filter only when the user requested one; the default page/limit is not evidence that all issues were checked.
|
||||
2. For each issue, run steps 1-5 above.
|
||||
3. Report a summary table of all triaged issues with verdicts.
|
||||
|
||||
## Report
|
||||
|
||||
Identify the issue and current state, verified implementation/PR evidence,
|
||||
remaining items, verdict, and action actually taken. Use a table for batches;
|
||||
a single issue does not require a heading for each field. Follow step 4's
|
||||
verdicts without repeating the assessment in another template.
|
||||
|
||||
## Notes
|
||||
|
||||
- The user may ask in Chinese ("是否可以关闭", "检查完成情况"); respond in the same language.
|
||||
- When closing, always include a summary of what was fixed and which PRs resolved it — this creates a useful audit trail.
|
||||
- For issues in `rustfs/backlog`, use `--repo rustfs/backlog`.
|
||||
- For issues in `rustfs/rustfs`, use `--repo rustfs/rustfs`.
|
||||
- If the issue has sub-issues (GitHub sub-issues API), check each one's state before declaring the parent complete.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: plugin-contract-guard
|
||||
description: Invariants and change procedure for the target-plugin / extension system — plugin manifests, admin plugin/extension catalog and instance APIs, secret redaction, external-plugin install policy. Use when editing crates/targets (manifest, plugin, control_plane, catalog, runtime), crates/extension-schema, or rustfs/src/admin plugin_contract.rs / plugins_*.rs / extensions.rs / target_descriptor.rs.
|
||||
description: Guard changes to target-plugin manifests, extension schemas, admin catalog/instance contracts, secret redaction, and external-plugin install policy. Use when a diff changes those contracts in crates/targets, crates/extension-schema, or admin plugin/extension handlers; path membership alone, comments, and unrelated runtime internals do not trigger it.
|
||||
---
|
||||
|
||||
# Plugin & Extension Contract Guard
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
name: pr-creation-checker
|
||||
description: Perform the final RustFS PR preflight and draft compliant English title/body metadata immediately before creating or updating a PR. Do not use during implementation or as a second general code review.
|
||||
---
|
||||
|
||||
# PR Creation Checker
|
||||
|
||||
Use this skill only at the PR boundary. Reuse completed diff review and
|
||||
verification evidence; do not reread the repository or rerun equivalent checks.
|
||||
|
||||
## Preflight
|
||||
|
||||
1. Confirm the branch is based on current `origin/main` and contains only the
|
||||
intended task diff.
|
||||
2. Inspect `git diff --stat`, `git diff --check`, and changed file names for
|
||||
secrets, logs, generated artifacts, or unrelated edits.
|
||||
3. Confirm the checks selected by root `AGENTS.md` passed on the final diff.
|
||||
Do not replace focused behavioral tests with a generic gate or rerun checks
|
||||
already covered by an unchanged umbrella run.
|
||||
4. Read `.github/pull_request_template.md`. Consult `Makefile`, `.config/make/`,
|
||||
or CI only when the required command/current gate is uncertain.
|
||||
5. Return `BLOCKED` for an unclean scope, missing required evidence, failed
|
||||
required checks, or non-compliant metadata.
|
||||
|
||||
## Metadata
|
||||
|
||||
- Title: English Conventional Commit, at most 72 characters, with no tool
|
||||
prefix.
|
||||
- Body: English, exact template headings, `N/A` where needed, concise rationale,
|
||||
actual verification commands, and material risks/rollback notes.
|
||||
- Use repository-relative paths; never include local absolute paths.
|
||||
- Keep prose paragraphs on one logical line and never include the literal
|
||||
sequence `\n`.
|
||||
- Use a temporary body file with `gh pr create --body-file` or
|
||||
`gh pr edit --body-file`; never pass multiline Markdown inline.
|
||||
|
||||
## Output
|
||||
|
||||
- Status: `READY` or `BLOCKED`.
|
||||
- Title.
|
||||
- Complete PR body.
|
||||
- Verification commands and results.
|
||||
- Risks or `N/A`.
|
||||
|
||||
Immediately before the GitHub write, repeat only the five preflight checks above
|
||||
against the final head.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "PR Creation Checker"
|
||||
short_description: "Draft RustFS-ready PRs with checks, template, and blockers."
|
||||
default_prompt: "Use $pr-creation-checker for final PR preflight and compliant English title/body metadata."
|
||||
@@ -0,0 +1,93 @@
|
||||
---
|
||||
name: pr-review
|
||||
description: Review a GitHub PR from a URL or number using its actual base/head and risk-appropriate code review. Use when the user asks for a PR review, not a status lookup or PR wording edit. Publish a review only when authorized; delegation and monitoring follow the requested scope and root AGENTS.md.
|
||||
---
|
||||
|
||||
# PR Review
|
||||
|
||||
Use this skill for PR context and review delivery. An ordinary review request is read-only unless the conversation also authorizes posting or fixes. Reuse that authorization without asking again; prepare the review before requesting any missing publication approval.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Follow root `AGENTS.md`; classify risk with the [review policy](../../references/adversarial-validation.md) and consult relevant [change-style and boundary rules](../../references/implementation.md).
|
||||
- Select `code-change-verification` for ordinary review or `adversarial-validation` for explicitly adversarial, substantial, or high-risk review; do not run both on the same diff.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Gather PR context
|
||||
|
||||
```bash
|
||||
gh pr view <N> --repo <owner/repo> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName,baseRefOid,headRefOid
|
||||
gh pr diff <N> --repo <owner/repo> --name-only
|
||||
```
|
||||
|
||||
Read the PR body and linked issues to understand the change's purpose. If the PR references an issue, fetch that too:
|
||||
```bash
|
||||
gh issue view <ISSUE> --repo <issue-owner/repo> --json title,body,state
|
||||
```
|
||||
|
||||
### 2. Fetch the diff and classify the change
|
||||
|
||||
```bash
|
||||
git fetch <repo-remote> <baseRefName> refs/pull/<N>/head
|
||||
git diff <baseRefOid>...<headRefOid> --stat
|
||||
```
|
||||
|
||||
Resolve `<repo-remote>` to the PR repository; do not assume the current checkout's `origin` or `main` matches. Record the exact base/head used. If either moved during fetching, refresh the snapshot before reviewing. Classify using the repository review policy; instruction changes that affect agent execution are mechanical, not exempt.
|
||||
|
||||
### 3. Review the changed behavior
|
||||
|
||||
Group files by functional area to trace callers and invariants. Use the root risk tier's review shape and only matching lenses. File count does not authorize delegation. When delegation is explicitly authorized, high-risk/substantial reviews use exactly two independent reviewers with the applicable lenses split between them; otherwise use two fresh sequential passes. Reviewers do not spawn further agents.
|
||||
|
||||
Findings need a concrete failure scenario with `file:line`; a null verdict briefly names the relevant probes. Reuse existing evidence and choose local checks from the final diff under the root verification policy.
|
||||
|
||||
### 4. Check CI status
|
||||
|
||||
```bash
|
||||
gh pr checks <N> --repo <owner/repo>
|
||||
```
|
||||
|
||||
Investigate a failed check when it bears on a finding or the user requested CI diagnosis/merge readiness:
|
||||
```bash
|
||||
gh run view --repo <owner/repo> --log-failed --job=<JOB_ID>
|
||||
```
|
||||
|
||||
Use current evidence to distinguish pre-existing, flaky, and PR-caused failures. Do not classify them by guesswork or turn a code-only review into unrelated CI repair.
|
||||
|
||||
### 5. Synthesize findings
|
||||
|
||||
Report the PR, reviewed base/head, and risk tier, then summarize the assessment.
|
||||
Use the selected review's P0–P3 ratings and root finding standard: supported
|
||||
findings with `file:line`, failure scenario, and fix, or `No findings`.
|
||||
State the observed check status, including pending or unavailable checks, and
|
||||
the verdict (`APPROVE`, `REQUEST_CHANGES`, or `COMMENT`). Do not infer a pass
|
||||
from missing checks or add style nits to populate a clean review.
|
||||
|
||||
### 6. Post the review
|
||||
|
||||
Only when posting is authorized, write the review body to a temp file and post via CLI. Refresh the PR head first; if it changed, review the delta and update the verdict before posting:
|
||||
```bash
|
||||
# Request changes
|
||||
gh pr review <N> --repo <owner/repo> --request-changes --body-file /tmp/pr_review.md
|
||||
|
||||
# Approve
|
||||
gh pr review <N> --repo <owner/repo> --approve --body-file /tmp/pr_review.md
|
||||
|
||||
# Comment only (no verdict)
|
||||
gh pr review <N> --repo <owner/repo> --comment --body-file /tmp/pr_review.md
|
||||
```
|
||||
|
||||
For authorized inline comments, use [the submission example](references/posting.md).
|
||||
|
||||
Always use `--body-file` or `--input`, never inline multiline `--body`.
|
||||
|
||||
### 7. Handle follow-up
|
||||
|
||||
Follow the [PR lifecycle](../../references/pull-requests.md) and any explicit monitoring request. For follow-up, fetch the new head and compare the recorded reviewed SHA with the new SHA; revisit affected callers and findings. Never use an unfetched `origin/pull/<N>/head` ref as evidence. Update the posted review or resolve addressed threads only within existing authorization.
|
||||
|
||||
## Notes
|
||||
|
||||
- The user may ask for review in Chinese; respond in the same language but keep the review body in English per AGENTS.md rules.
|
||||
- When the user asks for "多角色对抗 review", run the full adversarial validation protocol — this skill's step 3 covers that.
|
||||
- If the PR is from a fork, check `maintainerCanModify` before attempting to push fixes.
|
||||
- For very large PRs, batch the review by functional area while keeping the same bounded review shape.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Inline PR Review Submission
|
||||
|
||||
Read only when an inline review is authorized. Recheck the PR head before posting and bind the review to the reviewed commit.
|
||||
|
||||
For inline comments on specific lines, use the GitHub API:
|
||||
```bash
|
||||
cat > /tmp/pr_review.json <<'EOF'
|
||||
{
|
||||
"commit_id": "<reviewed-head-sha>",
|
||||
"body": "review body",
|
||||
"event": "REQUEST_CHANGES",
|
||||
"comments": [
|
||||
{
|
||||
"path": "crates/foo/src/bar.rs",
|
||||
"line": 42,
|
||||
"body": "finding description"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
gh api --method POST /repos/{owner}/{repo}/pulls/<N>/reviews --input /tmp/pr_review.json
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: rust-code-quality
|
||||
description: Run a focused Rust quality review when the user requests one, when reviewing a Rust PR/commit, or when another selected review workflow delegates Rust-specific checks. Do not auto-load for every implementation edit.
|
||||
description: Run a focused Rust quality review when the user requests one or a selected review workflow needs Rust-specific checks for changed behavior. Do not auto-load for every implementation edit, comment-only or formatting-only Rust diff, or repeat an already completed review.
|
||||
---
|
||||
|
||||
# Rust Code Quality Gate
|
||||
@@ -8,12 +8,18 @@ description: Run a focused Rust quality review when the user requests one, when
|
||||
Use this skill for a dedicated Rust review to cover rules that `cargo clippy`
|
||||
does not catch.
|
||||
|
||||
Search matches and checklist items are candidates, not findings. Apply the root
|
||||
finding standard; distinguish a demonstrated bug, an explicit rule violation,
|
||||
and an optional preference. P2/P3 suggestions do not need to be invented or
|
||||
included in an otherwise clean correctness review.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Identify changed `.rs` files.
|
||||
2. Run automated checks on changed files.
|
||||
3. Run manual review checklist on the diff.
|
||||
4. Resolve or rebut every finding with evidence; P0/P1 findings cannot be deferred.
|
||||
2. Run the matching candidate searches on changed files.
|
||||
3. Apply the manual checklist sections whose behavior the diff touches.
|
||||
4. Report or rebut every finding with evidence; P0/P1 findings block approval.
|
||||
Fix them when implementation is authorized; a read-only review reports them.
|
||||
|
||||
## Automated Checks
|
||||
|
||||
@@ -35,7 +41,7 @@ rg -n 'Result<.*String>' <changed-files>
|
||||
rg -n 'Box<dyn.*Error' <changed-files>
|
||||
|
||||
# 5. println/eprintln in production
|
||||
rg -n 'println!\|eprintln!' <changed-files>
|
||||
rg -n 'println!|eprintln!' <changed-files>
|
||||
|
||||
# 6. Ordering::Relaxed usage (verify each is intentional)
|
||||
rg -n 'Ordering::Relaxed' <changed-files>
|
||||
@@ -80,7 +86,7 @@ For the Rust diff under review, verify:
|
||||
- [ ] Test volume and line count are never treated as production-code growth
|
||||
|
||||
### Serde
|
||||
- [ ] Structs from untrusted input have `#[serde(deny_unknown_fields)]`
|
||||
- [ ] Structs from untrusted input reject unknown fields where the compatibility contract permits; otherwise validate security-critical fields explicitly and test the supported input shape
|
||||
- [ ] `#[serde(default)]` not used on security-critical fields without validation
|
||||
|
||||
### Code Hygiene
|
||||
@@ -104,20 +110,7 @@ For the Rust diff under review, verify:
|
||||
|
||||
## Output Template
|
||||
|
||||
```
|
||||
## Rust Code Quality Report
|
||||
|
||||
### Automated Scan
|
||||
- unwrap/expect candidates inspected: N
|
||||
- numeric-cast candidates inspected: N
|
||||
- error-type candidates inspected: N
|
||||
- output-macro candidates inspected: N
|
||||
|
||||
### Findings
|
||||
- [P1] `path:line` — description
|
||||
- Fix: ...
|
||||
- Validation: ...
|
||||
|
||||
### Verdict
|
||||
PASS / BLOCKED (list blocking findings)
|
||||
```
|
||||
Use the calling review's output format. For a standalone review, report supported
|
||||
findings with severity, location, impact, fix, and validation, or `No findings`.
|
||||
Include only material unverified checks. Candidate counts are not a quality
|
||||
metric and do not need a separate scan report.
|
||||
|
||||
@@ -4,9 +4,14 @@ description: "Run the end-to-end RustFS console gate, version bump, preview vali
|
||||
---
|
||||
# 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` (invoked here with the authorized commit/push/PR scope) 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. 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. That Release is temporary: `build.yml` deletes it automatically once the final tag's Release is published, so the Releases page ends up carrying deliverables only while the `-preview.N` tags stay behind as the traceability record. 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.
|
||||
The binary reports its build tag (`build::TAG` via shadow_rs; `SHORT_VERSION` in
|
||||
`rustfs/src/config/cli.rs`), and `build.yml` derives asset names and preview
|
||||
classification from that tag. Cargo.toml supplies only the no-tag fallback.
|
||||
Preview and final tags must therefore share the validated source commit;
|
||||
their tag-dependent version and asset names differ. The channel and cleanup
|
||||
constraints are defined once under Preview tag naming and Hard rules below.
|
||||
|
||||
Pipeline shape:
|
||||
|
||||
@@ -29,9 +34,9 @@ On validation failure: fix lands on main via normal PR (version files are alread
|
||||
- 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.*'` 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, collect the current release/tag baseline and ask before version edits or publication. Continue independent read-only preflight while the answer is pending (see the semver gate below).
|
||||
|
||||
## Semver gate — confirm the target version before touching anything
|
||||
## Semver gate — resolve the target before version edits or publication
|
||||
|
||||
Versions follow [SemVer 2.0.0](https://semver.org/). Precedence reminder:
|
||||
|
||||
@@ -43,7 +48,7 @@ Numeric prerelease identifiers compare numerically (`beta.9 < beta.10`), not lex
|
||||
|
||||
Rules:
|
||||
|
||||
- A request like "发个版" / "release the next version" without an exact version string is ALWAYS ambiguous. Derive the current latest tag (`git tag --sort=-v:refname | head`), then ask the user to choose via AskUserQuestion with concrete candidates, e.g. from `1.0.0-beta.10`: next prerelease `1.0.0-beta.11`, promote to `1.0.0-rc.1`, promote to stable `1.0.0`. Never guess between these — they have very different meanings (channel promotion vs. iteration) and different CI classification consequences.
|
||||
- A request like "发个版" / "release the next version" without an exact version string is ALWAYS ambiguous. Derive the current latest tag (`git tag --sort=-v:refname | head`), then ask the user to choose with concrete candidates, e.g. from `1.0.0-beta.10`: next prerelease `1.0.0-beta.11`, promote to `1.0.0-rc.1`, promote to stable `1.0.0`. Never guess between these — they have very different meanings (channel promotion vs. iteration) and different CI classification consequences.
|
||||
- After a stable `X.Y.Z` exists, the next version must state which component bumps: patch `X.Y.(Z+1)` for fixes only, minor `X.(Y+1).0` for backward-compatible features, major `(X+1).0.0` for breaking changes. If the user names a bump type but not a number, compute it from the latest stable tag and echo the exact resulting version back for confirmation.
|
||||
- Echo the final confirmed version string verbatim in your first status report; every later phase must use exactly that string. If at any point the user's wording and the confirmed version diverge, stop and re-confirm.
|
||||
|
||||
@@ -77,58 +82,7 @@ Rules:
|
||||
|
||||
### 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.
|
||||
Read and complete [the Console gate](references/console-gate.md) before Phase 1. Verify the latest published Console asset and exact commit; if Console main is ahead, complete its release and asset verification first. A successful build alone does not satisfy this gate.
|
||||
|
||||
## Phase 1 — Version bump to the final target (once)
|
||||
|
||||
@@ -164,54 +118,9 @@ On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Phase 4 — Run the artifact locally, verify the console
|
||||
## Phases 4–5 — Local artifact, Console, and rc acceptance
|
||||
|
||||
Work inside the session scratchpad directory; never leave stray data dirs.
|
||||
|
||||
```bash
|
||||
gh release download "<preview-tag>" -p "rustfs-macos-aarch64-v<preview-tag>.zip" -D "$SCRATCH"
|
||||
cd "$SCRATCH" && unzip -o rustfs-*.zip
|
||||
./rustfs --version # must report the PREVIEW TAG (build::TAG), not the Cargo.toml version, plus expected short SHA
|
||||
mkdir -p data
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin ./rustfs ./data
|
||||
```
|
||||
|
||||
Defaults: S3 endpoint `:9000`, embedded console `:9001`.
|
||||
|
||||
Checks (all must pass):
|
||||
|
||||
- `./rustfs --version` reports the preview tag name and the short SHA of `PREVIEW_HASH`. Reporting `<target>` without the `-preview.N` suffix means the build did not embed the tag — treat as FAIL and investigate before proceeding.
|
||||
- `curl -fsS http://localhost:9000/health/ready` returns ready.
|
||||
- Startup log shows the embedded console being served (this was the regression that `fix(release): require embedded console assets` guards).
|
||||
- Open `http://localhost:9001` in the browser: login with `rustfsadmin`/`rustfsadmin`; dashboard renders without JS console errors; create a bucket, upload a file, download it back (byte-identical), delete the object and bucket. Keep the server running for Phase 5.
|
||||
|
||||
## Phase 5 — Validate with the latest rc client
|
||||
|
||||
`rc` is the RustFS CLI client from <https://github.com/rustfs/cli>.
|
||||
|
||||
- Ensure the latest release is installed: compare `rc --version` against `gh api repos/rustfs/cli/releases/latest --jq .tag_name`; update via `brew upgrade rustfs/tap/rc` (or download the release binary).
|
||||
- Point it at the preview server and run the command matrix, recording PASS/FAIL per command:
|
||||
|
||||
```bash
|
||||
rc alias set preview http://localhost:9000 rustfsadmin rustfsadmin
|
||||
rc ls preview/
|
||||
rc mb preview/rel-check
|
||||
rc cp <local-file> preview/rel-check/
|
||||
rc stat preview/rel-check/<file>
|
||||
rc cat preview/rel-check/<file> # matches source
|
||||
rc cp preview/rel-check/<file> ./out && cmp <local-file> ./out
|
||||
rc cp -r <local-dir>/ preview/rel-check/dir/
|
||||
rc find preview/rel-check --name "*"
|
||||
rc share download preview/rel-check/<file> --expire 1h # presigned URL fetchable via curl
|
||||
rc rm preview/rel-check/<file> && rc rm -r --force preview/rel-check/dir
|
||||
rc rb preview/rel-check
|
||||
rc admin user list preview/
|
||||
rc admin user add preview/ relcheckuser relchecksecret12
|
||||
rc admin user remove preview/ relcheckuser
|
||||
rc alias remove preview
|
||||
```
|
||||
|
||||
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
|
||||
Read and complete [preview acceptance](references/preview-acceptance.md): verify the downloaded binary's tag/SHA and readiness, exercise Console CRUD with byte-identical download, and pass the full latest-rc command matrix. Any failure blocks final publication. Retain the results for the confirmation gate below.
|
||||
|
||||
### Manual confirmation gate
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Console Release Gate
|
||||
|
||||
Read during Phase 0, before changing RustFS version files or tags. Follow the parent skill's release scope and authorization rules.
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Preview Artifact Acceptance
|
||||
|
||||
Read after Phase 3 succeeds. Complete every check below before the parent skill's manual confirmation gate. These checks cover the downloaded artifact, embedded Console, and latest rc client.
|
||||
|
||||
## Phase 4 — Run the artifact locally, verify the console
|
||||
|
||||
Work inside the session scratchpad directory; never leave stray data dirs.
|
||||
|
||||
```bash
|
||||
gh release download "<preview-tag>" -p "rustfs-macos-aarch64-v<preview-tag>.zip" -D "$SCRATCH"
|
||||
cd "$SCRATCH" && unzip -o rustfs-*.zip
|
||||
./rustfs --version # must report the PREVIEW TAG (build::TAG), not the Cargo.toml version, plus expected short SHA
|
||||
mkdir -p data
|
||||
RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin ./rustfs ./data
|
||||
```
|
||||
|
||||
Defaults: S3 endpoint `:9000`, embedded console `:9001`.
|
||||
|
||||
Checks (all must pass):
|
||||
|
||||
- `./rustfs --version` reports the preview tag name and the short SHA of `PREVIEW_HASH`. Reporting `<target>` without the `-preview.N` suffix means the build did not embed the tag — treat as FAIL and investigate before proceeding.
|
||||
- `curl -fsS http://localhost:9000/health/ready` returns ready.
|
||||
- Startup log shows the embedded console being served (this was the regression that `fix(release): require embedded console assets` guards).
|
||||
- Open `http://localhost:9001` in the browser: login with `rustfsadmin`/`rustfsadmin`; dashboard renders without JS console errors; create a bucket, upload a file, download it back (byte-identical), delete the object and bucket. Keep the server running for Phase 5.
|
||||
|
||||
## Phase 5 — Validate with the latest rc client
|
||||
|
||||
`rc` is the RustFS CLI client from <https://github.com/rustfs/cli>.
|
||||
|
||||
- Ensure the latest release is installed: compare `rc --version` against `gh api repos/rustfs/cli/releases/latest --jq .tag_name`; update via `brew upgrade rustfs/tap/rc` (or download the release binary).
|
||||
- Point it at the preview server and run the command matrix, recording PASS/FAIL per command:
|
||||
|
||||
```bash
|
||||
rc alias set preview http://localhost:9000 rustfsadmin rustfsadmin
|
||||
rc ls preview/
|
||||
rc mb preview/rel-check
|
||||
rc cp <local-file> preview/rel-check/
|
||||
rc stat preview/rel-check/<file>
|
||||
rc cat preview/rel-check/<file> # matches source
|
||||
rc cp preview/rel-check/<file> ./out && cmp <local-file> ./out
|
||||
rc cp -r <local-dir>/ preview/rel-check/dir/
|
||||
rc find preview/rel-check --name "*"
|
||||
rc share download preview/rel-check/<file> --expire 1h # presigned URL fetchable via curl
|
||||
rc rm preview/rel-check/<file> && rc rm -r --force preview/rel-check/dir
|
||||
rc rb preview/rel-check
|
||||
rc admin user list preview/
|
||||
rc admin user add preview/ relcheckuser relchecksecret12
|
||||
rc admin user remove preview/ relcheckuser
|
||||
rc alias remove preview
|
||||
```
|
||||
|
||||
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
|
||||
@@ -4,17 +4,16 @@ description: "Prepare the version-file and release-asset bump for an exact RustF
|
||||
---
|
||||
# RustFS Release Version Bump
|
||||
|
||||
Use this skill to publish a RustFS release (alpha, beta, or stable) with a minimal, auditable diff and a complete ship flow (`edit -> verify -> commit -> push -> PR`).
|
||||
Use this skill to prepare and verify release version files. Commit, push, and PR steps apply only when included in the user's delivery scope; publishing release tags belongs to `rustfs-release-publish`.
|
||||
|
||||
Validated baseline: release pattern used in PR `#2957`.
|
||||
|
||||
## Required inputs
|
||||
|
||||
- Exact target version, for example `1.0.0-beta.4`.
|
||||
- Delivery scope:
|
||||
- Local only (`edit/verify`).
|
||||
- Local + git (`commit/push`).
|
||||
- Full GitHub flow (`commit/push/PR`).
|
||||
- Delivery scope: local (`edit/verify`), git (`commit/push`), or GitHub
|
||||
(`commit/push/PR`). Derive it from the conversation; when unspecified, prepare
|
||||
and verify locally without blocking on a delivery question.
|
||||
|
||||
If target version is missing or ambiguous, stop and ask before editing.
|
||||
|
||||
@@ -23,7 +22,7 @@ Reject any target version containing `-preview`: preview identifiers are tag-onl
|
||||
## Read before editing
|
||||
|
||||
- `AGENTS.md` (root and nearest path-specific files).
|
||||
- `.github/pull_request_template.md`.
|
||||
- `.github/pull_request_template.md` only when preparing a PR.
|
||||
- Current branch status and diff against `origin/main`.
|
||||
|
||||
## Default release file scope
|
||||
@@ -50,8 +49,7 @@ Only drop a file when the current repository release process clearly no longer r
|
||||
## Step-by-step workflow
|
||||
|
||||
1. Confirm intent and isolate scope
|
||||
- Confirm target version string exactly.
|
||||
- Confirm whether user requested local-only or full GitHub flow.
|
||||
- Use the exact target and delivery scope already supplied; ask only for a missing or ambiguous target or a material release-policy choice.
|
||||
- Inspect current branch and ensure only release-related files are touched for this task.
|
||||
|
||||
2. Update workspace versions
|
||||
@@ -82,18 +80,18 @@ Only drop a file when the current repository release process clearly no longer r
|
||||
4. Verify before shipping
|
||||
- Run:
|
||||
- `make pre-commit`
|
||||
- If `make pre-commit` fails, return `BLOCKED` with root cause and do not silently widen scope to fix unrelated issues unless user asks.
|
||||
- If `make pre-commit` fails, fix task-attributable failures and rerun affected checks. Report unresolved required checks as `BLOCKED`; do not silently widen scope to fix unrelated issues.
|
||||
|
||||
5. Commit strategy
|
||||
5. Commit strategy (only when committing is authorized)
|
||||
- Preferred split when both parts changed:
|
||||
- `chore(release): prepare <version>` for `Cargo.toml` and `Cargo.lock`.
|
||||
- `chore(release): align release assets for <version>` for docs and packaging files.
|
||||
- If user asks for one commit, use one commit.
|
||||
- Stage only intended release files; do not include unrelated working tree changes.
|
||||
|
||||
6. Push and PR
|
||||
6. Push and PR (only for the authorized delivery scope)
|
||||
- Push branch:
|
||||
- `git push -u origin <branch>` (first push), or `git push` (tracking already exists).
|
||||
- Use the user-requested or configured push remote: `git push -u <push-remote> <branch>` (first push), or `git push` when tracking is already configured.
|
||||
- Create PR with template headings unchanged:
|
||||
- `gh pr create --base main --head <branch> --title ... --body-file ...`
|
||||
- PR title/body must be English.
|
||||
|
||||
@@ -12,8 +12,9 @@ matched security surface, the concise security reference under
|
||||
## Workflow
|
||||
|
||||
1. Freeze the exact diff/head and identify the changed trust boundaries.
|
||||
2. Read [advisory-patterns.md](references/advisory-patterns.md), then apply only
|
||||
the matching sections. Useful headings are
|
||||
2. Inspect the headings in [advisory-patterns.md](references/advisory-patterns.md),
|
||||
then read the matching sections. Read the full map only for a broad security
|
||||
audit. Useful headings are
|
||||
auth/admin, IAM/STS/OIDC, policy/plugins, S3/copy/multipart, protocols, paths,
|
||||
secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde.
|
||||
3. Trace unauthenticated, low-privilege, wrong-action/owner/bucket, malformed,
|
||||
|
||||
@@ -108,9 +108,9 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
|
||||
### Serde deserialization and input validation
|
||||
|
||||
- No `#[serde(deny_unknown_fields)]` found across the entire codebase. Lesson: all structs deserialized from untrusted input (S3 API XML/JSON, lifecycle rules, bucket policies, replication configs) should have `#[serde(deny_unknown_fields)]` to reject malformed or adversarial payloads.
|
||||
- Reject unknown fields in untrusted S3 API XML/JSON, lifecycle, policy, and replication input where compatibility permits. Check the current type and supported payload fixtures; do not infer the repository's current coverage from an older audit. Where extra fields are part of the compatibility contract, validate security-critical values explicitly.
|
||||
- `#[serde(default)]` on security-critical fields silently accepts missing values as zero/empty. Lesson: when a field has security implications (retention days, permissions, limits), validate the deserialized value explicitly rather than relying on defaults.
|
||||
- Integer fields deserialized from user input and cast with `as` (e.g., `i32 as u32`) can wrap negative values to large positives. Lesson: validate ranges before casting; use `try_into()` or clamp.
|
||||
- Integer fields deserialized from user input and cast with `as` (e.g., `i32 as u32`) can wrap negative values to large positives. Lesson: validate ranges before casting; use `try_into()` with a typed error, or clamp only when the domain explicitly requires saturation.
|
||||
- XML config typos (e.g., `"NoncurentDays"` instead of `"NoncurrentDays"`) are silently accepted when `deny_unknown_fields` is absent. Lesson: strict deserialization prevents silent misconfiguration that could cause data loss or unexpected retention behavior.
|
||||
|
||||
## Useful Search Seeds
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: test-coverage-improver
|
||||
description: Run project coverage checks, rank high-risk gaps, and propose high-impact tests to improve regression confidence for changed and critical code paths before release.
|
||||
description: Analyze a supplied coverage report or perform an explicitly requested RustFS coverage assessment, rank uncovered risks, and propose focused tests. Do not trigger for ordinary implementation verification, a single regression test, documentation wording, or release preparation without a coverage request.
|
||||
---
|
||||
|
||||
# Test Coverage Improver
|
||||
@@ -9,58 +9,49 @@ Use this skill when you need a prioritized, risk-aware plan to improve tests fro
|
||||
|
||||
## Usage assumptions
|
||||
- Focus scope is either changed lines/files, a module, or the whole repository.
|
||||
- Coverage artifact must be generated or provided in a supported format.
|
||||
- Reuse a supplied coverage artifact when its revision, scope, and format match.
|
||||
- If required context is missing, call out assumptions explicitly before proposing work.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Define scope and baseline
|
||||
- Confirm target language, framework, and branch.
|
||||
- Confirm whether the scope is changed files only or full-repo.
|
||||
- Derive the revision and scope from the request, diff, or supplied report.
|
||||
- Default to the affected files/module; whole-workspace coverage requires that
|
||||
scope in the request. Ask only if a wrong scope would change the result.
|
||||
|
||||
2. Produce coverage snapshot
|
||||
- Rust: `cargo llvm-cov` (or `cargo tarpaulin`) with existing repo config.
|
||||
- JavaScript/TypeScript: `npm test -- --coverage` and read `coverage/coverage-final.json`.
|
||||
- Python: `pytest --cov=<pkg> --cov-report=json` and read `coverage.json`.
|
||||
- Collect total, per-file, and changed-line coverage.
|
||||
2. Obtain coverage evidence
|
||||
- First inspect a matching existing artifact; do not regenerate it merely
|
||||
because this skill was selected.
|
||||
- If measurement is needed, read the Coverage section of
|
||||
[the testing guide](../../../docs/testing/README.md#coverage), check disk
|
||||
space/tool availability, and select package/test-scoped `cargo llvm-cov`
|
||||
using the repository's nextest configuration. `make coverage` measures the
|
||||
whole workspace (excluding E2E) and is only for that requested scope.
|
||||
- Collect only metrics the report supports. Missing branch/changed-line
|
||||
coverage is unknown, not zero.
|
||||
- If measurement cannot run, continue with code-based test proposals and
|
||||
mark measured coverage unverified; do not invent a coverage percentage.
|
||||
|
||||
3. Rank highest-risk gaps
|
||||
- Prioritize changed code, branch coverage gaps, and low-confidence boundaries.
|
||||
- Apply the risk rubric in [coverage-prioritization.md](references/coverage-prioritization.md).
|
||||
- Keep shortlist to 5–8 gaps.
|
||||
- Report up to 5–8 evidenced gaps; do not pad a small scope.
|
||||
- For each gap, capture: file, lines, uncovered branches, and estimated risk score.
|
||||
|
||||
4. Propose high-impact tests
|
||||
- For each shortlisted gap, output:
|
||||
- Intent and expected behavior.
|
||||
- Normal, edge, and failure scenarios.
|
||||
- Assertions and side effects to verify.
|
||||
- Setup needs (fixtures, mocks, integration dependencies).
|
||||
- Estimated effort (`S/M/L`).
|
||||
- For each gap, name the behavior and regression, distinguishing assertions,
|
||||
relevant normal/edge/failure cases, necessary setup, and estimated effort.
|
||||
- Include only scenarios and setup that apply; reuse shared fixture details.
|
||||
|
||||
5. Close with validation plan
|
||||
- State which gaps remain after proposals.
|
||||
- Provide concrete verification command and acceptance threshold.
|
||||
- Give a scoped verification command and behavior-based acceptance criterion;
|
||||
use a coverage threshold only when the task or repository requires one.
|
||||
- List assumptions or blockers (environment, fixtures, flaky dependencies).
|
||||
|
||||
## Output template
|
||||
## Report
|
||||
|
||||
### Coverage Snapshot
|
||||
- total / branch coverage
|
||||
- changed-file coverage
|
||||
- top missing regions by size
|
||||
|
||||
### Top Gaps (ranked)
|
||||
- `path:line-range` | risk score | why critical
|
||||
|
||||
### Test Proposals
|
||||
- `path:line-range`
|
||||
- Test name
|
||||
- scenarios
|
||||
- assertions
|
||||
- effort
|
||||
|
||||
### Validation Plan
|
||||
- command
|
||||
- pass criteria
|
||||
- remaining risk
|
||||
Summarize the supported metrics, then combine each ranked gap with its proposed
|
||||
test and validation. Include source lines only when supplied or inspected;
|
||||
mark missing metrics or locations as unknown. Do not duplicate gaps and tests
|
||||
in separate templates or fill empty categories for an otherwise small report.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "Test Coverage Improver"
|
||||
short_description: "Find top uncovered risk areas and propose high-impact tests."
|
||||
default_prompt: "Run coverage checks, identify largest gaps, and recommend highest-impact test cases to improve risk coverage."
|
||||
default_prompt: "Use $test-coverage-improver to analyze coverage for the requested scope, reuse matching reports, and propose tests for evidenced risks."
|
||||
|
||||
@@ -5,8 +5,11 @@ description: Debug ILM tiering / lifecycle transition issues — NoSuchVersion o
|
||||
|
||||
# Tier / ILM Debugging
|
||||
|
||||
Full playbook: [docs/operations/tier-ilm-debugging.md](../../../docs/operations/tier-ilm-debugging.md)
|
||||
— read it before changing tier code.
|
||||
Playbook: [docs/operations/tier-ilm-debugging.md](../../../docs/operations/tier-ilm-debugging.md).
|
||||
Read the section matching the symptom: metadata/`xl.meta`, runtime versionId,
|
||||
manual jobs, or retained-record recovery. Read the local-first expiry invariant
|
||||
before changing cleanup ordering. Before a reconcile/disposition action, read
|
||||
its entire procedure and retain its exact-evidence and confirmation gates.
|
||||
|
||||
Quick moves:
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Bound individual tool outputs retained in context; retrieve relevant ranges
|
||||
# from task-owned log files when more evidence is needed.
|
||||
# https://learn.chatgpt.com/docs/config-file/config-reference
|
||||
|
||||
tool_output_token_limit = 4000
|
||||
@@ -0,0 +1,2 @@
|
||||
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
|
||||
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=ef914ec0b8daa9c2c5e52f501d339914662f42d6f6ed9d33877d56b97adf16f9
|
||||
sha256-linux=a8a816d7bb0e7cb5632b1863b33794bcb9fc7e765f150aa5e1bf16518e28dfb4
|
||||
sha256-darwin=53b05ac745905809d3828c6994bdd8ecf9d20b2b61a8a9d80fe15eb62f932193
|
||||
sha256-linux=7c892afa4b9d1591b46bd79c976b647109a277284fddb3b98edced4b0297eda2
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
sha256=51da41c54167602f2bd6c45921b39a44562bf3cfcdf468d992bb992c62cad7fd
|
||||
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
|
||||
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
sha256=87c05c46d611ea7ed3feb5f7276bda8e5a0f70d72165d305d73a457907e7ba79
|
||||
@@ -1 +1 @@
|
||||
sha256=8d5517f5f2fc32d561782dfccd51b7f746f5e25b2835e37e100c883f7f18777d
|
||||
sha256=95c8adc016bbc0df9fb2afa24a108bcdf6567ec4d0518725a6cae301593ab556
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=dbebfbab9b9efd4eff31211e69dd32235dc00e207f2ab0dd919a1b2ac9e724c2
|
||||
sha256=5db88c6fec94d4f269c7d9cfc128bd2adc27b3d7021127e2fa0b1daccc5f900f
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"lane": "ci/test-and-lint",
|
||||
"tests": [
|
||||
{
|
||||
"invariant": "write-quorum",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::inline_put_commit_path_tests::inline_put_direct_commit_accepts_exact_quorum_and_rejects_quorum_minus_one"
|
||||
},
|
||||
{
|
||||
"invariant": "metadata-rollback",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::write_unique_file_info_reverts_metadata_when_write_quorum_fails"
|
||||
},
|
||||
{
|
||||
"invariant": "stale-writer",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::put_object_no_lock_aborts_after_outer_namespace_lock_loss"
|
||||
},
|
||||
{
|
||||
"invariant": "range-body",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::transition_upload_integrity_tests::transitioned_compressed_object_range_get_returns_plaintext_slice"
|
||||
},
|
||||
{
|
||||
"invariant": "multipart-cancellation",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::multipart::tests::cancelled_complete_keeps_upload_lock_through_tail_cleanup"
|
||||
},
|
||||
{
|
||||
"invariant": "list-uncommitted-version",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "metacache::tests::resolve_with_write_quorum_slack_keeps_partial_latest_hidden_during_merge"
|
||||
},
|
||||
{
|
||||
"invariant": "minio-object-fixture",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "filemeta::test::parses_real_minio_object_xlmeta"
|
||||
},
|
||||
{
|
||||
"invariant": "corrupt-part-arrays",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "filemeta::test::crc_valid_but_part_arrays_corrupt_into_fileinfo_errors_not_panics"
|
||||
},
|
||||
{
|
||||
"invariant": "odm-source-contract-s3",
|
||||
"suite": "rustfs",
|
||||
"name": "on_demand_migration::source_client::tests::s3_backend_satisfies_the_shared_backend_contract"
|
||||
},
|
||||
{
|
||||
"invariant": "odm-source-contract-azure",
|
||||
"suite": "rustfs",
|
||||
"name": "on_demand_migration::azure::tests::azure_backend_satisfies_the_shared_backend_contract"
|
||||
},
|
||||
{
|
||||
"invariant": "odm-source-contract-gcs",
|
||||
"suite": "rustfs",
|
||||
"name": "on_demand_migration::gcs::tests::gcs_native_backend_satisfies_the_shared_backend_contract"
|
||||
}
|
||||
],
|
||||
"fixtures": [
|
||||
{
|
||||
"path": "crates/filemeta/tests/fixtures/minio/object_large_bin.xlmeta.hex",
|
||||
"sha256": "e8093767806d701e639b48d023190e858fbc4cde69bcfd83c22af8cba8452ce5",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/filemeta/tests/fixtures/minio/object_small_txt.xlmeta.hex",
|
||||
"sha256": "2a415ad3a3be5a9440035d4026ff880e0e8c1ec1701be9f4e077734e8dce03da",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/filemeta/tests/fixtures/minio/object_versioned_txt.xlmeta.hex",
|
||||
"sha256": "7f21f50c326dd8b0228deb6dbdb7052b3d0a3f8ee6c85d43486f0e6bb7a97261",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata.blob.hex",
|
||||
"sha256": "f2b6e260aff106adf6039feb1c645686e84e75404ff725491fb18668be5db203",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata_full.xlmeta.hex",
|
||||
"sha256": "3b6de589519c08a1614c8bd409bb8199c17d42043861b07bce513075e6fbfc12",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,9 +3,10 @@
|
||||
.NOTPARALLEL: pre-commit pre-pr dev-check
|
||||
|
||||
.PHONY: setup-hooks
|
||||
setup-hooks: ## Set up git hooks
|
||||
setup-hooks: ## Install the configured pre-commit hooks
|
||||
@echo "🔧 Setting up git hooks..."
|
||||
chmod +x .git/hooks/pre-commit
|
||||
pre-commit validate-config
|
||||
pre-commit install
|
||||
@echo "✅ Git hooks setup complete!"
|
||||
|
||||
.PHONY: doc-paths-check
|
||||
|
||||
@@ -31,6 +31,7 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_object_batch_bench_enhanced.sh
|
||||
./scripts/test_hotpath_warp_ab_gate.sh
|
||||
./scripts/test_hotpath_warp_abba.sh
|
||||
./scripts/test_scanner_validation_harness.sh
|
||||
./scripts/test_exact_1mib_handoff_abba.sh
|
||||
./scripts/test_pinned_paired_abba_bench.sh
|
||||
./scripts/test_manual_transition_runbooks.sh
|
||||
@@ -40,10 +41,13 @@ script-tests: ## Run shell script tests
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_nightly_candidate.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/s3-tests/test_report_compat.py
|
||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||
./scripts/run_scanner_heal_evidence_case.sh --self-test
|
||||
|
||||
.PHONY: test
|
||||
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# Committed floor for the number of tests selected by the migration-critical
|
||||
# CI gate (see scripts/check_migration_gate_count.sh, backlog#1153 infra-12).
|
||||
#
|
||||
# The floor equals the exact count of rustfs-ecstore --lib tests matching the
|
||||
# gate filter (name substrings: data_movement, rebalance, decommission,
|
||||
# source_cleanup, delete_marker) at the time this file was last updated.
|
||||
# The floor equals the exact count of rustfs-ecstore --lib tests, with the
|
||||
# test-util feature enabled, matching the gate filter (name substrings:
|
||||
# data_movement, rebalance, decommission, source_cleanup, delete_marker) at
|
||||
# the time this file was last updated.
|
||||
# CI fails if the selected count drops below this number, so renames or
|
||||
# removals that thin the gate must update this file in the same PR.
|
||||
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
||||
571
|
||||
946
|
||||
|
||||
+121
-13
@@ -69,7 +69,7 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.default.scripts]]
|
||||
@@ -183,6 +183,13 @@ test-group = 'e2e-reliability'
|
||||
filter = 'package(e2e_test) & test(/^inline_fast_path_cluster_test::/)'
|
||||
test-group = 'e2e-inline-boundaries'
|
||||
|
||||
# 4-node 4-drive distributed Actions suite: each case starts four rustfs
|
||||
# processes and up to sixteen data directories. Serialize across nextest's
|
||||
# process boundary so several 4x4 clusters never overlap.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||
test-group = 'e2e-cluster-nightly'
|
||||
|
||||
# Vault KMS tests share the fixed dev-server port 8200. serial_test's #[serial]
|
||||
# does not cross nextest process boundaries, so keep every Vault-backed test in
|
||||
# one group.
|
||||
@@ -210,7 +217,7 @@ filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
filter = 'package(rustfs-ecstore) | package(rustfs-s3select-api) | package(rustfs-scanner) | (package(rustfs) & test(/^(app::multipart_usecase::tests::concurrent_completions_share_durable_bucket_quota_reservations|app::object::delete::tests::compressed_delete_requests_update_observed_usage_without_releasing_quota_floor|app::object::internal_put::tests::internal_multipart_roundtrip_completes_and_abort_leaves_nothing|app::object::restore::tests::execute_restore_object_maps_failures_to_typed_s3_errors|storage::access::tests::(delete_object_access_captures_authorized_bucket_incarnation|copy_operations_reject_recreated_source_bucket_after_authorization|request_slot_keeps_bucket_policy_bound_to_its_store))$/))'
|
||||
setup = 'ecstore-base-stack'
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
@@ -355,7 +362,8 @@ test-group = 'ecstore-serial-flaky'
|
||||
# allowlist", so any new replication test lands in nightly by default (never
|
||||
# silently unrun) until it is explicitly blessed as fast here. Keep the two
|
||||
# regexes byte-identical. The committed profile selection digests make changes
|
||||
# visible in CI; current counts live in docs/testing/e2e-suite-inventory.md.
|
||||
# visible in CI; list current membership with `cargo nextest list -p e2e_test
|
||||
# --profile <profile>` (platform-dependent; see docs/testing/README.md).
|
||||
# HISTORY (2026-07-11): the 20 fast tests were briefly pulled out of this lane
|
||||
# (#4724) because they set a loopback (127.0.0.1) replication target that the
|
||||
# SSRF egress guard rejected on every PR after repl-1 (#4712). That is fixed —
|
||||
@@ -393,13 +401,32 @@ test-group = 'ecstore-serial-flaky'
|
||||
# rustfs/rustfs#5169 disabled them) have PR-lane signal, not just merge-gate.
|
||||
# Single-node servers on random ports with isolated temp dirs — meets the
|
||||
# admission criteria unchanged.
|
||||
#
|
||||
# On-demand migration GA (backlog#2163 ODM-16): three named cases join the
|
||||
# lane, one per user-visible contract of the feature — a GET miss that pulls
|
||||
# the object and persists it locally, a HEAD miss that answers from the source
|
||||
# and stores nothing, and the admin config/status pair that must redact the
|
||||
# source secret. Each spawns one single-node rustfs server plus the in-process
|
||||
# fake S3 source (`fake_s3_target`, already in the first clause), so they meet
|
||||
# the admission criteria unchanged; measured at 15.8 s / 15.8 s / 15.9 s, which
|
||||
# is entirely the shared server startup and overlaps the lane's other tests.
|
||||
# The rest of `on_demand_migration::{get_basic,interaction,backfill,
|
||||
# harness_self}_test` stays in e2e-full and the fault / concurrency /
|
||||
# real-source modules stay in e2e-nightly; this is an allowlist, not a module
|
||||
# clause, so a new ODM test never lands here silently.
|
||||
#
|
||||
# Scanner authoritative usage publication (backlog#2213): data_usage_test is
|
||||
# the PR-lane e2e coverage for scanner usage snapshots consumed by quota and
|
||||
# admin surfaces. It uses the same single-node, random-port, isolated-temp-dir
|
||||
# fixture as the existing smoke modules.
|
||||
[profile.e2e-smoke]
|
||||
default-filter = """
|
||||
package(e2e_test) & (
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat)_test::|^fake_s3_target::/)
|
||||
test(/^(delete_marker_migration_semantics|version_id_regression|list_objects_v2_pagination|list_object_versions_regression|list_objects_duplicates|list_buckets_double_slash|list_buckets_auth|list_buckets_iam_filter|leading_slash_key|special_chars|create_bucket_region|delete_objects_versioning|head_object_consistency|head_object_range|copy_object_metadata|copy_object_tagging|copy_source_invalid_date|content_encoding|compression|multipart_storage_class|storage_class_capability|ssec_copy|anonymous_access|bucket_policy_check|presigned_negative|negative_sigv4|admin_auth|notification_webhook|tls_hot_reload|console_smoke|admin_iam_crud|admin_pools|sts_query_compat|data_usage)_test::|^fake_s3_target::/)
|
||||
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
||||
| test(/^reliant::lifecycle::/)
|
||||
| test(/^reliant::tiering::/)
|
||||
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
@@ -439,6 +466,10 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
# until it is explicitly promoted to the fast PR subset — no replication test
|
||||
# is ever silently left out of CI.
|
||||
#
|
||||
# replication_target_matrix_test (the outbound target matrix: every object
|
||||
# shape against every remote-target failure mode the fake target models) runs
|
||||
# here in full; its expectation table pins known-red cells to open issues.
|
||||
#
|
||||
# #[serial] does NOT serialize under nextest (process-per-test; see the file
|
||||
# header). These tests need no cross-test serialization: each spawns its own
|
||||
# server(s) on random ports with isolated temp dirs, so they are parallel-safe
|
||||
@@ -456,7 +487,7 @@ slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
[profile.e2e-repl-nightly]
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& test(/^replication_extension_test::/)
|
||||
& (test(/^replication_extension_test::/) | test(/^replication_target_matrix_test::/))
|
||||
& !test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
||||
"""
|
||||
fail-fast = false
|
||||
@@ -469,15 +500,29 @@ path = "junit.xml"
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-nightly profile — destructive multi-process cluster fault domains
|
||||
# ---------------------------------------------------------------------------
|
||||
# These seven modules are deliberately outside e2e-full's merge budget. Each
|
||||
# These eight modules are deliberately outside e2e-full's merge budget. Each
|
||||
# starts a real multi-process or multi-disk topology and exercises node/disk
|
||||
# loss, quorum, cleanup, notification fan-in, or admin-timeout behavior. The
|
||||
# consolidated nightly workflow runs them serially to avoid resource
|
||||
# starvation; failures are never retried.
|
||||
#
|
||||
# heal_erasure_disk_rebuild_test also runs in e2e-full so core heal rebuild
|
||||
# regressions are caught no later than the merge/main lane. It remains here for
|
||||
# nightly serial coverage with the other cluster fault domains.
|
||||
#
|
||||
# On-demand migration (backlog#2158 ODM-11) joins by the second clause: the
|
||||
# fault matrix waits out the 30 s circuit-breaker window, the concurrency
|
||||
# matrix drives 100-deep bursts, and the real-source cases start a second
|
||||
# (loop guard: a third) RustFS process. They are too slow or too heavy for
|
||||
# the merge budget; `on_demand_migration::{get_basic,interaction}_test` stay
|
||||
# in e2e-full, which excludes exactly these three modules.
|
||||
[profile.e2e-nightly]
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& (
|
||||
test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
| test(/^on_demand_migration::(concurrency_test|fault_test|real_source_test)::/)
|
||||
)
|
||||
"""
|
||||
fail-fast = false
|
||||
|
||||
@@ -488,6 +533,55 @@ path = "junit.xml"
|
||||
filter = 'package(e2e_test)'
|
||||
test-group = 'e2e-cluster-nightly'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-distributed profile — 4-node 4-disk Actions suite
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage-sensitive PR / nightly / dispatch lane owned by
|
||||
# .github/workflows/e2e-distributed.yml.
|
||||
# Each case starts four rustfs processes (and for site replication, two
|
||||
# clusters). Upgrade cases also require RUSTFS_UPGRADE_SOURCE_BINARY.
|
||||
# Serialized via e2e-cluster-nightly with no retries.
|
||||
[profile.e2e-distributed]
|
||||
default-filter = 'package(e2e_test) & test(/^distributed::/)'
|
||||
fail-fast = false
|
||||
# Decommission / rebalance cases poll for up to 180s with little stdout.
|
||||
slow-timeout = { period = "120s", terminate-after = 6 }
|
||||
|
||||
[profile.e2e-distributed.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
[[profile.e2e-distributed.overrides]]
|
||||
filter = 'package(e2e_test)'
|
||||
test-group = 'e2e-cluster-nightly'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-odm-interop profile — on-demand migration provider interop lane (ODM-20)
|
||||
# ---------------------------------------------------------------------------
|
||||
# backlog#2167. Report-only, scheduled, never a required check; wired by
|
||||
# .github/workflows/on-demand-migration-interop.yml.
|
||||
#
|
||||
# The four cases in `on_demand_migration::interop_test` take their source from
|
||||
# the environment (`RUSTFS_ODM_INTEROP_*`, documented on the constants in
|
||||
# `crates/e2e_test/src/on_demand_migration/common.rs`), so the same bodies run
|
||||
# against the in-process fake source locally and against a MinIO container or a
|
||||
# real cloud provider in the lane. The cloud jobs narrow this profile with their
|
||||
# own `-E` filter to the three-case minimum (GET miss, HEAD miss, merged list
|
||||
# pagination) and pass `--no-tests=fail` so a rename cannot silently select
|
||||
# nothing; the MinIO job runs the whole profile, backfill included.
|
||||
#
|
||||
# These cases are deliberately absent from every other lane: without an
|
||||
# interop source they only re-prove what `get_basic_test` and
|
||||
# `list_through_test` already cover in e2e-smoke and e2e-full. The committed
|
||||
# selection digest is the guard against a rename dropping one of them.
|
||||
[profile.e2e-odm-interop]
|
||||
default-filter = 'package(e2e_test) & test(/^on_demand_migration::interop_test::/)'
|
||||
fail-fast = false
|
||||
|
||||
[profile.e2e-odm-interop.junit]
|
||||
# Emitted to target/nextest/e2e-odm-interop/junit.xml; the lane uploads it and
|
||||
# reconciles it against the per-case JSON report entries.
|
||||
path = "junit.xml"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# e2e-protocols profile — serial protocol lane
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -508,16 +602,27 @@ path = "junit.xml"
|
||||
# quota, checksum, encryption,
|
||||
# security-boundary, ... — that the fast PR `e2e-smoke` subset deliberately
|
||||
# skips. Budget <= 45 min; authority for the suite count is `cargo nextest list
|
||||
# --profile e2e-full` (see docs/testing/e2e-suite-inventory.md).
|
||||
# --profile e2e-full -p e2e_test` (platform-dependent; see docs/testing/README.md).
|
||||
#
|
||||
# The filter is "the whole e2e_test crate MINUS the sets owned by other lanes":
|
||||
# * protocols:: — FTPS/SFTP/WebDAV, run from the dedicated protocol profile
|
||||
# with one worker because the suite owns fixed ports.
|
||||
# * the 7 cluster suites that spin up a RustFSTestClusterEnvironment
|
||||
# * cluster suites that spin up a RustFSTestClusterEnvironment
|
||||
# (cluster_concurrency, cluster_multidrive_pool, stale_multipart_cleanup_cluster,
|
||||
# namespace_lock_quorum, heal_erasure_disk_rebuild, admin_timeout_regression,
|
||||
# object_lambda) — too heavy for the merge budget; they run in the
|
||||
# e2e-nightly serial cluster-fault lane.
|
||||
# namespace_lock_quorum, admin_timeout_regression, object_lambda) — too
|
||||
# heavy for the merge budget; they run in the e2e-nightly serial
|
||||
# cluster-fault lane. heal_erasure_disk_rebuild is intentionally not
|
||||
# excluded here because backlog#2213 promotes core heal rebuild coverage to
|
||||
# this merge/main lane while retaining nightly coverage.
|
||||
# * distributed:: — 4-node 4-disk Actions suite (S3, lock, versioning,
|
||||
# replication, quota, observability, expand/decommission/rebalance, site
|
||||
# replication, chaos, upgrade history/IAM). Owns [profile.e2e-distributed] and
|
||||
# .github/workflows/e2e-distributed.yml.
|
||||
# * on_demand_migration::interop_test — the ODM-20 provider interoperability
|
||||
# cases, which are meaningless without a source: they run in the dedicated
|
||||
# [profile.e2e-odm-interop] lane below, where the workflow points them at a
|
||||
# MinIO container or a real cloud provider. Excluding them here also keeps
|
||||
# this profile's committed selection digest stable.
|
||||
# * replication_extension_test — repl-1 already splits it into the PR
|
||||
# `e2e-smoke` (20 fast) and `e2e-repl-nightly` (56 slow) lanes and reserves
|
||||
# it for those, so e2e-full does not double-run it.
|
||||
@@ -533,8 +638,11 @@ path = "junit.xml"
|
||||
default-filter = """
|
||||
package(e2e_test)
|
||||
& !test(/^protocols::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|heal_erasure_disk_rebuild_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^(admin_timeout_regression_test|cluster_concurrency_test|cluster_multidrive_pool_test|degraded_listing_availability_test|namespace_lock_quorum_test|object_lambda_test|stale_multipart_cleanup_cluster_test)::/)
|
||||
& !test(/^distributed::/)
|
||||
& !test(/^replication_extension_test::/)
|
||||
& !test(/^replication_target_matrix_test::/)
|
||||
& !test(/^on_demand_migration::(concurrency_test|fault_test|interop_test|real_source_test)::/)
|
||||
"""
|
||||
fail-fast = false
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"cases": {
|
||||
"background-target-restart": {
|
||||
"gate": "G14",
|
||||
"task": "W21",
|
||||
"lane": "e2e-nightly",
|
||||
"suite": "e2e_test",
|
||||
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart",
|
||||
"oracle": "background-target-restart.json",
|
||||
"evidence": "process-restart",
|
||||
"unclean_shutdown_marker": false,
|
||||
"min_objects": 9,
|
||||
"max_objects": 65,
|
||||
"topology": {"nodes": 4, "drives_per_node": 1},
|
||||
"scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
||||
},
|
||||
"background-target-crash": {
|
||||
"gate": "G14",
|
||||
"task": "W21",
|
||||
"lane": "e2e-nightly",
|
||||
"suite": "e2e_test",
|
||||
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_crash",
|
||||
"oracle": "background-target-crash.json",
|
||||
"evidence": "process-crash-restart",
|
||||
"unclean_shutdown_marker": true,
|
||||
"min_objects": 9,
|
||||
"max_objects": 65,
|
||||
"topology": {"nodes": 4, "drives_per_node": 1},
|
||||
"scope": "Target process killed during partial background rebuild, real unclean-shutdown marker, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
||||
}
|
||||
},
|
||||
"release_pending": {
|
||||
"G01": "W02/W04 complete root and quota authority coverage",
|
||||
"G02": "W03 bounded checkpoint progress and independent version inventory",
|
||||
"G03": "W17/W18 exact scoped ACK with durable publication and mixed peers",
|
||||
"G04": "W03/W15/W16 crash at every cache/root/floor/intent boundary",
|
||||
"G05": "W06/W07 per-object outcomes and bounded terminal retention",
|
||||
"G06": "W06/W08/W23 concurrent status, legacy clients and truncation",
|
||||
"G07": "W12/W13/W14 durable MRF responsibility at every commit boundary",
|
||||
"G08": "W12/W13/W14 MRF capacity, disk-full and replica-loss matrix",
|
||||
"G09": "W13/W18/W23 actual mixed-version reader/writer and rollback payloads",
|
||||
"G10": "W05/W09/W10/W11 bounded scheduling and pressure recovery",
|
||||
"G11": "W04/W19/W24 maintenance and complete producer coverage",
|
||||
"G12": "W02/W15/W16 both quota paths during reset and settlement",
|
||||
"G13": "W07/W14 quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
|
||||
"G14": "W20/W21 same-window field evidence; 3x4 EC8+4 and multi-set/pool coverage",
|
||||
"P1": "W20 measured cold-walk share and foreground latency/throughput",
|
||||
"P2": "W20/W24 measured post-stop convergence and cold segment reuse",
|
||||
"P3": "W20 measured two-hour pressure/heal capacity and recovery window",
|
||||
"P4": "W20 measured MRF scale and replay cost with retained responsibility",
|
||||
"R-E": "W03/W05 fixed-budget real process restart through enumeration and classification",
|
||||
"R-D": "W07/W14 manager-to-event-to-ledger exact disposition, including grace",
|
||||
"R-L": "W13/W14 legacy source conflicts, migration gaps and crash-safe source retirement"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
name: On-demand migration interop report
|
||||
description: >-
|
||||
Merge the per-case JSON entries an on-demand-migration interop run wrote with
|
||||
the nextest JUnit result into one provider report, and summarise it.
|
||||
|
||||
inputs:
|
||||
provider:
|
||||
description: Provider the run addressed (minio, aws, r2, gcs).
|
||||
required: true
|
||||
cases-dir:
|
||||
description: Directory the cases wrote their JSON entries into.
|
||||
required: true
|
||||
junit:
|
||||
description: nextest JUnit XML of the run.
|
||||
required: true
|
||||
output:
|
||||
description: Path of the merged JSON report to write.
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# The JUnit file is authoritative for which cases ran and how they ended:
|
||||
# a case that fails or panics never reaches its own report entry, so
|
||||
# trusting the entries alone would silently shorten the report exactly when
|
||||
# something went wrong. The entries only add what JUnit cannot know — the
|
||||
# source request accounting and the bucket's migration counters.
|
||||
- name: Merge interop case reports
|
||||
shell: bash
|
||||
env:
|
||||
ODM_REPORT_PROVIDER: ${{ inputs.provider }}
|
||||
ODM_REPORT_CASES_DIR: ${{ inputs.cases-dir }}
|
||||
ODM_REPORT_JUNIT: ${{ inputs.junit }}
|
||||
ODM_REPORT_OUTPUT: ${{ inputs.output }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import xml.etree.ElementTree as ElementTree
|
||||
|
||||
provider = os.environ["ODM_REPORT_PROVIDER"]
|
||||
cases_dir = pathlib.Path(os.environ["ODM_REPORT_CASES_DIR"])
|
||||
junit = pathlib.Path(os.environ["ODM_REPORT_JUNIT"])
|
||||
output = pathlib.Path(os.environ["ODM_REPORT_OUTPUT"])
|
||||
|
||||
entries = {}
|
||||
if cases_dir.is_dir():
|
||||
for path in sorted(cases_dir.glob("*.json")):
|
||||
entry = json.loads(path.read_text())
|
||||
entries[entry["case"]] = entry
|
||||
|
||||
cases = []
|
||||
for case in ElementTree.parse(junit).getroot().iter("testcase"):
|
||||
name = case.get("name", "")
|
||||
failed = [child for child in case if child.tag in ("failure", "error")]
|
||||
skipped = [child for child in case if child.tag == "skipped"]
|
||||
outcome = "failed" if failed else "skipped" if skipped else "passed"
|
||||
entry = entries.get(name.rsplit("::", 1)[-1], {})
|
||||
cases.append(
|
||||
{
|
||||
"name": name,
|
||||
"outcome": outcome,
|
||||
"junit_duration_ms": round(float(case.get("time", "0")) * 1000),
|
||||
"case_duration_ms": entry.get("duration_ms"),
|
||||
"source_requests": entry.get("source_requests"),
|
||||
"odm_counters": entry.get("odm_counters"),
|
||||
}
|
||||
)
|
||||
|
||||
report = {
|
||||
"provider": provider,
|
||||
"repository": os.environ.get("GITHUB_REPOSITORY", ""),
|
||||
"sha": os.environ.get("GITHUB_SHA", ""),
|
||||
"run_id": os.environ.get("GITHUB_RUN_ID", ""),
|
||||
"cases": cases,
|
||||
"totals": {
|
||||
"cases": len(cases),
|
||||
"passed": sum(1 for case in cases if case["outcome"] == "passed"),
|
||||
"failed": sum(1 for case in cases if case["outcome"] == "failed"),
|
||||
},
|
||||
}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n")
|
||||
|
||||
summary = [f"### On-demand migration interop: `{provider}`", "", "| Case | Outcome | Duration | Source requests |", "|---|---|---|---|"]
|
||||
for case in cases:
|
||||
requests = case["source_requests"]
|
||||
counted = f"{requests['total']} ({requests['counted_by']})" if requests else "not reported"
|
||||
summary.append(f"| `{case['name']}` | {case['outcome']} | {case['junit_duration_ms']} ms | {counted} |")
|
||||
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(summary) + "\n\n")
|
||||
|
||||
# A passed case with no entry of its own means the harness stopped
|
||||
# writing one: the report would keep looking complete while silently
|
||||
# losing its request accounting.
|
||||
unreported = [case["name"] for case in cases if case["outcome"] == "passed" and case["source_requests"] is None]
|
||||
if unreported:
|
||||
raise SystemExit(f"passed cases wrote no interop report entry: {', '.join(unreported)}")
|
||||
PY
|
||||
@@ -0,0 +1,120 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: Quick Checks
|
||||
description: Run the shared compile-free RustFS quality checks.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install quality tools
|
||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||
with:
|
||||
tool: |
|
||||
ripgrep@15.2.0
|
||||
shellcheck@0.11.0
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
actionlint_dir="$(mktemp -d "${RUNNER_TEMP}/actionlint.XXXXXX")"
|
||||
curl --fail --location --silent --show-error \
|
||||
--output "$actionlint_dir/actionlint.tar.gz" \
|
||||
https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz
|
||||
echo "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 $actionlint_dir/actionlint.tar.gz" | sha256sum --check --status
|
||||
tar -xzf "$actionlint_dir/actionlint.tar.gz" -C "$actionlint_dir" actionlint
|
||||
rm "$actionlint_dir/actionlint.tar.gz"
|
||||
echo "$actionlint_dir" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check workflow syntax and shell scripts
|
||||
shell: bash
|
||||
run: shellcheck --version && actionlint
|
||||
|
||||
- name: Check code formatting
|
||||
shell: bash
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Check unsafe code allowances
|
||||
shell: bash
|
||||
run: ./scripts/check_unsafe_code_allowances.sh
|
||||
|
||||
- name: Check layered dependencies
|
||||
shell: bash
|
||||
run: ./scripts/check_layer_dependencies.sh
|
||||
|
||||
- name: Check architecture migration rules
|
||||
shell: bash
|
||||
run: ./scripts/check_architecture_migration_rules.sh
|
||||
|
||||
- name: Check logging guardrails
|
||||
shell: bash
|
||||
run: ./scripts/check_logging_guardrails.sh
|
||||
|
||||
- name: Check error other(format!) ratchet
|
||||
shell: bash
|
||||
run: ./scripts/check_error_other_format_ratchet.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
shell: bash
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
- name: Check extension schema boundaries
|
||||
shell: bash
|
||||
run: ./scripts/check_extension_schema_boundaries.sh
|
||||
|
||||
- name: Check body-cache whitelist guard
|
||||
shell: bash
|
||||
run: ./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
- name: Check s3s footprint ratchet
|
||||
shell: bash
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
shell: bash
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
- name: Check no embedded secret material
|
||||
shell: bash
|
||||
run: ./scripts/check_embedded_secrets.sh
|
||||
|
||||
- name: Run script contract tests
|
||||
shell: bash
|
||||
run: make script-tests
|
||||
|
||||
- name: Check test wiring
|
||||
shell: bash
|
||||
run: |
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/test_security_workflow.py
|
||||
python3 ./scripts/test_nightly_candidate.py
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
|
||||
- name: Check no planning docs committed
|
||||
shell: bash
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Check CI paths stay in sync
|
||||
shell: bash
|
||||
run: ./scripts/check_ci_paths_sync.sh
|
||||
|
||||
- name: Check io_uring lane --lib precondition
|
||||
shell: bash
|
||||
run: ./scripts/check_uring_lane_lib_only.sh
|
||||
@@ -10,16 +10,16 @@ Use N/A when there is no related issue.
|
||||
|
||||
## Summary of Changes
|
||||
<!--
|
||||
Briefly explain what changed and why reviewers should accept it.
|
||||
Focus on behavior, compatibility, and review-relevant context.
|
||||
Describe the concrete problem and resulting behavior. For a behavior change, name the input or state that triggers it and the expected outcome. Explain any new dependency or abstraction that the change needs.
|
||||
-->
|
||||
|
||||
## Verification
|
||||
<!--
|
||||
List the commands or checks you ran, for example:
|
||||
- `make pre-commit`
|
||||
Give 1–3 concrete pieces of evidence for the changed behavior: the test or command, its observed result, and the regression it catches. For a bug fix, record a failing-before/passing-after check or explain why it was unavailable.
|
||||
|
||||
Use N/A only when verification is not applicable.
|
||||
Identify the tested commit and any local changes. When testing a prebuilt binary or external service, include its source/version and artifact identity; a successful run against a different build is not evidence for this change.
|
||||
|
||||
List relevant checks not run and the remaining risk. Use the validation tier in AGENTS.md; do not run broader checks solely to fill this section. For documentation-only changes, list the applicable documentation checks. Use N/A only when verification is not applicable.
|
||||
-->
|
||||
|
||||
## Impact
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
{ "workflow": ".github/workflows/ci.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/coverage.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/e2e-replication-nightly.yml", "max_age_hours": 36 },
|
||||
{
|
||||
"workflow": ".github/workflows/e2e-distributed.yml",
|
||||
"max_age_hours": 36,
|
||||
"never_ran_grace_until": "2026-09-18T00:00:00Z"
|
||||
},
|
||||
{ "workflow": ".github/workflows/e2e-s3tests.yml", "max_age_hours": 192 },
|
||||
{ "workflow": ".github/workflows/fuzz.yml", "max_age_hours": 36 },
|
||||
{ "workflow": ".github/workflows/mint.yml", "max_age_hours": 192 },
|
||||
|
||||
@@ -28,6 +28,7 @@ on:
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened, closed ]
|
||||
@@ -43,6 +44,7 @@ on:
|
||||
- 'scripts/test_package_versions.sh'
|
||||
- 'scripts/security/check_performance_ab_workflow.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_tier_artifact_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
schedule:
|
||||
# Daily, not weekly. This schedule exists to catch RustSec advisories
|
||||
@@ -150,6 +152,9 @@ jobs:
|
||||
- name: Check performance A/B workflow trust boundary
|
||||
run: ./scripts/security/check_performance_ab_workflow.sh
|
||||
|
||||
- name: Check tier evidence workflow isolation
|
||||
run: ./scripts/security/check_tier_artifact_workflow.sh
|
||||
|
||||
- name: Check package version contract
|
||||
run: ./scripts/test_package_versions.sh
|
||||
|
||||
|
||||
@@ -1065,7 +1065,7 @@ jobs:
|
||||
while IFS= read -r preview_tag; do
|
||||
[[ -n "$preview_tag" ]] || continue
|
||||
echo "🧹 Deleting preview release $preview_tag (tag kept)"
|
||||
gh release delete "$preview_tag" --yes
|
||||
gh release delete "$preview_tag" --repo "${GITHUB_REPOSITORY}" --yes
|
||||
DELETED=$((DELETED + 1))
|
||||
done < <(
|
||||
jq -r --arg tag "$TAG" '
|
||||
|
||||
@@ -12,24 +12,10 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Companion to ci.yml for required status checks.
|
||||
#
|
||||
# ci.yml skips docs-only pull requests via paths-ignore, but the branch ruleset
|
||||
# requires a check named "Test and Lint" — without this workflow a docs-only PR
|
||||
# would wait on it forever. This workflow triggers on exactly the paths ci.yml
|
||||
# ignores and reports success under the same job name. Mixed PRs trigger both
|
||||
# workflows and the real check still gates: a required check with any failing
|
||||
# run blocks the merge.
|
||||
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
|
||||
#
|
||||
# "Quick Checks" is mirrored here ahead of the ruleset change that will make it
|
||||
# required too (rustfs/backlog#1599). Until that change lands this job is
|
||||
# inert; mirroring it first is what lets the ruleset change happen without
|
||||
# stranding docs-only PRs on a check nobody reports.
|
||||
#
|
||||
# Keep the paths list below in sync with the pull_request paths-ignore list
|
||||
# in ci.yml, and keep the quick-checks steps below byte-identical to the
|
||||
# quick-checks job in ci.yml.
|
||||
# Reports the existing required checks for paths excluded by ci.yml.
|
||||
# Mixed PRs can trigger both workflows; their Quick Checks jobs use one shared
|
||||
# action to keep validation coverage aligned. Keep this paths list in sync with
|
||||
# ci.yml's pull_request.paths-ignore via scripts/check_ci_paths_sync.sh.
|
||||
|
||||
name: Continuous Integration (docs only)
|
||||
|
||||
@@ -59,19 +45,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Deliberately NOT a bare `echo`. Once "Quick Checks" becomes a required
|
||||
# check, ci.yml gates every expensive job behind it, so a mixed PR reports
|
||||
# two check runs with this name: the real one (45-51s) and this companion.
|
||||
# GitHub has no written contract for how it picks between same-named
|
||||
# required check runs ("latest wins" vs "any failure blocks"), so instead of
|
||||
# relying on ordering we make both runs execute the same commands against
|
||||
# the same merge ref — their conclusions are then necessarily identical and
|
||||
# the choice does not matter. Keep these steps byte-identical to the
|
||||
# quick-checks job in ci.yml (a guard script that asserts this, and the paths
|
||||
# sync below, is tracked in rustfs/backlog#1603).
|
||||
#
|
||||
# For a genuinely docs-only PR this adds no strictness (no code changed, so
|
||||
# fmt and the guards always pass) and costs ~50s of ubuntu-latest.
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
runs-on: ubuntu-latest
|
||||
@@ -82,63 +55,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install ripgrep
|
||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||
with:
|
||||
tool: ripgrep@15.2.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check code formatting
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Check unsafe code allowances
|
||||
run: ./scripts/check_unsafe_code_allowances.sh
|
||||
|
||||
- name: Check layered dependencies
|
||||
run: ./scripts/check_layer_dependencies.sh
|
||||
|
||||
- name: Check architecture migration rules
|
||||
run: ./scripts/check_architecture_migration_rules.sh
|
||||
|
||||
- name: Check logging guardrails
|
||||
run: ./scripts/check_logging_guardrails.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
- name: Check extension schema boundaries
|
||||
run: ./scripts/check_extension_schema_boundaries.sh
|
||||
|
||||
- name: Check body-cache whitelist guard
|
||||
run: ./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
- name: Check s3s footprint ratchet
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
- name: Check no embedded secret material
|
||||
run: ./scripts/check_embedded_secrets.sh
|
||||
|
||||
- name: Check test wiring
|
||||
run: |
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Check CI paths stay in sync
|
||||
run: ./scripts/check_ci_paths_sync.sh
|
||||
|
||||
- name: Check io_uring lane --lib precondition
|
||||
run: ./scripts/check_uring_lane_lib_only.sh
|
||||
- name: Run shared quick checks
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
test-and-lint:
|
||||
name: Test and Lint
|
||||
|
||||
+21
-66
@@ -100,12 +100,7 @@ jobs:
|
||||
- name: Typos check with custom config file
|
||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||
|
||||
# Fast, compile-free checks that fail early so contributors get feedback in
|
||||
# ~1 minute instead of waiting for the full test job.
|
||||
#
|
||||
# These steps are mirrored byte-for-byte in ci-docs-only.yml so that a mixed
|
||||
# PR, which reports two check runs named "Quick Checks", cannot get one red
|
||||
# and one green. Edit both jobs together.
|
||||
# Fail early with compile-free checks shared with docs-only CI.
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
@@ -117,66 +112,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install ripgrep
|
||||
uses: taiki-e/install-action@bffeee26d4db9be238a4ea78d8826604ebcb594d # v2
|
||||
with:
|
||||
tool: ripgrep@15.2.0
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check code formatting
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Check unsafe code allowances
|
||||
run: ./scripts/check_unsafe_code_allowances.sh
|
||||
|
||||
- name: Check layered dependencies
|
||||
run: ./scripts/check_layer_dependencies.sh
|
||||
|
||||
- name: Check architecture migration rules
|
||||
run: ./scripts/check_architecture_migration_rules.sh
|
||||
|
||||
- name: Check logging guardrails
|
||||
run: ./scripts/check_logging_guardrails.sh
|
||||
|
||||
- name: Check error other(format!) ratchet
|
||||
run: ./scripts/check_error_other_format_ratchet.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
- name: Check extension schema boundaries
|
||||
run: ./scripts/check_extension_schema_boundaries.sh
|
||||
|
||||
- name: Check body-cache whitelist guard
|
||||
run: ./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
- name: Check s3s footprint ratchet
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
|
||||
- name: Check no embedded secret material
|
||||
run: ./scripts/check_embedded_secrets.sh
|
||||
|
||||
- name: Check test wiring
|
||||
run: |
|
||||
python3 ./scripts/check_test_wiring.py --self-test
|
||||
python3 ./scripts/check_scheduled_validation_freshness.py --self-test
|
||||
python3 ./scripts/check_test_wiring.py
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
- name: Check CI paths stay in sync
|
||||
run: ./scripts/check_ci_paths_sync.sh
|
||||
|
||||
- name: Check io_uring lane --lib precondition
|
||||
run: ./scripts/check_uring_lane_lib_only.sh
|
||||
- name: Run shared quick checks
|
||||
uses: ./.github/actions/quick-checks
|
||||
|
||||
test-and-lint:
|
||||
name: Test and Lint
|
||||
@@ -269,6 +206,7 @@ jobs:
|
||||
CARGO_BUILD_JOBS: ${{ (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && '3' || '2' }}
|
||||
run: |
|
||||
mkdir -p artifacts/test-and-lint
|
||||
rm -f target/nextest/ci/junit.xml
|
||||
./scripts/ci/resource_sampler.sh start nextest
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
set +e
|
||||
@@ -277,6 +215,12 @@ jobs:
|
||||
--status-level all --final-status-level all \
|
||||
2>&1 | tee artifacts/test-and-lint/nextest.log
|
||||
status=${PIPESTATUS[0]}
|
||||
if [[ "${status}" -eq 0 ]]; then
|
||||
cargo nextest list --profile ci --all --exclude e2e_test --message-format json \
|
||||
> artifacts/test-and-lint/core-test-listing.json \
|
||||
&& python3 scripts/check_test_wiring.py --check-core artifacts/test-and-lint/core-test-listing.json \
|
||||
&& test -s target/nextest/ci/junit.xml || status=$?
|
||||
fi
|
||||
{
|
||||
echo "command=cargo nextest run --profile ci --all --exclude e2e_test"
|
||||
echo "exit_status=${status}"
|
||||
@@ -906,6 +850,17 @@ jobs:
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Install network fault-injection tools
|
||||
run: |
|
||||
sudo apt-get install -y iptables
|
||||
sudo -n iptables --version
|
||||
# The endpoint-blackhole heal scenario needs CAP_NET_ADMIN. Containerised
|
||||
# runners can run iptables but not touch the rule set; the test then logs
|
||||
# a skip instead of failing, so surface that here where it is visible.
|
||||
if ! sudo -n iptables -w 5 -S OUTPUT >/dev/null 2>&1; then
|
||||
echo "::warning::iptables cannot read the OUTPUT chain on this runner (no CAP_NET_ADMIN); the endpoint-blackhole heal scenario will be skipped"
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
|
||||
@@ -121,6 +121,22 @@ jobs:
|
||||
create_latest=false
|
||||
source_ref="$GITHUB_SHA"
|
||||
|
||||
# Pre-GA policy: until the first stable (vX.Y.Z) tag exists, every
|
||||
# prerelease (alpha/beta/rc) also moves `latest`, so users pulling
|
||||
# `latest` get the newest test build. Once a stable tag is published
|
||||
# this returns false and `latest` follows stable releases only.
|
||||
prerelease_moves_latest() {
|
||||
local stable_tags
|
||||
stable_tags=$(git ls-remote --tags --refs origin 2>/dev/null \
|
||||
| awk '{print $2}' \
|
||||
| grep -E '^refs/tags/v?[0-9]+\.[0-9]+\.[0-9]+$' || true)
|
||||
if [[ -z "$stable_tags" ]]; then
|
||||
return 0
|
||||
fi
|
||||
echo "ℹ️ Stable release tag(s) already exist; prereleases no longer update latest"
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
# Triggered by build workflow completion
|
||||
echo "🔗 Triggered by build workflow completion"
|
||||
@@ -184,8 +200,8 @@ jobs:
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]] || [[ "$version" == *"rc"* ]]; then
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta).
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then
|
||||
# Pre-GA policy: prereleases update latest until the first stable tag exists.
|
||||
if prerelease_moves_latest; then
|
||||
create_latest=true
|
||||
echo "🧪 Building Docker image for prerelease: $version (creating latest tag)"
|
||||
else
|
||||
@@ -243,8 +259,8 @@ jobs:
|
||||
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
||||
build_type="prerelease"
|
||||
is_prerelease=true
|
||||
# Current policy: create latest tags for stable releases and selected prereleases (alpha/beta).
|
||||
if [[ "$version" == *"alpha"* ]] || [[ "$version" == *"beta"* ]]; then
|
||||
# Pre-GA policy: prereleases update latest until the first stable tag exists.
|
||||
if prerelease_moves_latest; then
|
||||
create_latest=true
|
||||
echo "🧪 Building with prerelease version: $input_version (creating latest tag)"
|
||||
else
|
||||
@@ -394,11 +410,13 @@ jobs:
|
||||
TAG_BASE="${VERSION}${VARIANT_SUFFIX}"
|
||||
TAGS="${{ env.REGISTRY_DOCKERHUB }}:$TAG_BASE,${{ env.REGISTRY_GHCR }}:$TAG_BASE,${{ env.REGISTRY_QUAY }}:$TAG_BASE"
|
||||
|
||||
# Add channel tags for prereleases and latest for stable
|
||||
# Add latest when requested (stable releases, and prereleases before GA)
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true.
|
||||
TAGS="$TAGS,${{ env.REGISTRY_DOCKERHUB }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_GHCR }}:latest${VARIANT_SUFFIX},${{ env.REGISTRY_QUAY }}:latest${VARIANT_SUFFIX}"
|
||||
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
fi
|
||||
|
||||
# Always add the channel tag for prereleases, independent of latest
|
||||
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
# Prerelease channel tags (alpha, beta, rc)
|
||||
if [[ "$VERSION" == *"alpha"* ]]; then
|
||||
CHANNEL="alpha"
|
||||
@@ -555,7 +573,7 @@ jobs:
|
||||
"prerelease")
|
||||
echo "🧪 Prerelease Docker image has been built with ${VERSION} tags"
|
||||
echo "⚠️ This is a prerelease image - use with caution"
|
||||
# Create latest tags for stable releases and selected prereleases when CREATE_LATEST=true.
|
||||
# Prereleases move latest until the first stable tag exists (pre-GA policy).
|
||||
if [[ "$CREATE_LATEST" == "true" ]]; then
|
||||
echo "🏷️ Latest tag has been created for prerelease: $VERSION"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# 4-node 4-disk distributed e2e lane.
|
||||
#
|
||||
# Each selected test starts a real localhost cluster via
|
||||
# `RustFSTestClusterEnvironment` (4 processes; 4 drives per node unless the
|
||||
# case is a two-site 4-node 1-drive pair or a 4-node upgrade). Membership is
|
||||
# `[profile.e2e-distributed]` in `.config/nextest.toml`. Storage-sensitive PRs,
|
||||
# nightly runs, and manual dispatches all execute the same fail-closed suite.
|
||||
# Upgrade cases download the same pinned previous release as e2e-upgrade.yml.
|
||||
#
|
||||
# Isolated pool filesystems: expand/decommission/rebalance cases require
|
||||
# independent `statfs` capacity. This job runs on GitHub-hosted
|
||||
# `ubuntu-latest` because the self-hosted `sm-standard-4` ARC pods cannot
|
||||
# create filesystems: `mount -o loop` fails with ENOENT (no
|
||||
# `/dev/loop-control`), and `mount -t tmpfs` fails with "cannot mount tmpfs
|
||||
# read-only" (no `CAP_SYS_ADMIN` in the initial namespace). The same reason
|
||||
# `uring-integration` and `e2e-s3tests.yml` left that label. The prepare
|
||||
# step mounts four 1 GiB tmpfs instances and exports `RUSTFS_E2E_POOL_ROOTS`.
|
||||
|
||||
name: e2e-distributed
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- ".config/nextest.toml"
|
||||
- ".github/workflows/e2e-distributed.yml"
|
||||
- "crates/audit/**"
|
||||
- "crates/common/**"
|
||||
- "crates/config/**"
|
||||
- "crates/e2e_test/**"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
- "crates/heal/**"
|
||||
- "crates/iam/**"
|
||||
- "crates/lock/**"
|
||||
- "crates/madmin/**"
|
||||
- "crates/notify/**"
|
||||
- "crates/replication/**"
|
||||
- "crates/s3-client/**"
|
||||
- "crates/s3-ops/**"
|
||||
- "crates/s3-types/**"
|
||||
- "crates/scanner/**"
|
||||
- "crates/storage-api/**"
|
||||
- "crates/utils/**"
|
||||
- "rustfs/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
filter:
|
||||
description: "Optional nextest -E filter (default: the whole e2e-distributed profile)"
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
# 05:53 UTC nightly — clear of e2e-nightly (04:29) and ODM interop (05:23).
|
||||
- cron: "53 5 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'schedule' }}
|
||||
|
||||
jobs:
|
||||
distributed:
|
||||
name: Distributed 4-node 4-disk e2e
|
||||
# GitHub-hosted VM: loop and tmpfs mounts work here. sm-standard-4 is an
|
||||
# ARC pod and rejects both (`mount -o loop` ENOENT, tmpfs "read-only").
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
HTTP_PROXY: ""
|
||||
HTTPS_PROXY: ""
|
||||
# Pinned previous release used by distributed::upgrade_test (same pin as e2e-upgrade.yml).
|
||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
# Dedicated key: ubuntu-latest and sm-standard-4 share runner.os, so
|
||||
# a shared key would mix VM and ARC pod target/ artifacts.
|
||||
cache-shared-key: ci-e2e-distributed-hosted
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Prepare isolated filesystems for pool movement
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||
mkdir -p "${mount_base}"
|
||||
roots=()
|
||||
for pool in 0 1 2 3; do
|
||||
mountpoint="${mount_base}/pool-${pool}"
|
||||
mkdir -p "${mountpoint}"
|
||||
# Sized tmpfs reports a distinct st_dev and independent 1G
|
||||
# statfs capacity. Requires a VM runner (ubuntu-latest).
|
||||
if ! sudo mount -t tmpfs -o size=1G,nosuid,nodev,mode=1777 tmpfs "${mountpoint}"; then
|
||||
echo "tmpfs mount failed on $(uname -a)" >&2
|
||||
findmnt || true
|
||||
grep Cap /proc/self/status || true
|
||||
exit 1
|
||||
fi
|
||||
sudo chmod 1777 "${mountpoint}"
|
||||
roots+=("${mountpoint}")
|
||||
done
|
||||
printf -v joined_roots '%s:' "${roots[@]}"
|
||||
echo "RUSTFS_E2E_POOL_ROOTS=${joined_roots%:}" >> "${GITHUB_ENV}"
|
||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[0]}"
|
||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[1]}"
|
||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[2]}"
|
||||
findmnt --noheadings --output TARGET,SOURCE,FSTYPE,SIZE --target "${roots[3]}"
|
||||
|
||||
- name: Download pinned previous release
|
||||
env:
|
||||
SOURCE_DIR: ${{ runner.temp }}/rustfs-upgrade-source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$SOURCE_DIR"
|
||||
archive="$SOURCE_DIR/$UPGRADE_SOURCE_ASSET"
|
||||
curl --fail --location --retry 3 --output "$archive" \
|
||||
"https://github.com/${GITHUB_REPOSITORY}/releases/download/${UPGRADE_SOURCE_VERSION}/${UPGRADE_SOURCE_ASSET}"
|
||||
echo "$UPGRADE_SOURCE_SHA256 $archive" | sha256sum --check --strict
|
||||
unzip -q "$archive" -d "$SOURCE_DIR"
|
||||
chmod +x "$SOURCE_DIR/rustfs"
|
||||
test -x "$SOURCE_DIR/rustfs"
|
||||
echo "RUSTFS_UPGRADE_SOURCE_BINARY=$SOURCE_DIR/rustfs" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build rustfs binary
|
||||
run: |
|
||||
cargo build -p rustfs --bins
|
||||
: > target/debug/rustfs.features
|
||||
|
||||
- name: Verify distributed e2e membership
|
||||
env:
|
||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||
run: |
|
||||
cargo nextest list --profile e2e-distributed -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-distributed "${NEXTEST_LISTING}"
|
||||
|
||||
- name: Run distributed 4-node e2e suite
|
||||
env:
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-distributed-logs
|
||||
FILTER: ${{ inputs.filter }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${FILTER}" ]; then
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||
else
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||
fi
|
||||
|
||||
- name: Upload distributed e2e diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: e2e-distributed-${{ github.run_number }}
|
||||
path: |
|
||||
target/nextest/e2e-distributed/junit.xml
|
||||
${{ runner.temp }}/rustfs-e2e-distributed-list.json
|
||||
${{ runner.temp }}/rustfs-e2e-distributed-logs/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Unmount isolated pool filesystems
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mount_base="${RUNNER_TEMP}/rustfs-e2e-pools"
|
||||
for pool in 0 1 2 3; do
|
||||
mountpoint="${mount_base}/pool-${pool}"
|
||||
if mountpoint --quiet "${mountpoint}"; then
|
||||
sudo umount "${mountpoint}"
|
||||
fi
|
||||
done
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [distributed]
|
||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -19,7 +19,9 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/e2e-upgrade.yml"
|
||||
- "crates/e2e_test/src/common.rs"
|
||||
- "crates/e2e_test/src/fake_s3_target/**"
|
||||
- "crates/e2e_test/src/lib.rs"
|
||||
- "crates/e2e_test/src/replication_extension_test.rs"
|
||||
- "crates/e2e_test/src/upgrade_compatibility_test.rs"
|
||||
- "crates/ecstore/**"
|
||||
- "crates/filemeta/**"
|
||||
@@ -44,9 +46,9 @@ concurrency:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.2
|
||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.2.zip
|
||||
UPGRADE_SOURCE_SHA256: 7c789386bf85278f865b8e0d359bf4edb84d5aa408cc3fa54a18c25ca74cd6e7
|
||||
UPGRADE_SOURCE_VERSION: 1.0.0-rc.5
|
||||
UPGRADE_SOURCE_ASSET: rustfs-linux-x86_64-gnu-v1.0.0-rc.5.zip
|
||||
UPGRADE_SOURCE_SHA256: 3ee8df71e8edcfada533be452c4135868f697bc515460ae97b027313eade7a3d
|
||||
|
||||
jobs:
|
||||
upgrade:
|
||||
@@ -55,14 +57,39 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: Direct upgrade from rc.2
|
||||
# The two `_from_rc2_` tests keep their names: they assert
|
||||
# release-independent object contracts and pass unchanged against the
|
||||
# newer pinned source, so renaming them would only churn history and
|
||||
# the CI required-check names. UPGRADE_SOURCE_VERSION above is the
|
||||
# single source of truth for which release they actually run against.
|
||||
- name: Direct upgrade from the previous release
|
||||
cache_key: e2e-direct-upgrade
|
||||
test: direct_upgrade_from_rc2_preserves_object_contracts
|
||||
artifact: direct-upgrade
|
||||
- name: Mixed-version rolling upgrade from rc.2
|
||||
- name: Mixed-version rolling upgrade from the previous release
|
||||
cache_key: e2e-mixed-version-upgrade
|
||||
test: rolling_upgrade_from_rc2_preserves_mixed_version_contracts
|
||||
artifact: mixed-version-upgrade
|
||||
- name: Bucket configuration survives the upgrade
|
||||
cache_key: e2e-bucket-config-upgrade
|
||||
test: direct_upgrade_from_previous_release_preserves_bucket_configuration
|
||||
artifact: bucket-config-upgrade
|
||||
- name: Rollback reads current bucket metadata
|
||||
cache_key: e2e-bucket-config-rollback
|
||||
test: rollback_to_previous_release_reads_current_bucket_metadata
|
||||
artifact: bucket-config-rollback
|
||||
- name: ODM configuration recovery after rc.5 rollback
|
||||
cache_key: e2e-odm-config-rollback
|
||||
test: rc5_rollback_requires_restoring_odm_configuration
|
||||
artifact: odm-config-rollback
|
||||
- name: Multipart layouts survive the rc.5 upgrade
|
||||
cache_key: e2e-multipart-layout-upgrade
|
||||
test: direct_upgrade_from_rc5_preserves_multipart_layouts
|
||||
artifact: multipart-layout-upgrade
|
||||
- name: rc.5 multipart replication baseline
|
||||
cache_key: e2e-multipart-layout-baseline
|
||||
test: rc5_baseline_replicates_multipart_layouts
|
||||
artifact: multipart-layout-baseline
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
|
||||
@@ -166,8 +166,9 @@ jobs:
|
||||
# e.g. https://dl.rustfs.com/artifacts/rustfs/packages/nightly/... .
|
||||
# Skipped when the R2 secrets are not configured (artifact-only mode).
|
||||
- name: Upload DEB to Cloudflare R2
|
||||
if: env.R2_ACCESS_KEY_ID != ''
|
||||
id: publish
|
||||
env:
|
||||
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
@@ -182,28 +183,70 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
sudo apt-get update && sudo apt-get install -y -qq awscli
|
||||
fi
|
||||
|
||||
export AWS_ACCESS_KEY_ID="$R2_ACCESS_KEY_ID"
|
||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||
export AWS_DEFAULT_REGION="auto"
|
||||
|
||||
DEB_FILE="${{ steps.deb.outputs.deb_file }}"
|
||||
SOURCE_SHA="$(git rev-parse HEAD)"
|
||||
if [[ "${SOURCE_SHA}" != "${GITHUB_SHA}" ]]; then
|
||||
echo "Checkout SHA does not match the nightly build run" >&2
|
||||
exit 1
|
||||
fi
|
||||
DEB_SHA256="$(sha256sum "${DEB_FILE}" | cut -d ' ' -f 1)"
|
||||
CANDIDATE_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
|
||||
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
|
||||
|
||||
# Old AWS CLI models lack conditional PutObject support. Never fall
|
||||
# back to an overwriting upload for a candidate.
|
||||
AWS_CLI=aws
|
||||
if ! "${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y -qq python3-venv
|
||||
AWS_CLI_DIR="$(mktemp -d "${RUNNER_TEMP}/nightly-awscli.XXXXXX")"
|
||||
trap 'rm -rf "${AWS_CLI_DIR}"' EXIT
|
||||
python3 -m venv "${AWS_CLI_DIR}"
|
||||
"${AWS_CLI_DIR}/bin/python" -m pip install --disable-pip-version-check 'awscli==1.44.79'
|
||||
AWS_CLI="${AWS_CLI_DIR}/bin/aws"
|
||||
fi
|
||||
"${AWS_CLI}" s3api put-object --generate-cli-skeleton input | jq -e 'has("IfNoneMatch")' >/dev/null
|
||||
"${AWS_CLI}" --version
|
||||
"${AWS_CLI}" s3api put-object --bucket "${R2_BUCKET}" --key "${CANDIDATE_KEY}" \
|
||||
--body "${DEB_FILE}" --if-none-match '*' --endpoint-url "${R2_ENDPOINT}"
|
||||
PUBLISHED_SHA256="$(curl -fsSL --retry 3 --connect-timeout 15 --max-time 300 "${CANDIDATE_URL}" | sha256sum | cut -d ' ' -f 1)"
|
||||
if [[ "${PUBLISHED_SHA256}" != "${DEB_SHA256}" ]]; then
|
||||
echo "Published candidate checksum does not match the built package" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
R2_PREFIX="s3://${R2_BUCKET}/artifacts/rustfs/packages/nightly/"
|
||||
|
||||
echo "📤 Uploading ${DEB_FILE} to ${R2_PREFIX}"
|
||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}" --endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
|
||||
# Stable "latest" alias so tests can fetch the newest nightly
|
||||
# without knowing today's date.
|
||||
echo "📤 Uploading latest alias"
|
||||
aws s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
||||
"${AWS_CLI}" s3 cp "${DEB_FILE}" "${R2_PREFIX}rustfs-nightly-latest.deb" \
|
||||
--endpoint-url "$R2_ENDPOINT" --only-show-errors
|
||||
|
||||
echo "✅ R2 upload complete"
|
||||
|
||||
CANDIDATE_FILE="${RUNNER_TEMP}/nightly-candidate-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json"
|
||||
jq -n --arg source_sha "${SOURCE_SHA}" \
|
||||
--argjson build_run_id "${GITHUB_RUN_ID}" --argjson build_run_attempt "${GITHUB_RUN_ATTEMPT}" \
|
||||
--arg package_url "${CANDIDATE_URL}" --arg package_sha256 "${DEB_SHA256}" \
|
||||
'{schema: 1, source_sha: $source_sha, build_run_id: $build_run_id, build_run_attempt: $build_run_attempt, package_url: $package_url, package_sha256: $package_sha256}' \
|
||||
> "${CANDIDATE_FILE}"
|
||||
echo "candidate_file=${CANDIDATE_FILE}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Upload nightly candidate manifest
|
||||
if: ${{ steps.publish.outputs.candidate_file != '' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: nightly-candidate-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.publish.outputs.candidate_file }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
||||
#
|
||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# On-demand migration provider interop (rustfs/backlog#2167, ODM-20).
|
||||
#
|
||||
# The in-process fake source that the merge-gate ODM suite runs against covers
|
||||
# the protocol semantics, but real implementations differ in path-style vs
|
||||
# virtual-host addressing, region handling, ETag shape, list pagination and
|
||||
# rate limiting. This lane runs the same case bodies
|
||||
# (crates/e2e_test/src/on_demand_migration/interop_test.rs) against real
|
||||
# sources; the source is injected through RUSTFS_ODM_INTEROP_* environment
|
||||
# variables, so nothing about the cases is duplicated per provider.
|
||||
#
|
||||
# Report-only and scheduled. It is never a required check and must not be
|
||||
# promoted to one: it depends on third-party endpoints and on repository
|
||||
# secrets that a fork does not have.
|
||||
#
|
||||
# Jobs:
|
||||
# * minio-source runs the whole e2e-odm-interop profile — read-through,
|
||||
# HEAD passthrough, merged list pagination and a backfill — against a
|
||||
# pinned MinIO container. The backfill is sized at 5,000 objects here: the
|
||||
# fake source retains at most 4,096 object versions and 4,096 journal
|
||||
# entries, so the merge-gate backfill coverage cannot go past that, and a
|
||||
# real source is where a production-shaped batch belongs.
|
||||
# * cloud-source runs the three-case minimum (GET miss, HEAD miss, merged
|
||||
# list pagination) against AWS S3, Cloudflare R2 and the GCS XML
|
||||
# interoperability API. Each provider is skipped with a summary note when
|
||||
# its ODM_INTEROP_* repository secrets are absent, which is the normal
|
||||
# state on a fork and in any clone of this repository.
|
||||
#
|
||||
# Every job uploads one JSON report per provider naming the cases, their
|
||||
# timings and the source request accounting.
|
||||
name: on-demand-migration-interop
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Nightly at 05:23 UTC, offset from the other nightly lanes.
|
||||
- cron: "23 5 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
# The three cases a cloud provider is asked for. Named individually rather
|
||||
# than by module so adding a fourth case does not silently start billing a
|
||||
# cloud account for it.
|
||||
CLOUD_CASE_FILTER: >-
|
||||
package(e2e_test) & test(/^on_demand_migration::interop_test::(interop_get_miss_pulls_from_the_source_and_serves_locally|interop_head_miss_answers_from_the_source_without_persisting|interop_list_through_pages_the_source_namespace)$/)
|
||||
CLOUD_CASE_COUNT: "3"
|
||||
|
||||
jobs:
|
||||
minio-source:
|
||||
name: MinIO source (read-through, list-through, backfill)
|
||||
# Skip on forks: needs this repository's runners and is not a contributor
|
||||
# gate.
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
NO_PROXY: 127.0.0.1,localhost
|
||||
# Fixed credentials of the container this job starts and throws away;
|
||||
# not a secret and deliberately not read from one, so the lane runs
|
||||
# unattended in any clone that enables it.
|
||||
MINIO_ROOT_USER: rustfsodminterop
|
||||
MINIO_ROOT_PASSWORD: rustfsodminteropsecret
|
||||
RUSTFS_ODM_INTEROP_PROVIDER: minio
|
||||
RUSTFS_ODM_INTEROP_ENDPOINT: http://127.0.0.1:9100
|
||||
RUSTFS_ODM_INTEROP_REGION: auto
|
||||
RUSTFS_ODM_INTEROP_BUCKET: odm-interop-source
|
||||
RUSTFS_ODM_INTEROP_PATH_STYLE: path
|
||||
RUSTFS_ODM_INTEROP_ACCESS_KEY: rustfsodminterop
|
||||
RUSTFS_ODM_INTEROP_SECRET_KEY: rustfsodminteropsecret
|
||||
RUSTFS_ODM_INTEROP_BACKFILL_OBJECTS: "5000"
|
||||
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/minio/cases
|
||||
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/minio/selection.json
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: ci-odm-interop
|
||||
cache-save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Start MinIO source
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p artifacts/odm-interop/minio
|
||||
docker run -d --name rustfs-odm-interop-minio \
|
||||
-e "MINIO_ROOT_USER=${MINIO_ROOT_USER}" \
|
||||
-e "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" \
|
||||
-p 9100:9000 \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
|
||||
for _ in $(seq 1 120); do
|
||||
curl -fsS http://127.0.0.1:9100/minio/health/live >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS http://127.0.0.1:9100/minio/health/live
|
||||
|
||||
# The harness never creates a bucket, so that pointing it at a cloud
|
||||
# account cannot create one there either. The source bucket for the
|
||||
# container is created here instead.
|
||||
- name: Create the MinIO source bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ env.MINIO_ROOT_USER }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ env.MINIO_ROOT_PASSWORD }}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
run: |
|
||||
aws --endpoint-url "${RUSTFS_ODM_INTEROP_ENDPOINT}" \
|
||||
s3api create-bucket --bucket "${RUSTFS_ODM_INTEROP_BUCKET}"
|
||||
|
||||
- name: Build the RustFS binary under test
|
||||
run: cargo build --locked -p rustfs --bins
|
||||
|
||||
# The lane selects tests by module, so a rename would quietly shrink it.
|
||||
# The committed digest in .config/e2e-odm-interop-selection.txt fails
|
||||
# closed on that.
|
||||
- name: Verify interop lane membership
|
||||
run: |
|
||||
cargo nextest list --profile e2e-odm-interop -p e2e_test --message-format json > "${NEXTEST_LISTING}"
|
||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-odm-interop "${NEXTEST_LISTING}"
|
||||
|
||||
- name: Run the interop cases against MinIO
|
||||
run: cargo nextest run --profile e2e-odm-interop -p e2e_test --no-tests=fail
|
||||
|
||||
- name: Build the MinIO interop report
|
||||
if: always()
|
||||
uses: ./.github/actions/odm-interop-report
|
||||
with:
|
||||
provider: minio
|
||||
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
|
||||
junit: target/nextest/e2e-odm-interop/junit.xml
|
||||
output: artifacts/odm-interop/minio/report.json
|
||||
|
||||
- name: Collect MinIO logs
|
||||
if: always()
|
||||
run: |
|
||||
docker logs --tail 500 rustfs-odm-interop-minio \
|
||||
> artifacts/odm-interop/minio/minio.log 2>&1 || true
|
||||
|
||||
- name: Stop MinIO source
|
||||
if: always()
|
||||
run: docker rm -f rustfs-odm-interop-minio >/dev/null 2>&1 || true
|
||||
|
||||
- name: Upload the MinIO interop report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: odm-interop-minio-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/odm-interop/minio
|
||||
target/nextest/e2e-odm-interop/junit.xml
|
||||
retention-days: 14
|
||||
|
||||
# Unlike a production migration source, which needs read access only, the
|
||||
# credentials here also seed the objects each case reads back, so they need
|
||||
# write and delete on the interop bucket. Every run seeds under
|
||||
# `odm-interop/<case>/<uuid>/` and deletes what it seeded when the case
|
||||
# passes; give the bucket an expiration lifecycle rule so the prefixes a
|
||||
# failing case leaves behind cannot accumulate.
|
||||
cloud-source:
|
||||
name: ${{ matrix.provider }} source (three-case minimum)
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- provider: aws
|
||||
secret_prefix: AWS
|
||||
path_style: virtual
|
||||
- provider: r2
|
||||
secret_prefix: R2
|
||||
path_style: virtual
|
||||
- provider: gcs
|
||||
secret_prefix: GCS_HMAC
|
||||
path_style: virtual
|
||||
env:
|
||||
RUSTFS_ODM_INTEROP_PROVIDER: ${{ matrix.provider }}
|
||||
RUSTFS_ODM_INTEROP_PATH_STYLE: ${{ matrix.path_style }}
|
||||
RUSTFS_ODM_INTEROP_ENDPOINT: ${{ secrets[format('ODM_INTEROP_{0}_ENDPOINT', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_REGION: ${{ secrets[format('ODM_INTEROP_{0}_REGION', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_BUCKET: ${{ secrets[format('ODM_INTEROP_{0}_BUCKET', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_ACCESS_KEY: ${{ secrets[format('ODM_INTEROP_{0}_ACCESS_KEY_ID', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_SECRET_KEY: ${{ secrets[format('ODM_INTEROP_{0}_SECRET_ACCESS_KEY', matrix.secret_prefix)] }}
|
||||
RUSTFS_ODM_INTEROP_REPORT_DIR: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/cases
|
||||
NEXTEST_LISTING: ${{ github.workspace }}/artifacts/odm-interop/${{ matrix.provider }}/selection.json
|
||||
steps:
|
||||
# Absent secrets are the normal state, not a failure: the lane reports
|
||||
# which providers it could reach and skips the rest. An empty value is
|
||||
# what an unset repository secret expands to, so it is checked, not the
|
||||
# secret's existence.
|
||||
- name: Check for provider credentials
|
||||
id: credentials
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${RUSTFS_ODM_INTEROP_ENDPOINT}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_REGION}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_BUCKET}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_ACCESS_KEY}" ] \
|
||||
|| [ -z "${RUSTFS_ODM_INTEROP_SECRET_KEY}" ]; then
|
||||
echo "present=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "### On-demand migration interop: \`${{ matrix.provider }}\`"
|
||||
echo
|
||||
echo "Skipped: the \`ODM_INTEROP_${{ matrix.secret_prefix }}_*\` repository secrets"
|
||||
echo "(\`_ENDPOINT\`, \`_REGION\`, \`_BUCKET\`, \`_ACCESS_KEY_ID\`, \`_SECRET_ACCESS_KEY\`)"
|
||||
echo "are not configured, so no real \`${{ matrix.provider }}\` source was reached."
|
||||
echo
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "present=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
cache-shared-key: ci-odm-interop
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Build the RustFS binary under test
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: cargo build --locked -p rustfs --bins
|
||||
|
||||
# A filterset that matches nothing is valid, so the count is asserted
|
||||
# rather than inferred from a green run.
|
||||
- name: Verify the three-case minimum still selects three cases
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p "$(dirname "${NEXTEST_LISTING}")"
|
||||
cargo nextest list --profile e2e-odm-interop -p e2e_test \
|
||||
-E "${CLOUD_CASE_FILTER}" --message-format json > "${NEXTEST_LISTING}"
|
||||
selected="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(sum(1 for suite in d.get("rust-suites", {}).values() for test in suite.get("testcases", {}).values() if test.get("filter-match", {}).get("status") == "matches"))' "${NEXTEST_LISTING}")"
|
||||
echo "cloud interop cases selected: ${selected}"
|
||||
if [ "${selected}" != "${CLOUD_CASE_COUNT}" ]; then
|
||||
echo "::error::CLOUD_CASE_FILTER selected ${selected} cases, expected ${CLOUD_CASE_COUNT}; the interop cases were renamed or moved. Context: rustfs/backlog#2167."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run the three-case minimum
|
||||
if: steps.credentials.outputs.present == 'true'
|
||||
run: |
|
||||
cargo nextest run --profile e2e-odm-interop -p e2e_test \
|
||||
-E "${CLOUD_CASE_FILTER}" --no-tests=fail
|
||||
|
||||
- name: Build the ${{ matrix.provider }} interop report
|
||||
if: always() && steps.credentials.outputs.present == 'true'
|
||||
uses: ./.github/actions/odm-interop-report
|
||||
with:
|
||||
provider: ${{ matrix.provider }}
|
||||
cases-dir: ${{ env.RUSTFS_ODM_INTEROP_REPORT_DIR }}
|
||||
junit: target/nextest/e2e-odm-interop/junit.xml
|
||||
output: artifacts/odm-interop/${{ matrix.provider }}/report.json
|
||||
|
||||
- name: Upload the ${{ matrix.provider }} interop report
|
||||
if: always() && steps.credentials.outputs.present == 'true'
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: odm-interop-${{ matrix.provider }}-${{ github.run_number }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
artifacts/odm-interop/${{ matrix.provider }}
|
||||
target/nextest/e2e-odm-interop/junit.xml
|
||||
retention-days: 14
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [minio-source, cloud-source]
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -12,10 +12,10 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Functional chain driver: runs the nine functional suites in a fixed order
|
||||
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security, with
|
||||
# performance on its own runner in parallel) and guarantees the chain keeps
|
||||
# moving even when individual suites fail.
|
||||
# Functional chain driver: runs the ten functional suites in a fixed order
|
||||
# (upgrade -> s3 -> kms -> tier -> storage -> heal -> pool -> security ->
|
||||
# replication -> performance). Each suite attempts the next handoff even
|
||||
# when its tests fail.
|
||||
#
|
||||
# Each suite workflow can still be dispatched standalone (workflow_dispatch);
|
||||
# only chain-triggered runs forward to the next suite via repository_dispatch,
|
||||
@@ -59,16 +59,3 @@ jobs:
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-upgrade' \
|
||||
-F 'client_payload[from_suite]=nightly-build'
|
||||
|
||||
- name: Dispatch performance suite (parallel, own runner)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch performance" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-performance' \
|
||||
-F 'client_payload[from_suite]=nightly-build'
|
||||
|
||||
@@ -54,14 +54,26 @@ env:
|
||||
jobs:
|
||||
heal-test:
|
||||
runs-on: smoke-testing
|
||||
# Requirement: a failing suite must not fail the workflow; failures
|
||||
# are filed to rustfs/backlog and the chain continues.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 480
|
||||
# Standalone manual run, or one link of the nightly functional chain
|
||||
# (storage -> heal -> pool). Pool expansion no longer re-runs heal.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-heal-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'RUSTFS_WARP_LOG_FILE=%s/warp.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -117,7 +129,7 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||
|
||||
- name: Preflight checks
|
||||
run: |
|
||||
@@ -127,7 +139,7 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}"
|
||||
./auto-testing/rustfs_heal_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||
|
||||
- name: Run heal test (write -> outage -> heal -> verify)
|
||||
id: test
|
||||
@@ -137,13 +149,10 @@ jobs:
|
||||
--endpoint "${{ env.RUSTFS_API_ENDPOINT }}" \
|
||||
--stop-node-gb "${{ inputs.stop_node_gb || '15' }}" \
|
||||
--warp-stop-gb "${{ inputs.warp_stop_gb || '40' }}" \
|
||||
--log-file /tmp/rustfs-heal-test.log
|
||||
--log-file "${LOG_FILE}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-heal-test.log
|
||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
@@ -152,27 +161,99 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
STEPS_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/steps.md"
|
||||
CASE_RESULT=success
|
||||
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY' || CASE_RESULT=failure
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
step_re = re.compile(r'^\[HEAL-STEP\]\s+(\d+)\s+(.+?)\s+(PASS|FAIL|SKIP)\s*$')
|
||||
ver_re = re.compile(r'^\[HEAL-VERSION\]\s+(\S+)(?:\s+\(node\s+(\S+)\))?\s*$')
|
||||
result_re = re.compile(r'^\[HEAL-RESULT\]\s+(PASS|FAIL)\s+(.*)$')
|
||||
|
||||
steps = {}
|
||||
order = []
|
||||
status_rank = {'SKIP': 0, 'PASS': 1, 'FAIL': 2}
|
||||
version = None
|
||||
version_node = None
|
||||
verdict = None
|
||||
verdict_detail = ''
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = step_re.match(line)
|
||||
if m:
|
||||
n, desc, status = m.group(1), m.group(2), m.group(3)
|
||||
if n not in steps:
|
||||
order.append(n)
|
||||
if n not in steps or status_rank[status] > status_rank[steps[n][1]]:
|
||||
steps[n] = (desc, status)
|
||||
continue
|
||||
m = ver_re.match(line)
|
||||
if m:
|
||||
version, version_node = m.group(1), m.group(2)
|
||||
continue
|
||||
m = result_re.match(line)
|
||||
if m and verdict != 'FAIL':
|
||||
verdict, verdict_detail = m.group(1), m.group(2)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Step Results\n\n')
|
||||
if version:
|
||||
node_note = f' (captured via `rustfs --version` on {version_node})' if version_node else ''
|
||||
out.write(f'- Version under test: **{version}**{node_note}\n')
|
||||
if verdict:
|
||||
out.write(f'- Overall result: **{verdict}** — {verdict_detail}\n')
|
||||
out.write('\n')
|
||||
out.write('| Step | Description | Result |\n')
|
||||
out.write('| --- | --- | --- |\n')
|
||||
for n in sorted(order, key=int):
|
||||
desc, status = steps[n]
|
||||
out.write(f'| {n} | {desc} | {status} |\n')
|
||||
if not order:
|
||||
out.write('| - | - | NOT RUN (no step result lines found) |\n')
|
||||
complete = set(steps) == {str(n) for n in range(1, 8)}
|
||||
sys.exit(0 if complete and verdict != 'FAIL' and all(status == 'PASS' for _, status in steps.values()) else 1)
|
||||
PY
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS heal test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${STEPS_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial step results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-heal-report.md
|
||||
SUITE: heal
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -182,28 +263,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'heal'
|
||||
SUITE_LABEL: 'Heal'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-heal-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-heal-test.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -231,14 +316,16 @@ jobs:
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -254,14 +341,16 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-heal-test-${{ github.run_id }}
|
||||
name: rustfs-heal-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-heal-test*.log
|
||||
/tmp/rustfs-warp.*.log
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/warp.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/steps.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
@@ -284,21 +373,52 @@ jobs:
|
||||
|
||||
- name: "Continue functional chain (next: Pool expansion)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: Pool expansion"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-pool' \
|
||||
-F 'client_payload[from_suite]=heal'
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-pool' \
|
||||
-F 'client_payload[from_suite]=heal'; then
|
||||
echo "dispatched next suite Pool expansion (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Pool expansion after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after heal (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **heal** to **Pool expansion** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-pool'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-pool'"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -49,10 +49,28 @@ env:
|
||||
jobs:
|
||||
kms-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-kms-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -109,9 +127,6 @@ jobs:
|
||||
|
||||
- name: Run KMS suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-kms.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-kms-test.sh
|
||||
@@ -141,10 +156,7 @@ jobs:
|
||||
./auto-testing/rustfs-kms-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-kms.log
|
||||
REPORT_FILE: /tmp/rustfs-kms-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
@@ -156,79 +168,43 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-kms-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS KMS test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-kms-report.md
|
||||
SUITE: kms
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -238,28 +214,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'kms'
|
||||
SUITE_LABEL: 'KMS'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-kms-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-kms.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -287,14 +267,16 @@ jobs:
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -310,14 +292,15 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-kms-test-${{ github.run_id }}
|
||||
name: rustfs-kms-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-kms.log
|
||||
/tmp/rustfs-kms-report.md
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
@@ -340,21 +323,52 @@ jobs:
|
||||
|
||||
- name: "Continue functional chain (next: Tier)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: Tier"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-tier' \
|
||||
-F 'client_payload[from_suite]=kms'
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-tier' \
|
||||
-F 'client_payload[from_suite]=kms'; then
|
||||
echo "dispatched next suite Tier (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Tier after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after kms (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **kms** to **Tier** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-tier'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-tier'"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -49,17 +49,16 @@ on:
|
||||
type: boolean
|
||||
default: true
|
||||
repository_dispatch:
|
||||
# Chain entry: dispatched by rustfs-functional-chain.yml (runs on its own
|
||||
# pf-testing runner, in parallel with the shared-VM chain).
|
||||
# Chain handoff: dispatched when the replication suite finishes.
|
||||
types: [rustfs-chain-performance]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Dedicated pf-testing runner/environment: own concurrency group so perf runs
|
||||
# never block (or are blocked by) the pool-expansion / heal tests.
|
||||
# The default performance nodes overlap the other suites' remote VMs, even
|
||||
# though the runner differs. Hold the shared lock through cleanup as well.
|
||||
concurrency:
|
||||
group: rustfs-performance-test
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
@@ -76,22 +75,33 @@ env:
|
||||
# Package used by the nightly run (workflow_dispatch inputs are empty for
|
||||
# workflow_run events), i.e. the latest nightly deb published by nightly-gnu.yml.
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
# Fixed benchmark result directory so later steps can read summary.md
|
||||
RUSTFS_RESULT_DIR: /tmp/rustfs-perf-results
|
||||
# Cross-repo token for uploading reports to rustfs/dashboard (set in repo settings)
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
jobs:
|
||||
performance-test:
|
||||
runs-on: pf-testing
|
||||
# Requirement: a failing benchmark must not fail the workflow;
|
||||
# failures are filed to rustfs/backlog.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 900
|
||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||
# Skipped when nightly failed.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-performance-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'RUSTFS_RESULT_DIR=%s/results\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'VERSION_FILE=%s/version.txt\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -123,7 +133,7 @@ jobs:
|
||||
if: ${{ inputs.cleanup_before != 'false' }}
|
||||
run: |
|
||||
chmod +x auto-testing/rustfs_performance_test.sh
|
||||
./auto-testing/rustfs_performance_test.sh --step 1 -y
|
||||
./auto-testing/rustfs_performance_test.sh --step 1 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||
|
||||
- name: Install RustFS package & start cluster (4x4)
|
||||
run: |
|
||||
@@ -133,7 +143,7 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||
|
||||
- name: Preflight checks
|
||||
run: |
|
||||
@@ -143,7 +153,7 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${{ env.RUSTFS_NIGHTLY_PACKAGE_URL }}")
|
||||
fi
|
||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}"
|
||||
./auto-testing/rustfs_performance_test.sh "${ARGS[@]}" --log-file "${LOG_FILE}"
|
||||
|
||||
- name: Run benchmark (GET/PUT/MIXED)
|
||||
id: benchmark
|
||||
@@ -156,17 +166,15 @@ jobs:
|
||||
--step 5 -y \
|
||||
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
||||
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
||||
--log-file /tmp/rustfs-perf-test.log
|
||||
--log-file "${LOG_FILE}"
|
||||
|
||||
- name: Analyze results
|
||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||
run: |
|
||||
./auto-testing/rustfs_performance_test.sh --step 6 -y
|
||||
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||
|
||||
- name: Collect RustFS version info
|
||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||
env:
|
||||
VERSION_FILE: /tmp/rustfs-version.txt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES}"
|
||||
@@ -186,7 +194,6 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
RESULT_DIR: ${{ env.RUSTFS_RESULT_DIR }}
|
||||
VERSION_FILE: /tmp/rustfs-version.txt
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -194,7 +201,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
SUMMARY="${RESULT_DIR}/summary.md"
|
||||
[ -f "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
||||
[ -s "${SUMMARY}" ] || { echo "summary.md not found at ${SUMMARY}"; exit 1; }
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="reports/${DATE}.md"
|
||||
{
|
||||
@@ -202,6 +209,8 @@ jobs:
|
||||
echo ""
|
||||
echo "- **Date**: ${DATE}"
|
||||
echo "- **Run**: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- **Attempt**: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- **Workflow Commit**: ${GITHUB_SHA}"
|
||||
echo "- **Trigger**: ${{ github.event_name }}"
|
||||
echo "- **Package**: ${{ inputs.package_url || 'nightly (R2 latest)' }}"
|
||||
echo ""
|
||||
@@ -211,8 +220,8 @@ jobs:
|
||||
echo '```text'
|
||||
cat "${VERSION_FILE}"
|
||||
echo '```'
|
||||
} > /tmp/rustfs-perf-report.md
|
||||
CONTENT="$(python3 -c 'import base64; print(base64.b64encode(open("/tmp/rustfs-perf-report.md","rb").read()).decode())')"
|
||||
} > "${REPORT_FILE}"
|
||||
CONTENT="$(python3 -c 'import base64,sys; print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report: ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
@@ -231,11 +240,10 @@ jobs:
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'performance'
|
||||
SUITE_LABEL: 'Performance'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-perf-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-perf-test.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -263,14 +271,16 @@ jobs:
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -286,20 +296,26 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload test logs & results
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-perf-test-${{ github.run_id }}
|
||||
name: rustfs-perf-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-perf-test*.log
|
||||
/tmp/rustfs-perf-results/**
|
||||
/tmp/rustfs-version.txt
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/version.txt
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/master.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/summary.tsv
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/get_*.txt
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/put_*.txt
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/results/mixed_*.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Reset test environment (after)
|
||||
if: ${{ always() && inputs.cleanup_after != 'false' }}
|
||||
run: |
|
||||
./auto-testing/rustfs_performance_test.sh --step 7 -y
|
||||
./auto-testing/rustfs_performance_test.sh --step 7 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -76,9 +76,6 @@ jobs:
|
||||
pool-expansion-test:
|
||||
name: Pool expansion / decommission test
|
||||
runs-on: smoke-testing
|
||||
# Requirement: a failing suite must not fail the workflow; failures
|
||||
# are filed to rustfs/backlog and the chain continues.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
env:
|
||||
@@ -380,6 +377,60 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
STEPS_TABLE="${POOL_ARTIFACT_DIR}/pool-steps.md"
|
||||
python3 - "${LOG_FILE}" "${STEPS_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
step_re = re.compile(r'^\[POOL-STEP\]\s+(\d+)\s+(.+?)\s+(PASS|FAIL|SKIP)\s*$')
|
||||
ver_re = re.compile(r'^\[POOL-VERSION\]\s+(\S+)(?:\s+\(node\s+(\S+)\))?\s*$')
|
||||
result_re = re.compile(r'^\[POOL-RESULT\]\s+(PASS|FAIL)\s+(.*)$')
|
||||
|
||||
steps = {}
|
||||
order = []
|
||||
version = None
|
||||
version_node = None
|
||||
verdict = None
|
||||
verdict_detail = ''
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = step_re.match(line)
|
||||
if m:
|
||||
n, desc, status = m.group(1), m.group(2), m.group(3)
|
||||
if n not in steps:
|
||||
order.append(n)
|
||||
steps[n] = (desc, status) # later lines win (fail after pass)
|
||||
continue
|
||||
m = ver_re.match(line)
|
||||
if m:
|
||||
version, version_node = m.group(1), m.group(2)
|
||||
continue
|
||||
m = result_re.match(line)
|
||||
if m:
|
||||
verdict, verdict_detail = m.group(1), m.group(2)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Step Results\n\n')
|
||||
if version:
|
||||
node_note = f' (captured via `rustfs --version` on {version_node})' if version_node else ''
|
||||
out.write(f'- Version under test: **{version}**{node_note}\n')
|
||||
if verdict:
|
||||
out.write(f'- Overall result: **{verdict}** — {verdict_detail}\n')
|
||||
out.write('\n')
|
||||
out.write('| Step | Description | Result |\n')
|
||||
out.write('| --- | --- | --- |\n')
|
||||
for n in sorted(order, key=int):
|
||||
desc, status = steps[n]
|
||||
out.write(f'| {n} | {desc} | {status} |\n')
|
||||
if not order:
|
||||
out.write('| - | - | NOT RUN (no step result lines found) |\n')
|
||||
PY
|
||||
{
|
||||
echo "# RustFS pool expansion test report"
|
||||
echo ""
|
||||
@@ -389,6 +440,8 @@ jobs:
|
||||
echo "- Warp concurrent: ${{ inputs.warp_concurrent || '32' }}"
|
||||
echo "- Test Step Outcome: ${{ steps.pool_test.outcome }}"
|
||||
echo ""
|
||||
cat "${STEPS_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
@@ -486,17 +539,22 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.pool_test.outcome == 'failure' || steps.pool_test.outcome == 'cancelled') }}
|
||||
@@ -596,21 +654,52 @@ jobs:
|
||||
|
||||
- name: "Continue functional chain (next: Security)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: Security"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-security' \
|
||||
-F 'client_payload[from_suite]=pool'
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-security' \
|
||||
-F 'client_payload[from_suite]=pool'; then
|
||||
echo "dispatched next suite Security (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Security after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after pool (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **pool** to **Security** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-security'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-security'"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
# Copyright 2024 RustFS Team
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
name: RustFS Replication Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rustfs_version:
|
||||
description: 'RustFS release tag to test (e.g. 1.0.0-rc.4-preview.1)'
|
||||
required: false
|
||||
default: '1.0.0-rc.4-preview.1'
|
||||
package_url:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
suite:
|
||||
description: 'Suite to run (all = bucket REP-* then site SITE-*)'
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- bucket
|
||||
- site
|
||||
default: all
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the security suite finishes.
|
||||
types: [rustfs-chain-replication]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# The replication suite uses the same shared VMs as the other functional
|
||||
# tests, so it must serialize with them instead of running in parallel.
|
||||
concurrency:
|
||||
group: rustfs-shared-functional-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
RUSTFS_ACCESS_KEY: ${{ secrets.RUSTFS_ACCESS_KEY }}
|
||||
RUSTFS_SECRET_KEY: ${{ secrets.RUSTFS_SECRET_KEY }}
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
|
||||
jobs:
|
||||
replication-test:
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-replication-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf auto-testing
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if gh repo clone rustfs/auto-testing auto-testing -- --depth 1 --quiet; then
|
||||
echo "auto-testing cloned (attempt ${attempt})"
|
||||
exit 0
|
||||
fi
|
||||
rm -rf auto-testing
|
||||
echo "clone attempt ${attempt} failed; retrying in $((attempt * 15))s" >&2
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
jq --version
|
||||
openssl version
|
||||
aws --version
|
||||
df -h /data | tail -1 || true
|
||||
|
||||
- name: Cleanup environment (before)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2 /var/lib/rustfs/kms
|
||||
'
|
||||
done
|
||||
|
||||
- name: Run replication suite
|
||||
id: test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-replication-test.sh
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
SUITE='${{ inputs.suite }}'
|
||||
ARGS=(-y --log-file "${LOG_FILE}")
|
||||
if [ "${SUITE}" = "all" ] || [ -z "${SUITE}" ] || [ "${SUITE}" = "null" ]; then
|
||||
ARGS+=(--suite all)
|
||||
else
|
||||
ARGS+=(--suite "${SUITE}")
|
||||
fi
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
ARGS+=(--package-url "${PACKAGE_URL}")
|
||||
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
|
||||
ARGS+=(--version "${RUSTFS_VERSION}")
|
||||
else
|
||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
./auto-testing/rustfs-replication-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
RUSTFS_VERSION='${{ inputs.rustfs_version }}'
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
PACKAGE_SOURCE="${PACKAGE_URL}"
|
||||
elif [ -n "${RUSTFS_VERSION}" ] && [ "${RUSTFS_VERSION}" != "null" ]; then
|
||||
PACKAGE_SOURCE="version ${RUSTFS_VERSION}"
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
RUSTFS_VERSION_INFO="N/A"
|
||||
if [ "${#NODES[@]}" -gt 0 ]; then
|
||||
DETECTED_VERSION="$(ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \
|
||||
"${SSH_USER}@${NODES[0]}" 'rustfs --version' 2>/dev/null | tr -d '\r' | head -n 1 || true)"
|
||||
if [ -n "${DETECTED_VERSION}" ]; then
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS replication test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: replication
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping dashboard upload"
|
||||
exit 0
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'replication'
|
||||
SUITE_LABEL: 'Replication (bucket + site)'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; skipping backlog issue"
|
||||
exit 0
|
||||
fi
|
||||
TITLE="[functional][${SUITE}] ${SUITE_LABEL} suite failed (run ${GITHUB_RUN_ID})"
|
||||
EXISTING="$(gh issue list -R rustfs/backlog --state all \
|
||||
--search "in:title \"run ${GITHUB_RUN_ID}\"" \
|
||||
--json number --jq '.[].number' || true)"
|
||||
if [ -n "${EXISTING}" ]; then
|
||||
echo "backlog issue already exists for run ${GITHUB_RUN_ID}; skipping"
|
||||
exit 0
|
||||
fi
|
||||
redact() {
|
||||
sed -E \
|
||||
-e 's/(RUSTFS_(ACCESS_KEY|SECRET_KEY)[=: ]+)[^[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/(Authorization:).*/\1 [REDACTED]/Ig' \
|
||||
-e 's/(X-Amz-Signature=)[^&[:space:]]+/\1[REDACTED]/Ig' \
|
||||
-e 's/^.*(password|secret|token)[=: ].*/[REDACTED SENSITIVE LINE]/Ig'
|
||||
}
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The **${SUITE_LABEL}** functional suite failed."
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
else
|
||||
echo "(no report or log file was produced)"
|
||||
fi
|
||||
} | head -c 55000 > "${BODY_FILE}"
|
||||
gh label create functional-test -R rustfs/backlog --color d73a4a 2>/dev/null || true
|
||||
if ! gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test; then
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}"
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-replication-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs rustfs-rep2 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /data/rustfs-rep2 /var/log/rustfs /var/log/rustfs-rep2
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: Performance)"
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-performance' \
|
||||
-F 'client_payload[from_suite]=replication'; then
|
||||
echo "dispatched next suite Performance (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Performance after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after replication (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
trap 'rm -f "${BODY_FILE}"' EXIT
|
||||
{
|
||||
echo "The functional chain could not hand off from **replication** to **Performance** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-performance'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-performance'"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "RustFS replication suite failed"
|
||||
echo "Package source: ${{ inputs.package_url || inputs.rustfs_version || 'nightly (R2 latest)' }}"
|
||||
echo "See the uploaded report and log artifacts for details."
|
||||
@@ -37,10 +37,28 @@ env:
|
||||
jobs:
|
||||
s3-compat-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-s3-compat-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -88,9 +106,6 @@ jobs:
|
||||
|
||||
- name: Run S3 compatibility suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-s3-compat.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-s3-compat-test.sh
|
||||
@@ -107,10 +122,7 @@ jobs:
|
||||
./auto-testing/rustfs-s3-compat-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-s3-compat.log
|
||||
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
@@ -132,83 +144,44 @@ jobs:
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-s3-compat-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
current = None
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
current = case_id
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
current = None
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS S3 compatibility test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-s3-compat-report.md
|
||||
SUITE: s3
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -218,28 +191,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 's3'
|
||||
SUITE_LABEL: 'S3 compatibility'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-s3-compat-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-s3-compat.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -267,14 +244,16 @@ jobs:
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -290,14 +269,15 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-s3-compat-${{ github.run_id }}
|
||||
name: rustfs-s3-compat-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-s3-compat.log
|
||||
/tmp/rustfs-s3-compat-report.md
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
@@ -320,21 +300,52 @@ jobs:
|
||||
|
||||
- name: "Continue functional chain (next: KMS)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: KMS"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-kms' \
|
||||
-F 'client_payload[from_suite]=s3'
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-kms' \
|
||||
-F 'client_payload[from_suite]=s3'; then
|
||||
echo "dispatched next suite KMS (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch KMS after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after s3 (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **s3** to **KMS** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-kms'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-kms'"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -47,7 +47,7 @@ on:
|
||||
type: boolean
|
||||
default: true
|
||||
repository_dispatch:
|
||||
# Chain handoff: dispatched when the pool expansion suite finishes (last link).
|
||||
# Chain handoff: dispatched when the pool expansion suite finishes.
|
||||
types: [rustfs-chain-security]
|
||||
|
||||
permissions:
|
||||
@@ -74,10 +74,27 @@ env:
|
||||
jobs:
|
||||
security-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
# Checkout the repository into its own subdirectory. Checking out at
|
||||
# the workspace root would wipe the auto-testing clone above (that is
|
||||
# exactly how run 33934141181 lost rustfs-security-test.sh).
|
||||
- name: Checkout repository (for the OIDC live gate script)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
path: rustfs-repo
|
||||
|
||||
- name: Initialize security evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
SECURITY_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-security-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${SECURITY_ARTIFACTS_DIR}" "${SECURITY_ARTIFACTS_DIR}-scratch"
|
||||
printf 'SECURITY_ARTIFACTS_DIR=%s\n' "${SECURITY_ARTIFACTS_DIR}" >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -98,11 +115,6 @@ jobs:
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Checkout repository (for the OIDC live gate script)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
@@ -135,8 +147,9 @@ jobs:
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
REPORT_FILE: /tmp/rustfs-security-report.md
|
||||
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/scripts/test/oidc_keycloak_live.sh
|
||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||
TMPDIR: ${{ env.SECURITY_ARTIFACTS_DIR }}-scratch
|
||||
RUSTFS_SECURITY_OIDC_LIVE_SCRIPT: ${{ github.workspace }}/rustfs-repo/scripts/test/oidc_keycloak_live.sh
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-security-test.sh
|
||||
@@ -159,29 +172,48 @@ jobs:
|
||||
else
|
||||
ARGS+=(--package-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
./auto-testing/rustfs-security-test.sh "${ARGS[@]}"
|
||||
GITHUB_STEP_SUMMARY=/dev/null ./auto-testing/rustfs-security-test.sh "${ARGS[@]}" 2>&1 | tee "${SECURITY_ARTIFACTS_DIR}/suite.log"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
id: report
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -f /tmp/rustfs-security-report.md ]; then
|
||||
{
|
||||
echo "# RustFS security test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Test Step Outcome: failure (suite did not produce a report)"
|
||||
} > /tmp/rustfs-security-report.md
|
||||
RESULT=failure
|
||||
if [ "${TEST_OUTCOME}" = "success" ] && [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
cat /tmp/rustfs-security-report.md >> "${GITHUB_STEP_SUMMARY}"
|
||||
{
|
||||
echo "# RustFS security test report"
|
||||
echo ""
|
||||
echo "- Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${TEST_OUTCOME}"
|
||||
echo ""
|
||||
# The dashboard prioritizes case rows over the step outcome.
|
||||
# Keep partial case results in the artifact when the suite fails.
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${SECURITY_ARTIFACTS_DIR}/suite-report.md"
|
||||
elif [ -s "${SECURITY_ARTIFACTS_DIR}/suite-report.md" ]; then
|
||||
echo "The suite did not complete successfully. See suite-report.md in this run's artifact for diagnostics."
|
||||
else
|
||||
echo "The suite did not produce a non-empty report."
|
||||
fi
|
||||
} > "${SECURITY_ARTIFACTS_DIR}/report.md"
|
||||
cat "${SECURITY_ARTIFACTS_DIR}/report.md" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-security-report.md
|
||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||
SUITE: security
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -191,17 +223,22 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
@@ -210,8 +247,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'security'
|
||||
SUITE_LABEL: 'Security'
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-security-report.md'
|
||||
REPORT_FILE: ${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||
LOG_FILE: ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -245,7 +283,7 @@ jobs:
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
@@ -263,14 +301,15 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-security-test-${{ github.run_id }}
|
||||
name: rustfs-security-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-security-report.md
|
||||
/tmp/rustfs-security.*/*
|
||||
if-no-files-found: ignore
|
||||
${{ env.SECURITY_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.SECURITY_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.SECURITY_ARTIFACTS_DIR }}/suite-report.md
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
@@ -292,6 +331,24 @@ jobs:
|
||||
'
|
||||
done
|
||||
|
||||
- name: "Continue functional chain (next: Replication)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: Replication"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-replication' \
|
||||
-F 'client_payload[from_suite]=security'
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
|
||||
@@ -46,10 +46,28 @@ env:
|
||||
jobs:
|
||||
storage-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 360
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-storage-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -97,9 +115,6 @@ jobs:
|
||||
|
||||
- name: Run storage engine suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-storage.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-storage-test.sh
|
||||
@@ -122,10 +137,7 @@ jobs:
|
||||
./auto-testing/rustfs-storage-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-storage.log
|
||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_URL='${{ inputs.package_url }}'
|
||||
@@ -147,83 +159,44 @@ jobs:
|
||||
RUSTFS_VERSION_INFO="${DETECTED_VERSION}"
|
||||
fi
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-storage-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z0-9]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z0-9]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
current = None
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
current = case_id
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
current = None
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS storage engine test report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- RustFS Version: ${RUSTFS_VERSION_INFO}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-storage-report.md
|
||||
SUITE: storage
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -233,28 +206,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'storage'
|
||||
SUITE_LABEL: 'Storage engine'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-storage-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-storage.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -282,14 +259,16 @@ jobs:
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -305,14 +284,15 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-storage-${{ github.run_id }}
|
||||
name: rustfs-storage-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-storage.log
|
||||
/tmp/rustfs-storage-report.md
|
||||
if-no-files-found: warn
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
@@ -335,21 +315,52 @@ jobs:
|
||||
|
||||
- name: "Continue functional chain (next: Heal)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: Heal"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-heal' \
|
||||
-F 'client_payload[from_suite]=storage'
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-heal' \
|
||||
-F 'client_payload[from_suite]=storage'; then
|
||||
echo "dispatched next suite Heal (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Heal after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after storage (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **storage** to **Heal** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-heal'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-heal'"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -11,9 +11,20 @@ on:
|
||||
description: 'Direct .deb URL (nightly/R2/dev). Overrides rustfs_version.'
|
||||
required: false
|
||||
type: string
|
||||
rc_sha256:
|
||||
description: 'Optional SHA-256 for the preinstalled rc binary; mismatch is an infrastructure failure.'
|
||||
rc_archive_url:
|
||||
description: 'Exact RustFS CLI Linux archive URL.'
|
||||
required: false
|
||||
default: 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz'
|
||||
type: string
|
||||
rc_archive_sha256:
|
||||
description: 'Expected SHA-256 of the RustFS CLI archive.'
|
||||
required: false
|
||||
default: 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df'
|
||||
type: string
|
||||
rc_sha256:
|
||||
description: 'Expected SHA-256 of the extracted RustFS CLI binary.'
|
||||
required: false
|
||||
default: '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df'
|
||||
type: string
|
||||
force_case_failure:
|
||||
description: 'Diagnostic only: rewrite single-single/TIER-101 to FAIL after execution to verify artifact and final-gate behavior.'
|
||||
@@ -41,19 +52,30 @@ env:
|
||||
RUSTFS_NODES: ${{ secrets.RUSTFS_NODES || vars.RUSTFS_NODES }}
|
||||
RUSTFS_SSH_USER: ${{ secrets.RUSTFS_SSH_USER || vars.RUSTFS_SSH_USER }}
|
||||
RUSTFS_NIGHTLY_PACKAGE_URL: ${{ vars.RUSTFS_NIGHTLY_PACKAGE_URL || 'https://dl.rustfs.com/artifacts/rustfs/packages/nightly/rustfs-nightly-latest.deb' }}
|
||||
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || vars.RUSTFS_TIER_RC_SHA256 }}
|
||||
RUSTFS_RC_ARCHIVE_URL: ${{ inputs.rc_archive_url || 'https://github.com/rustfs/cli/releases/download/v0.1.32/rustfs-cli-linux-amd64-v0.1.32.tar.gz' }}
|
||||
RUSTFS_RC_ARCHIVE_SHA256: ${{ inputs.rc_archive_sha256 || 'ab00d937079dcb6f1c7b41d34bbfaad0eb0bd4f7218672cbcb7c33652d1c46df' }}
|
||||
RUSTFS_EXPECTED_RC_SHA256: ${{ inputs.rc_sha256 || '320bdd4223a4d1986c1a098165f2198e92c35c4042b9a4d5e6fa33e9152477df' }}
|
||||
PF_TESTING_GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
TIER_ARTIFACTS_DIR: /tmp/rustfs-tier-artifacts-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
jobs:
|
||||
tier-test:
|
||||
runs-on: smoke-testing
|
||||
# Requirement: a failing suite must not fail the workflow; failures
|
||||
# are filed to rustfs/backlog and the chain continues.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Initialize run evidence directory
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
if ! mkdir -- "${TIER_ARTIFACTS_DIR}"; then
|
||||
echo "refusing to reuse tier evidence path: ${TIER_ARTIFACTS_DIR}" >&2
|
||||
exit 1
|
||||
fi
|
||||
test -d "${TIER_ARTIFACTS_DIR}"
|
||||
test ! -L "${TIER_ARTIFACTS_DIR}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -74,6 +96,111 @@ jobs:
|
||||
echo "ERROR: unable to clone rustfs/auto-testing after 5 attempts" >&2
|
||||
exit 1
|
||||
|
||||
- name: Prepare pinned RustFS CLI
|
||||
id: rc
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
case "${RUSTFS_RC_ARCHIVE_URL}" in
|
||||
https://*) ;;
|
||||
*)
|
||||
echo "RustFS CLI archive URL must use HTTPS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
EXPECTED_ARCHIVE_SHA256="$(printf '%s' "${RUSTFS_RC_ARCHIVE_SHA256}" | tr '[:upper:]' '[:lower:]')"
|
||||
EXPECTED_RC_SHA256="$(printf '%s' "${RUSTFS_EXPECTED_RC_SHA256}" | tr '[:upper:]' '[:lower:]')"
|
||||
if ! [[ "${EXPECTED_ARCHIVE_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "invalid RustFS CLI archive SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! [[ "${EXPECTED_RC_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "invalid RustFS CLI binary SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
RC_ARCHIVE="${RC_ROOT}/rustfs-cli.tar.gz"
|
||||
RC_BIN="${RC_ROOT}/rc"
|
||||
if ! mkdir -- "${RC_ROOT}"; then
|
||||
echo "refusing to reuse RustFS CLI directory: ${RC_ROOT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --fail --location --retry 3 --retry-all-errors \
|
||||
--connect-timeout 15 --max-time 180 \
|
||||
--proto '=https' --proto-redir '=https' \
|
||||
--output "${RC_ARCHIVE}" "${RUSTFS_RC_ARCHIVE_URL}"
|
||||
RC_ARCHIVE_SIZE="$(wc -c < "${RC_ARCHIVE}" | tr -d '[:space:]')"
|
||||
if [ "${RC_ARCHIVE_SIZE}" -eq 0 ] || [ "${RC_ARCHIVE_SIZE}" -gt 33554432 ]; then
|
||||
echo "RustFS CLI archive size is outside the accepted range: ${RC_ARCHIVE_SIZE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! ACTUAL_ARCHIVE_SHA256="$(openssl dgst -sha256 -r "${RC_ARCHIVE}" | awk '{print $1}')"; then
|
||||
echo "failed to calculate RustFS CLI archive SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${ACTUAL_ARCHIVE_SHA256}" != "${EXPECTED_ARCHIVE_SHA256}" ]; then
|
||||
echo "RustFS CLI archive SHA-256 mismatch: expected ${EXPECTED_ARCHIVE_SHA256}, got ${ACTUAL_ARCHIVE_SHA256}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE_MEMBERS="$(tar -tzf "${RC_ARCHIVE}")"
|
||||
if ! grep -Fxq 'rc' <<< "${ARCHIVE_MEMBERS}"; then
|
||||
echo "RustFS CLI archive does not contain the rc entry" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! tar -xOzf "${RC_ARCHIVE}" rc > "${RC_BIN}"; then
|
||||
echo "failed to extract the RustFS CLI binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
chmod 0700 "${RC_BIN}"
|
||||
if ! ACTUAL_RC_SHA256="$(openssl dgst -sha256 -r "${RC_BIN}" | awk '{print $1}')"; then
|
||||
echo "failed to calculate RustFS CLI binary SHA-256" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "${ACTUAL_RC_SHA256}" != "${EXPECTED_RC_SHA256}" ]; then
|
||||
echo "RustFS CLI binary SHA-256 mismatch: expected ${EXPECTED_RC_SHA256}, got ${ACTUAL_RC_SHA256}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! RC_VERSION_OUTPUT="$(timeout 30 "${RC_BIN}" --version 2>&1)"; then
|
||||
echo "failed to execute the pinned RustFS CLI" >&2
|
||||
exit 1
|
||||
fi
|
||||
RC_VERSION="${RC_VERSION_OUTPUT%%$'\n'*}"
|
||||
if [ -z "${RC_VERSION}" ]; then
|
||||
echo "pinned RustFS CLI returned an empty version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
jq -n \
|
||||
--arg schema_version '1' \
|
||||
--arg generated_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--arg archive_url "${RUSTFS_RC_ARCHIVE_URL}" \
|
||||
--arg archive_sha256 "${ACTUAL_ARCHIVE_SHA256}" \
|
||||
--arg archive_size "${RC_ARCHIVE_SIZE}" \
|
||||
--arg path "${RC_BIN}" \
|
||||
--arg version "${RC_VERSION}" \
|
||||
--arg sha256 "${ACTUAL_RC_SHA256}" \
|
||||
'{
|
||||
schema_version: ($schema_version | tonumber),
|
||||
generated_at: $generated_at,
|
||||
archive: {
|
||||
url: $archive_url,
|
||||
sha256: $archive_sha256,
|
||||
size: ($archive_size | tonumber)
|
||||
},
|
||||
binary: {
|
||||
path: $path,
|
||||
version: $version,
|
||||
sha256: $sha256
|
||||
}
|
||||
}' > "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json"
|
||||
printf 'path=%s\n' "${RC_BIN}" >> "${GITHUB_OUTPUT}"
|
||||
echo "RustFS CLI ready: ${RC_VERSION} (${ACTUAL_RC_SHA256})"
|
||||
|
||||
- name: Show environment
|
||||
run: |
|
||||
uname -a
|
||||
@@ -133,13 +260,13 @@ jobs:
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-tier.log
|
||||
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
|
||||
RC_BIN: ${{ steps.rc.outputs.path }}
|
||||
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
|
||||
chmod +x auto-testing/rustfs-tier-test.sh
|
||||
RC_BIN="$(command -v rc)"
|
||||
PACKAGE_URL="${PACKAGE_URL_INPUT}"
|
||||
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
|
||||
ARGS=(
|
||||
@@ -162,7 +289,7 @@ jobs:
|
||||
./auto-testing/rustfs-tier-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Inject diagnostic case failure
|
||||
if: ${{ always() && inputs.force_case_failure }}
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' && inputs.force_case_failure }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESULT_FILE="${TIER_ARTIFACTS_DIR}/cases/single-single--TIER-101.json"
|
||||
@@ -172,12 +299,8 @@ jobs:
|
||||
mv "${TMP_FILE}" "${RESULT_FILE}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-tier.log
|
||||
REPORT_FILE: /tmp/rustfs-tier-report.md
|
||||
CASE_TABLE: /tmp/rustfs-tier-cases.md
|
||||
GATE_RC_FILE: /tmp/rustfs-tier-gate.rc
|
||||
PACKAGE_URL_INPUT: ${{ inputs.package_url }}
|
||||
RUSTFS_VERSION_INPUT: ${{ inputs.rustfs_version }}
|
||||
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
@@ -185,6 +308,12 @@ jobs:
|
||||
TRIGGER_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -d "${TIER_ARTIFACTS_DIR}"
|
||||
test ! -L "${TIER_ARTIFACTS_DIR}"
|
||||
LOG_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier.log"
|
||||
REPORT_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier-report.md"
|
||||
CASE_TABLE="${TIER_ARTIFACTS_DIR}/rustfs-tier-cases.md"
|
||||
GATE_RC_FILE="${TIER_ARTIFACTS_DIR}/rustfs-tier-gate.rc"
|
||||
PACKAGE_URL="${PACKAGE_URL_INPUT}"
|
||||
RUSTFS_VERSION="${RUSTFS_VERSION_INPUT}"
|
||||
if [ -n "${PACKAGE_URL}" ]; then
|
||||
@@ -194,6 +323,11 @@ jobs:
|
||||
else
|
||||
PACKAGE_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
if RC_BOOTSTRAP_SUMMARY="$(jq -r '.binary | "\(.version) / \(.sha256)"' "${TIER_ARTIFACTS_DIR}/rc-bootstrap.json" 2>/dev/null)"; then
|
||||
:
|
||||
else
|
||||
RC_BOOTSTRAP_SUMMARY="missing or invalid"
|
||||
fi
|
||||
set +e
|
||||
python3 auto-testing/rustfs_tier_report.py \
|
||||
--results-dir "${TIER_ARTIFACTS_DIR}/cases" \
|
||||
@@ -215,6 +349,7 @@ jobs:
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${TRIGGER_NAME}"
|
||||
echo "- Package: ${PACKAGE_SOURCE}"
|
||||
echo "- Client bootstrap: ${RC_BOOTSTRAP_SUMMARY}"
|
||||
echo "- Test Step Outcome: ${TEST_OUTCOME}"
|
||||
echo "- Structured Gate Exit: ${CASE_GATE_RC}"
|
||||
echo ""
|
||||
@@ -228,11 +363,11 @@ jobs:
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-tier-report.md
|
||||
REPORT_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-report.md
|
||||
SUITE: tier
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -242,28 +377,138 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: Verify required tier evidence
|
||||
id: evidence_verify
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
for name in \
|
||||
rustfs-tier.log \
|
||||
rustfs-tier-report.md \
|
||||
rustfs-tier-cases.md \
|
||||
rustfs-tier-gate.rc \
|
||||
rc-bootstrap.json \
|
||||
provenance.json; do
|
||||
if [ ! -s "${TIER_ARTIFACTS_DIR}/${name}" ]; then
|
||||
echo "required tier evidence is missing or empty: ${name}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
for name in cases logs; do
|
||||
if [ ! -d "${TIER_ARTIFACTS_DIR}/${name}" ]; then
|
||||
echo "required tier evidence directory is missing: ${name}" >&2
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
if ! find "${TIER_ARTIFACTS_DIR}/cases" -maxdepth 1 -type f -name '*.json' -print -quit 2>/dev/null | grep -q .; then
|
||||
echo "no atomic tier case result was produced" >&2
|
||||
failed=1
|
||||
fi
|
||||
[ "${failed}" -eq 0 ]
|
||||
|
||||
- name: Upload report and logs
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-tier-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ env.TIER_ARTIFACTS_DIR }}/
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
|
||||
sudo rm -f /tmp/rustfs-mosquitto.conf
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms
|
||||
'
|
||||
done
|
||||
|
||||
- name: Cleanup pinned RustFS CLI
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RC_ROOT="${RUNNER_TEMP}/rustfs-tier-rc-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
rm -f -- "${RC_ROOT}/rustfs-cli.tar.gz" "${RC_ROOT}/rc"
|
||||
if [ -d "${RC_ROOT}" ]; then
|
||||
rmdir -- "${RC_ROOT}"
|
||||
fi
|
||||
|
||||
- name: Enforce tier suite result
|
||||
id: gate
|
||||
if: always()
|
||||
env:
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
GATE_RC_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-gate.rc
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
|
||||
echo "tier evidence directory initialization is ${EVIDENCE_OUTCOME}, expected success" >&2
|
||||
failed=1
|
||||
fi
|
||||
if [ "${TEST_OUTCOME}" != "success" ]; then
|
||||
echo "tier suite step outcome is ${TEST_OUTCOME}, expected success" >&2
|
||||
failed=1
|
||||
fi
|
||||
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
|
||||
echo "structured gate result is unavailable because evidence initialization failed" >&2
|
||||
elif [ ! -s "${GATE_RC_FILE}" ]; then
|
||||
echo "structured gate result is missing" >&2
|
||||
failed=1
|
||||
else
|
||||
GATE_RC="$(tr -d '[:space:]' < "${GATE_RC_FILE}")"
|
||||
if ! [[ "${GATE_RC}" =~ ^[0-9]+$ ]] || [ "${GATE_RC}" -ne 0 ]; then
|
||||
echo "structured 56-case gate failed with exit ${GATE_RC:-invalid}" >&2
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
[ "${failed}" -eq 0 ]
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled' || steps.evidence_verify.outcome == 'failure' || steps.evidence_verify.outcome == 'cancelled' || steps.gate.outcome == 'failure' || steps.gate.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
SUITE: 'tier'
|
||||
SUITE_LABEL: 'Tier'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-tier-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-tier.log'
|
||||
EVIDENCE_DIR: ${{ env.TIER_ARTIFACTS_DIR }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
VERIFY_OUTCOME: ${{ steps.evidence_verify.outcome }}
|
||||
GATE_OUTCOME: ${{ steps.gate.outcome }}
|
||||
REPORT_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier-report.md
|
||||
LOG_FILE: ${{ env.TIER_ARTIFACTS_DIR }}/rustfs-tier.log
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -293,10 +538,17 @@ jobs:
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo "- Evidence initialization: ${EVIDENCE_OUTCOME}"
|
||||
echo "- Evidence verification: ${VERIFY_OUTCOME}"
|
||||
echo "- Final gate: ${GATE_OUTCOME}"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" != "success" ]; then
|
||||
echo "(the run evidence directory was rejected; its contents were not read)"
|
||||
elif [ ! -d "${EVIDENCE_DIR}" ] || [ -L "${EVIDENCE_DIR}" ]; then
|
||||
echo "(the run evidence directory is missing or unsafe; its contents were not read)"
|
||||
elif [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
@@ -313,81 +565,54 @@ jobs:
|
||||
fi
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-tier-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-tier.log
|
||||
/tmp/rustfs-tier-report.md
|
||||
/tmp/rustfs-tier-cases.md
|
||||
/tmp/rustfs-tier-gate.rc
|
||||
${{ env.TIER_ARTIFACTS_DIR }}/
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
if: always()
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo docker rm -f rustfs-test-mqtt >/dev/null 2>&1 || true
|
||||
sudo rm -f /tmp/rustfs-mosquitto.conf
|
||||
read -r -a NODES <<< "${RUSTFS_NODES:-vm000 vm001 vm002}"
|
||||
SSH_USER="${RUSTFS_SSH_USER:-azureuser}"
|
||||
for node in "${NODES[@]}"; do
|
||||
ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new "${SSH_USER}@${node}" '
|
||||
set -euo pipefail
|
||||
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||
${SUDO} systemctl stop rustfs 2>/dev/null || true
|
||||
if ${SUDO} dpkg -l rustfs 2>/dev/null | grep -q "^ii"; then
|
||||
${SUDO} dpkg -P rustfs
|
||||
fi
|
||||
for i in 1 2 3 4; do ${SUDO} rm -rf /data/rustfs${i}/mnmd; done
|
||||
${SUDO} rm -rf /var/log/rustfs /var/lib/rustfs/kms
|
||||
'
|
||||
done
|
||||
|
||||
- name: Enforce tier suite result
|
||||
if: always()
|
||||
env:
|
||||
TEST_OUTCOME: ${{ steps.test.outcome }}
|
||||
GATE_RC_FILE: /tmp/rustfs-tier-gate.rc
|
||||
run: |
|
||||
set -euo pipefail
|
||||
failed=0
|
||||
if [ "${TEST_OUTCOME}" != "success" ]; then
|
||||
echo "tier suite step outcome is ${TEST_OUTCOME}, expected success" >&2
|
||||
failed=1
|
||||
fi
|
||||
if [ ! -s "${GATE_RC_FILE}" ]; then
|
||||
echo "structured gate result is missing" >&2
|
||||
failed=1
|
||||
else
|
||||
GATE_RC="$(tr -d '[:space:]' < "${GATE_RC_FILE}")"
|
||||
if ! [[ "${GATE_RC}" =~ ^[0-9]+$ ]] || [ "${GATE_RC}" -ne 0 ]; then
|
||||
echo "structured 56-case gate failed with exit ${GATE_RC:-invalid}" >&2
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
[ "${failed}" -eq 0 ]
|
||||
|
||||
- name: "Continue functional chain (next: Storage engine)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: Storage engine"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-storage' \
|
||||
-F 'client_payload[from_suite]=tier'
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-storage' \
|
||||
-F 'client_payload[from_suite]=tier'; then
|
||||
echo "dispatched next suite Storage engine (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch Storage engine after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after tier (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **tier** to **Storage engine** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-storage'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-storage'"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -18,15 +18,15 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
from_version:
|
||||
description: 'OLD RustFS release tag (e.g. 1.0.0-rc.4-preview.1)'
|
||||
description: 'OLD RustFS release tag, e.g. 1.0.0-rc.3 (its release must ship a .deb asset). Leave empty for the default.'
|
||||
required: false
|
||||
default: '1.0.0-rc.4-preview.1'
|
||||
default: '1.0.0-rc.3'
|
||||
from_url:
|
||||
description: 'OLD .deb URL. Overrides from_version.'
|
||||
required: false
|
||||
type: string
|
||||
to_version:
|
||||
description: 'NEW RustFS release tag (leave empty for latest nightly)'
|
||||
description: 'NEW RustFS release tag, e.g. 1.0.0-rc.5 (any version with a .deb asset). Leave empty for latest nightly.'
|
||||
required: false
|
||||
to_url:
|
||||
description: 'NEW .deb URL. Overrides to_version / nightly default.'
|
||||
@@ -79,10 +79,28 @@ env:
|
||||
jobs:
|
||||
upgrade-test:
|
||||
runs-on: smoke-testing
|
||||
continue-on-error: true
|
||||
timeout-minutes: 420
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||
steps:
|
||||
- name: Checkout repository (for report parser)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize functional evidence
|
||||
id: evidence
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
FUNCTIONAL_ARTIFACTS_DIR="${RUNNER_TEMP}/rustfs-upgrade-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
|
||||
mkdir -- "${FUNCTIONAL_ARTIFACTS_DIR}" "${FUNCTIONAL_ARTIFACTS_DIR}-scratch"
|
||||
{
|
||||
printf 'FUNCTIONAL_ARTIFACTS_DIR=%s\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'LOG_FILE=%s/suite.log\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'REPORT_FILE=%s/report.md\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
printf 'TMPDIR=%s-scratch\n' "${FUNCTIONAL_ARTIFACTS_DIR}"
|
||||
} >> "${GITHUB_ENV}"
|
||||
|
||||
# auto-testing is private: clone it with the dedicated PF token (not
|
||||
# GITHUB_TOKEN) and retry transient GitHub/network failures.
|
||||
- name: Checkout auto-testing scripts (with retry)
|
||||
@@ -142,9 +160,8 @@ jobs:
|
||||
|
||||
- name: Run upgrade compatibility suite
|
||||
id: test
|
||||
continue-on-error: true
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x auto-testing/rustfs-upgrade-test.sh
|
||||
@@ -175,13 +192,33 @@ jobs:
|
||||
else
|
||||
ARGS+=(--to-url "${RUSTFS_NIGHTLY_PACKAGE_URL}")
|
||||
fi
|
||||
# Fail fast with a clear message when a requested release tag has
|
||||
# no .deb asset (e.g. 1.0.0-rc.4 ships only zips), instead of
|
||||
# letting the suite die mid-run on a 404.
|
||||
check_release_asset() {
|
||||
local version="$1" tag asset url
|
||||
[ -n "${version}" ] && [ "${version}" != "null" ] || return 0
|
||||
tag="${version#v}"
|
||||
asset="rustfs_${tag//-/.}_amd64.deb"
|
||||
url="https://github.com/rustfs/rustfs/releases/download/${tag}/${asset}"
|
||||
if ! gh api "repos/rustfs/rustfs/releases/tags/${tag}" --jq '.assets[].name' 2>/dev/null | grep -qxF "${asset}"; then
|
||||
echo "ERROR: release ${tag} has no downloadable asset ${asset}:" >&2
|
||||
echo " ${url}" >&2
|
||||
echo "Pick a tag whose release ships a .deb (check its release assets)." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "resolved ${tag} -> ${url}"
|
||||
}
|
||||
if [ -z "${FROM_URL}" ]; then
|
||||
check_release_asset "${FROM_VERSION}"
|
||||
fi
|
||||
if [ -z "${TO_URL}" ]; then
|
||||
check_release_asset "${TO_VERSION}"
|
||||
fi
|
||||
./auto-testing/rustfs-upgrade-test.sh "${ARGS[@]}"
|
||||
|
||||
- name: Generate report
|
||||
if: always()
|
||||
env:
|
||||
LOG_FILE: /tmp/rustfs-upgrade.log
|
||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
FROM_URL='${{ inputs.from_url }}'
|
||||
@@ -202,80 +239,47 @@ jobs:
|
||||
else
|
||||
TO_SOURCE="${RUSTFS_NIGHTLY_PACKAGE_URL}"
|
||||
fi
|
||||
CASE_TABLE="/tmp/rustfs-upgrade-cases.md"
|
||||
python3 - "${LOG_FILE}" "${CASE_TABLE}" <<'PY'
|
||||
import re
|
||||
import sys
|
||||
|
||||
log_file, out_file = sys.argv[1], sys.argv[2]
|
||||
ansi = re.compile(r'\x1b\[[0-9;]*m')
|
||||
start_re = re.compile(r'^---\s+([A-Z]+-[0-9]+)\s+(.+?)\s+---$')
|
||||
done_re = re.compile(r'^\[(PASS|FAIL|UNSUPPORTED)\]\s+([A-Z]+-[0-9]+)\b')
|
||||
|
||||
rows = []
|
||||
index = {}
|
||||
try:
|
||||
with open(log_file, 'r', encoding='utf-8', errors='replace') as fh:
|
||||
for raw in fh:
|
||||
line = ansi.sub('', raw).strip()
|
||||
m = start_re.match(line)
|
||||
if m:
|
||||
case_id, name = m.group(1), m.group(2)
|
||||
if case_id not in index:
|
||||
index[case_id] = len(rows)
|
||||
rows.append([case_id, name, 'RUNNING'])
|
||||
continue
|
||||
m = done_re.match(line)
|
||||
if m:
|
||||
status, case_id = m.group(1), m.group(2)
|
||||
if case_id in index:
|
||||
rows[index[case_id]][2] = status
|
||||
else:
|
||||
rows.append([case_id, case_id, status])
|
||||
index[case_id] = len(rows) - 1
|
||||
except FileNotFoundError:
|
||||
rows = []
|
||||
|
||||
counts = {'PASS': 0, 'FAIL': 0, 'UNSUPPORTED': 0, 'RUNNING': 0}
|
||||
for _, _, status in rows:
|
||||
counts[status] = counts.get(status, 0) + 1
|
||||
|
||||
with open(out_file, 'w', encoding='utf-8') as out:
|
||||
out.write('## Case Summary\n\n')
|
||||
out.write(f"- Total: {len(rows)}\\n")
|
||||
out.write(f"- PASS: {counts.get('PASS', 0)}\\n")
|
||||
out.write(f"- FAIL: {counts.get('FAIL', 0)}\\n")
|
||||
out.write(f"- UNSUPPORTED: {counts.get('UNSUPPORTED', 0)}\\n")
|
||||
out.write('\\n')
|
||||
out.write('| Case | Name | Status |\\n')
|
||||
out.write('| --- | --- | --- |\\n')
|
||||
for case_id, name, status in rows:
|
||||
out.write(f'| {case_id} | {name} | {status} |\\n')
|
||||
PY
|
||||
CASE_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/cases.md"
|
||||
MATRIX_TABLE="${FUNCTIONAL_ARTIFACTS_DIR}/matrix.md"
|
||||
CASE_RESULT=success
|
||||
python3 scripts/functional_case_report.py "${LOG_FILE}" "${CASE_TABLE}" "${MATRIX_TABLE}" || CASE_RESULT=failure
|
||||
RESULT=failure
|
||||
if [ '${{ steps.test.outcome }}' = 'success' ] && [ "${CASE_RESULT}" = 'success' ]; then
|
||||
RESULT=success
|
||||
fi
|
||||
{
|
||||
echo "# RustFS upgrade compatibility report"
|
||||
echo ""
|
||||
echo "- Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${{ github.event_name }}"
|
||||
echo "- From: ${FROM_SOURCE}"
|
||||
echo "- To: ${TO_SOURCE}"
|
||||
echo "- Test Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo "- Test Step Outcome: ${RESULT}"
|
||||
echo "- Suite Step Outcome: ${{ steps.test.outcome }}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}" || true
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}" || true
|
||||
echo '```'
|
||||
if [ "${RESULT}" = "success" ]; then
|
||||
cat "${MATRIX_TABLE}"
|
||||
echo ""
|
||||
cat "${CASE_TABLE}"
|
||||
echo ""
|
||||
echo "## Log tail"
|
||||
echo '```text'
|
||||
tail -n 200 "${LOG_FILE}"
|
||||
echo '```'
|
||||
else
|
||||
echo "The suite or evidence validation failed. See this run's artifact for partial case results and suite.log."
|
||||
fi
|
||||
} | tee "${REPORT_FILE}"
|
||||
cat "${REPORT_FILE}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
[ "${RESULT}" = "success" ]
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ env.PF_TESTING_GH_TOKEN }}
|
||||
REPORT_FILE: /tmp/rustfs-upgrade-report.md
|
||||
SUITE: upgrade
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -285,28 +289,32 @@ jobs:
|
||||
fi
|
||||
DATE="$(date -u +%Y-%m-%d)"
|
||||
REPORT_PATH="functional-reports/${SUITE}/${DATE}.md"
|
||||
CONTENT="$(python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}")"
|
||||
# Base64-encode the report into a temp file and feed it to jq via
|
||||
# --rawfile: large reports (e.g. pool) exceed the OS argv limit and
|
||||
# make `jq --arg content "${CONTENT}"` fail with "Argument list too long".
|
||||
B64_FILE="$(mktemp)"
|
||||
python3 -c 'import base64,sys;print(base64.b64encode(open(sys.argv[1],"rb").read()).decode())' "${REPORT_FILE}" > "${B64_FILE}"
|
||||
SHA="$(gh api "repos/rustfs/dashboard/contents/${REPORT_PATH}" -q '.sha' 2>/dev/null || true)"
|
||||
if [ -n "${SHA}" ]; then
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:$content, sha:$sha}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" --arg sha "${SHA}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n")), sha:$sha}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
else
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --arg content "${CONTENT}" \
|
||||
'{message:$msg, content:$content}' \
|
||||
jq -n --arg msg "report(${SUITE}): ${DATE}" --rawfile content "${B64_FILE}" \
|
||||
'{message:$msg, content:($content|rtrimstr("\n"))}' \
|
||||
| gh api --method PUT "repos/rustfs/dashboard/contents/${REPORT_PATH}" --input - >/dev/null
|
||||
fi
|
||||
rm -f "${B64_FILE}"
|
||||
|
||||
- name: File failure issue in rustfs/backlog
|
||||
if: ${{ always() && (failure() || steps.test.outcome == 'failure' || steps.test.outcome == 'cancelled') }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
EVIDENCE_OUTCOME: ${{ steps.evidence.outcome }}
|
||||
SUITE: 'upgrade'
|
||||
SUITE_LABEL: 'Upgrade compatibility'
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
REPORT_FILE: '/tmp/rustfs-upgrade-report.md'
|
||||
LOG_FILE: '/tmp/rustfs-upgrade.log'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
@@ -334,14 +342,16 @@ jobs:
|
||||
echo ""
|
||||
echo "- Suite: \`${SUITE}\`"
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Attempt: ${GITHUB_RUN_ATTEMPT}"
|
||||
echo "- Workflow Commit: ${GITHUB_SHA}"
|
||||
echo "- Trigger: ${GITHUB_EVENT_NAME}"
|
||||
echo "- Date: $(date -u +%Y-%m-%d)"
|
||||
echo ""
|
||||
echo "## Report (errors and symptoms)"
|
||||
echo ""
|
||||
if [ -s "${REPORT_FILE}" ]; then
|
||||
if [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${REPORT_FILE}" ]; then
|
||||
redact < "${REPORT_FILE}"
|
||||
elif [ -s "${LOG_FILE:-}" ]; then
|
||||
elif [ "${EVIDENCE_OUTCOME}" = "success" ] && [ -s "${LOG_FILE:-}" ]; then
|
||||
echo "(report file missing; log tail below)"
|
||||
echo ""
|
||||
tail -n 200 "${LOG_FILE}" | redact
|
||||
@@ -357,14 +367,16 @@ jobs:
|
||||
echo "filed backlog issue for suite ${SUITE}"
|
||||
|
||||
- name: Upload report and logs
|
||||
if: always()
|
||||
if: ${{ always() && steps.evidence.outcome == 'success' }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: rustfs-upgrade-test-${{ github.run_id }}
|
||||
name: rustfs-upgrade-test-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: |
|
||||
/tmp/rustfs-upgrade-report.md
|
||||
/tmp/rustfs-upgrade.*/*
|
||||
if-no-files-found: ignore
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/report.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/suite.log
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/cases.md
|
||||
${{ env.FUNCTIONAL_ARTIFACTS_DIR }}/matrix.md
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
- name: Cleanup environment (after)
|
||||
@@ -388,21 +400,52 @@ jobs:
|
||||
|
||||
- name: "Continue functional chain (next: S3 compatibility)"
|
||||
# Only chain-triggered runs forward to the next suite; standalone
|
||||
# workflow_dispatch runs stop after their own cleanup.
|
||||
# workflow_dispatch runs stop after their own cleanup. A failed
|
||||
# handoff must never pass silently: it retries, then files an alert
|
||||
# issue in rustfs/backlog so a stalled chain is visible.
|
||||
if: ${{ always() && github.event_name == 'repository_dispatch' }}
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -uo pipefail
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
echo "PF_TESTING_GH_TOKEN is not configured; cannot dispatch the next suite" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Dispatching next functional suite: S3 compatibility"
|
||||
gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-s3' \
|
||||
-F 'client_payload[from_suite]=upgrade'
|
||||
DISPATCHED=0
|
||||
for attempt in 1 2 3; do
|
||||
if gh api --method POST repos/rustfs/rustfs/dispatches \
|
||||
-f event_type='rustfs-chain-s3' \
|
||||
-F 'client_payload[from_suite]=upgrade'; then
|
||||
echo "dispatched next suite S3 compatibility (attempt ${attempt})"
|
||||
DISPATCHED=1
|
||||
break
|
||||
fi
|
||||
echo "dispatch attempt ${attempt} failed; retrying in ${attempt}0s" >&2
|
||||
sleep "${attempt}0"
|
||||
done
|
||||
if [ "${DISPATCHED:-0}" -ne 1 ]; then
|
||||
echo "ERROR: functional chain stalled: could not dispatch S3 compatibility after 3 attempts" >&2
|
||||
TITLE="[functional][chain] stalled after upgrade (run ${GITHUB_RUN_ID})"
|
||||
BODY_FILE="$(mktemp)"
|
||||
{
|
||||
echo "The functional chain could not hand off from **upgrade** to **S3 compatibility** after 3 attempts."
|
||||
echo ""
|
||||
echo "- Failed suite job: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
echo "- Expected next event: 'rustfs-chain-s3'"
|
||||
echo "- Likely cause: PF_TESTING_GH_TOKEN lacks contents:write on rustfs/rustfs, or the GitHub API was unavailable."
|
||||
echo "- Recovery: re-dispatch manually with"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
echo " gh api --method POST repos/rustfs/rustfs/dispatches -f event_type='rustfs-chain-s3'"
|
||||
FENCE="$(printf "\x60\x60\x60")"; echo " ${FENCE}"
|
||||
} > "${BODY_FILE}"
|
||||
gh issue create -R rustfs/backlog --title "${TITLE}" \
|
||||
--body-file "${BODY_FILE}" --label functional-test \
|
||||
|| gh issue create -R rustfs/backlog --title "${TITLE}" --body-file "${BODY_FILE}" \
|
||||
|| echo "could not file the stall alert issue either; check the token" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
- name: Check latest scheduled runs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RUSTFS_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set +e
|
||||
python3 scripts/check_scheduled_validation_freshness.py \
|
||||
|
||||
@@ -22,6 +22,7 @@ on:
|
||||
- "Continuous Integration"
|
||||
- "coverage"
|
||||
- "e2e-nightly"
|
||||
- "e2e-distributed"
|
||||
- "e2e-s3tests"
|
||||
- "Fuzz"
|
||||
- "mint"
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: overtrue/repo-visuals-action@72f34d24769ff5d341956da2f23952594ef2f1e2 # v1.3.0
|
||||
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
output-branch: star-history
|
||||
|
||||
+5
-3
@@ -33,6 +33,7 @@ profile.json
|
||||
*.zst
|
||||
.secrets
|
||||
*.go
|
||||
!crates/zip/tests/fixtures/snowball/**/generate/*.go
|
||||
*.pb
|
||||
*.svg
|
||||
deploy/logs/*.log.*
|
||||
@@ -55,11 +56,10 @@ docs/*
|
||||
!docs/architecture/**
|
||||
!docs/operations/
|
||||
!docs/operations/**
|
||||
!docs/postmortems/
|
||||
!docs/postmortems/**
|
||||
!docs/testing/
|
||||
!docs/testing/**
|
||||
docs/heal-scanner-logging-governance.md
|
||||
docs/benchmark/rustfs-target-bench/
|
||||
docs/benchmark/*.md
|
||||
.codegraph/*
|
||||
.docker/test/compat/data/*
|
||||
.docker/test/compat/kms/*
|
||||
@@ -83,6 +83,8 @@ worktrees/*
|
||||
|
||||
# Local AI-agent review artifacts (omo evidence dumps)
|
||||
.omo/
|
||||
# Legacy per-tool skill dir; skills live in .agents/skills (shared by all agents)
|
||||
.mimocode/
|
||||
|
||||
# insta scratch files; the accepted .snap files ARE the assertions and are committed
|
||||
*.snap.new
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
name: issue-triage
|
||||
description: Triage a GitHub issue — determine if it is already fixed, needs implementation, or should be closed. Searches related commits and PRs, verifies implementation status, and posts a triage comment or closes the issue. Use when the user provides an issue URL and asks whether it can be closed or needs work.
|
||||
---
|
||||
|
||||
# Issue Triage
|
||||
|
||||
Use this skill when the user provides a GitHub issue URL and asks "can this be closed?", "is this already implemented?", "check completion status", or similar triage questions.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Fetch issue context
|
||||
|
||||
```bash
|
||||
gh issue view <N> --repo <owner/repo> --json title,body,state,comments,labels,updatedAt
|
||||
```
|
||||
|
||||
Read the issue body to understand what was requested. Extract:
|
||||
- The specific feature/fix/behavior described.
|
||||
- Any linked PRs or commits mentioned in the body or comments.
|
||||
- Any checklist items or sub-issues.
|
||||
|
||||
### 2. Search for related work
|
||||
|
||||
Search git history for commits referencing the issue:
|
||||
```bash
|
||||
git log --oneline --all --grep="<N>" | head -30
|
||||
```
|
||||
|
||||
Search for related PRs:
|
||||
```bash
|
||||
gh pr list --search "fixes #<N> OR closes #<N> OR #<N>" --state all --json number,title,state,mergedAt
|
||||
```
|
||||
|
||||
If the issue mentions specific PRs, check their status:
|
||||
```bash
|
||||
gh pr view <PR_N> --json state,mergedAt,title
|
||||
```
|
||||
|
||||
### 3. Verify implementation
|
||||
|
||||
For each linked or related PR that is merged, verify the fix is actually present on the current main branch:
|
||||
```bash
|
||||
git log --oneline main | grep -i "<keyword>"
|
||||
# or
|
||||
git log --oneline main --grep="<PR_N>"
|
||||
```
|
||||
|
||||
If the issue describes a specific defect, check the relevant code to confirm the fix is in place:
|
||||
```bash
|
||||
grep -n "<pattern>" crates/<relevant>/src/<file>.rs
|
||||
```
|
||||
|
||||
For issues with checklists, verify each item individually. If sub-items are tracked as separate issues, check those too:
|
||||
```bash
|
||||
gh issue view <SUB_N> --repo <owner/repo> --json state
|
||||
```
|
||||
|
||||
### 4. Determine verdict
|
||||
|
||||
- **All items fixed and merged**: Close with a summary comment listing what was fixed and which PRs.
|
||||
- **Some items fixed, some remaining**: Comment with status of each item. Do not close.
|
||||
- **Not yet implemented**: Comment with a summary of what remains. Do not close.
|
||||
- **Superseded or no longer relevant**: Close with explanation.
|
||||
|
||||
### 5. Take action
|
||||
|
||||
Close with comment:
|
||||
```bash
|
||||
gh issue close <N> --repo <owner/repo> --comment "<body>"
|
||||
```
|
||||
|
||||
Comment without closing:
|
||||
```bash
|
||||
gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md
|
||||
```
|
||||
|
||||
Update issue labels if needed:
|
||||
```bash
|
||||
gh issue edit <N> --repo <owner/repo> --add-label "completed" --remove-label "needs-triage"
|
||||
```
|
||||
|
||||
Always use `--body-file` for multiline content, never inline `--body`.
|
||||
|
||||
### 6. Handle multi-issue batches
|
||||
|
||||
When the user asks to check multiple issues (e.g., "check all issues by user X" or "scan backlog for closable issues"):
|
||||
1. List the issues: `gh issue list --repo <repo> --author <user> --state open --json number,title,updatedAt`
|
||||
2. For each issue, run steps 1-5 above.
|
||||
3. Report a summary table of all triaged issues with verdicts.
|
||||
|
||||
## Output format
|
||||
|
||||
### Issue Triage: #<N> — <title>
|
||||
|
||||
**State**: OPEN / CLOSED
|
||||
**Linked PRs**: <list with merge status>
|
||||
|
||||
#### Assessment
|
||||
<what was requested vs what is implemented>
|
||||
|
||||
#### Verdict
|
||||
- Close — all items resolved by <PR list>
|
||||
- Keep open — <remaining items>
|
||||
- Not started — <what needs to be done>
|
||||
|
||||
#### Action taken
|
||||
- Closed with comment / Commented / No action
|
||||
|
||||
## Notes
|
||||
|
||||
- The user may ask in Chinese ("是否可以关闭", "检查完成情况"); respond in the same language.
|
||||
- When closing, always include a summary of what was fixed and which PRs resolved it — this creates a useful audit trail.
|
||||
- For issues in `rustfs/backlog`, use `--repo rustfs/backlog`.
|
||||
- For issues in `rustfs/rustfs`, use `--repo rustfs/rustfs`.
|
||||
- If the issue has sub-issues (GitHub sub-issues API), check each one's state before declaring the parent complete.
|
||||
@@ -1,147 +0,0 @@
|
||||
---
|
||||
name: pr-review
|
||||
description: Review a GitHub PR end-to-end from a URL or number — fetch metadata, inspect the diff, run multi-role adversarial review, check CI status, and post the review comment. Use when the user provides a PR link and asks to review it.
|
||||
---
|
||||
|
||||
# PR Review
|
||||
|
||||
Use this skill when the user provides a GitHub PR URL or number and asks to review it. This covers the full review lifecycle: data gathering, code review, CI verification, and posting the result.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Read `AGENTS.md` for the repository's adversarial validation policy and change-style rules.
|
||||
- The `adversarial-validation` skill handles the review role playbooks; this skill orchestrates the workflow around it.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Gather PR context
|
||||
|
||||
```bash
|
||||
gh pr view <N> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName
|
||||
gh pr diff <N> --name-only
|
||||
```
|
||||
|
||||
Read the PR body and linked issues to understand the change's purpose. If the PR references an issue, fetch that too:
|
||||
```bash
|
||||
gh issue view <ISSUE> --json title,body,state
|
||||
```
|
||||
|
||||
### 2. Fetch the diff and classify the change
|
||||
|
||||
```bash
|
||||
git fetch origin pull/<N>/head:pr-<N>
|
||||
git diff main...pr-<N> --stat
|
||||
```
|
||||
|
||||
Classify the change by risk tier (per AGENTS.md):
|
||||
- **Exempt**: docs/comments/instruction-only, formatting, typos.
|
||||
- **Mechanical**: renames, file moves, test-only or tooling changes.
|
||||
- **Standard** (default): any behavior change.
|
||||
- **High risk**: locking, erasure coding, quorum/heal, replication, multipart, RPC, lifecycle/tiering, metadata formats, persistence/fsync, IAM/KMS/auth, on-disk/on-wire formats, S3 API-visible behavior.
|
||||
|
||||
### 3. Cluster changed files and delegate review
|
||||
|
||||
Group the changed files into logical clusters (by crate or functional area). For each cluster, spawn a subagent with a focused review prompt that includes:
|
||||
- The cluster's changed files and their diffs.
|
||||
- The applicable adversarial role probes (from the `adversarial-validation` skill).
|
||||
- The repository's AGENTS.md rules relevant to that domain.
|
||||
|
||||
For standard-tier changes: correctness adversary + simplicity adversary + test-coverage skeptic, plus every role whose domain the diff touches.
|
||||
For high-risk changes: run all seven roles.
|
||||
|
||||
Each subagent must produce findings (concrete failure scenario with file:line) or a null report ("attacked X, Y, Z — no break found").
|
||||
|
||||
### 4. Check CI status
|
||||
|
||||
```bash
|
||||
gh pr checks <N>
|
||||
```
|
||||
|
||||
If any checks fail, investigate:
|
||||
```bash
|
||||
gh run view --log-failed --job=<JOB_ID>
|
||||
```
|
||||
|
||||
Determine whether failures are pre-existing (on main), flaky, or caused by the PR.
|
||||
|
||||
### 5. Synthesize findings
|
||||
|
||||
Combine all subagent findings into a structured review:
|
||||
- **Summary**: one-paragraph overview of the change and overall assessment.
|
||||
- **Findings**: each finding with severity (critical/major/minor/nit), file:line, concrete failure scenario, and suggested fix.
|
||||
- **CI status**: pass/fail with notes on any failures.
|
||||
- **Verdict**: APPROVE, REQUEST_CHANGES, or COMMENT.
|
||||
|
||||
### 6. Post the review
|
||||
|
||||
Write the review body to a temp file and post via CLI:
|
||||
```bash
|
||||
# Request changes
|
||||
gh pr review <N> --request-changes --body-file /tmp/pr_review.md
|
||||
|
||||
# Approve
|
||||
gh pr review <N> --approve --body-file /tmp/pr_review.md
|
||||
|
||||
# Comment only (no verdict)
|
||||
gh pr review <N> --comment --body-file /tmp/pr_review.md
|
||||
```
|
||||
|
||||
For inline comments on specific lines, use the GitHub API:
|
||||
```bash
|
||||
cat > /tmp/pr_review.json <<'EOF'
|
||||
{
|
||||
"body": "review body",
|
||||
"event": "REQUEST_CHANGES",
|
||||
"comments": [
|
||||
{
|
||||
"path": "crates/foo/src/bar.rs",
|
||||
"line": 42,
|
||||
"body": "finding description"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
gh api --method POST /repos/{owner}/{repo}/pulls/<N>/reviews --input /tmp/pr_review.json
|
||||
```
|
||||
|
||||
Always use `--body-file` or `--input`, never inline multiline `--body`.
|
||||
|
||||
### 7. Handle follow-up
|
||||
|
||||
If the review requests changes:
|
||||
- Monitor for new commits: `gh pr view <N> --json commits`
|
||||
- Re-review changed files only: `git diff pr-<N>..origin/pull/<N>/head`
|
||||
- Update the review when findings are addressed.
|
||||
|
||||
If CI was failing due to pre-existing main breakage:
|
||||
- Comment on the PR noting the failure is pre-existing.
|
||||
- Suggest updating the branch: `gh pr update-branch <N>`
|
||||
|
||||
## Output format
|
||||
|
||||
### PR Review: #<N> — <title>
|
||||
|
||||
**Author**: <author>
|
||||
**Risk tier**: exempt | mechanical | standard | high-risk
|
||||
**Changed files**: <count> across <cluster count> clusters
|
||||
|
||||
#### Summary
|
||||
<one-paragraph overview>
|
||||
|
||||
#### Findings
|
||||
| Severity | Location | Finding |
|
||||
|----------|----------|---------|
|
||||
| critical | file:line | concrete failure scenario |
|
||||
|
||||
#### CI Status
|
||||
- All checks pass / Failing: <details>
|
||||
|
||||
#### Verdict
|
||||
APPROVE / REQUEST_CHANGES / COMMENT
|
||||
|
||||
## Notes
|
||||
|
||||
- The user may ask for review in Chinese; respond in the same language but keep the review body in English per AGENTS.md rules.
|
||||
- When the user asks for "多角色对抗 review", run the full adversarial validation protocol — this skill's step 3 covers that.
|
||||
- If the PR is from a fork, check `maintainerCanModify` before attempting to push fixes.
|
||||
- For very large PRs (>50 files), cluster aggressively and delegate in parallel to keep review time reasonable.
|
||||
@@ -3,9 +3,9 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: rustfs-dev-check
|
||||
name: rustfs dev-check
|
||||
entry: make dev-check
|
||||
- id: rustfs-fmt-check
|
||||
name: Rust formatting
|
||||
entry: cargo fmt --all --check
|
||||
language: system
|
||||
types: [rust]
|
||||
pass_filenames: false
|
||||
|
||||
@@ -7,8 +7,11 @@ This file contains repository-wide rules. Use the nearest subdirectory
|
||||
|
||||
1. System/developer instructions.
|
||||
2. The current user request.
|
||||
3. The nearest `AGENTS.md`.
|
||||
4. This file.
|
||||
3. Applicable `AGENTS.md` files, with the nearest file winning conflicts.
|
||||
4. Selected skills and reference documents.
|
||||
|
||||
Nested instructions add to ancestor rules; they do not discard non-conflicting
|
||||
rules. A skill cannot expand the user's requested scope or grant authorization.
|
||||
|
||||
## Operating Model
|
||||
|
||||
@@ -21,63 +24,29 @@ This file contains repository-wide rules. Use the nearest subdirectory
|
||||
- Do not load every skill or inspect unrelated modules preemptively. Select a
|
||||
skill only when its description directly matches the request or changed
|
||||
surface.
|
||||
- Resolve repository workflow skills under `.agents/skills/` when a global
|
||||
skill has the same name, unless the user explicitly selects another path.
|
||||
- Avoid repeated reads and equivalent verification commands once enough
|
||||
evidence exists.
|
||||
- Search for relevant symbols/headings before reading long files; return only
|
||||
matching ranges. If output is truncated, narrow the query instead of repeating
|
||||
a full read. Keep reusable raw logs in task artifacts and report the evidence.
|
||||
- Reuse authorization already given in the conversation. Resolve routine choices
|
||||
within that scope and continue independent work while a material question is
|
||||
pending. Before requesting missing approval, prepare the concrete result that
|
||||
is already authorized; retain explicit merge and release gates.
|
||||
|
||||
## Worktree and Disk Hygiene
|
||||
## Task-Specific Guidance
|
||||
|
||||
- Start implementation from the latest `origin/main` and confirm the requested
|
||||
change is not already present.
|
||||
- An existing clean, isolated task worktree is sufficient. Create another
|
||||
worktree only when the current checkout is shared, dirty with unrelated work,
|
||||
or belongs to another task.
|
||||
- Never commit from a shared checkout.
|
||||
- Use a task-specific branch named `<type>/<topic>`, such as `fix/...`,
|
||||
`feat/...`, `test/...`, or `docs/...`, unless the user specifies a name.
|
||||
- Do not include agent, tool, contributor, account, or organization names in
|
||||
branch names.
|
||||
- Push to the user-requested remote or the repository's configured push remote.
|
||||
Do not hard-code or infer a remote from an account name.
|
||||
- Check free space before artifact-heavy builds, tests, coverage, or downloads.
|
||||
Re-check before a broad gate when space is tight.
|
||||
- Remove only task-owned temporary/build artifacts. Never delete another task's
|
||||
worktree or uncommitted data.
|
||||
- At handoff, mention disk or cleanup details only when they affected execution
|
||||
or artifacts/worktrees remain intentionally.
|
||||
Read only the reference needed for the current task, once per unchanged context:
|
||||
|
||||
## Change Style
|
||||
|
||||
- Preserve existing control flow unless changing it is required for correctness.
|
||||
- Prefer a direct local edit over new files, wrappers, managers, or speculative
|
||||
abstractions.
|
||||
- Add a helper only when it removes current duplication, names a real domain
|
||||
boundary, or isolates a non-trivial invariant.
|
||||
- Remove an in-scope path superseded by the change. If compatibility requires it,
|
||||
adapt at the boundary to one canonical core and use the repository's
|
||||
`RUSTFS_COMPAT_TODO` policy.
|
||||
- Comments explain non-obvious invariants or reasons. Do not narrate code or
|
||||
record change history.
|
||||
- Mention unrelated problems when useful; do not fix them in a narrow task.
|
||||
|
||||
## Reuse and Boundary Rules
|
||||
|
||||
- Before adding helpers, constants, fixtures, or wrappers, search the touched
|
||||
crate, the domain-owning crate, `crates/utils`, `crates/common`, and relevant
|
||||
direct dependencies.
|
||||
- Reuse requires matching semantics: normalization, error types, deadlines,
|
||||
durability, and compatibility must fit the call site. A narrowly named local
|
||||
helper is better than forced reuse with different semantics.
|
||||
- Validate untrusted input at its trust boundary, then trust the validated type.
|
||||
Values crossing disk, RPC, persistence, or version boundaries remain
|
||||
untrusted at every consumer.
|
||||
- Re-check boundary values immediately before destructive actions such as
|
||||
delete, overwrite, or quorum decisions.
|
||||
- Every new branch needs a concrete triggering input/state. For decoded or peer
|
||||
data, corruption and mixed-version input are valid triggers.
|
||||
- Required values must return a typed error when absent or corrupt; do not use a
|
||||
default that converts corruption into a plausible result.
|
||||
- Attach error context once where it is actionable. Do not erase typed errors
|
||||
below aggregation or quorum layers.
|
||||
- Before code changes or artifact-heavy work, read [implementation rules](.agents/references/implementation.md).
|
||||
For a read-only code review, use its change-style and boundary sections as needed.
|
||||
- Before commits, pushes, PR creation/updates, or posting to PRs/issues/discussions,
|
||||
read [Git and PR rules](.agents/references/pull-requests.md).
|
||||
Reuse existing authorization; a reference does not authorize posting, merging, or publishing.
|
||||
- Preserve unrelated work. Never commit from a shared checkout or delete another task's artifacts.
|
||||
- Source comments, commits, PR titles, and PR bodies are in English.
|
||||
|
||||
## Sources of Truth
|
||||
|
||||
@@ -86,6 +55,7 @@ This file contains repository-wide rules. Use the nearest subdirectory
|
||||
- CI gates: `.github/workflows/ci.yml`.
|
||||
- PR format: `.github/pull_request_template.md`.
|
||||
- Architecture routing: `ARCHITECTURE.md` and `docs/architecture/README.md`.
|
||||
- Knowledge-base index and documentation rules: `docs/architecture/README.md`.
|
||||
- Agent skills: `.agents/skills/*/SKILL.md`.
|
||||
|
||||
Do not commit one-shot plans, trackers, migration ledgers, benchmark snapshots,
|
||||
@@ -146,67 +116,25 @@ requested adversarial/design reviews, and agent-instruction changes that alter
|
||||
execution. Ordinary questions, diagnoses, status reports, non-adversarial code
|
||||
reviews, and low-risk planning do not trigger it.
|
||||
|
||||
Risk and review shape:
|
||||
For applicable work and substantial PR reviews, read the [risk tiers and review shape](.agents/references/adversarial-validation.md).
|
||||
Load only the matching domain probes; ordinary reviews do not become adversarial
|
||||
merely because this reference exists.
|
||||
|
||||
- **Exempt:** documentation, comments, formatting, or typos with no runtime,
|
||||
build, test, or agent-execution effect.
|
||||
- **Mechanical:** renames, moves, test/tooling-only changes, and agent-rule
|
||||
changes. Run correctness and simplicity lenses.
|
||||
- **Standard:** localized behavior changes. Run one integrated final-diff pass
|
||||
covering correctness, simplicity, and test coverage; add only domain lenses
|
||||
matched by the diff.
|
||||
- **High risk / substantial PR review:** high risk includes locking,
|
||||
erasure/quorum/heal, replication, multipart, RPC, lifecycle/tiering,
|
||||
persistence/fsync, IAM/KMS/auth, cryptography, on-disk/on-wire formats, and
|
||||
S3-visible semantics. Cover all applicable lenses using exactly two
|
||||
independent reviewers when delegation is explicitly authorized. Split the
|
||||
lenses between them. Otherwise perform two fresh sequential passes.
|
||||
A review has no finding quota; `No findings` is a complete outcome. A request to
|
||||
find problems is not evidence that a defect exists. Before reporting a candidate,
|
||||
check callers, invariants, and existing tests for evidence that disproves it.
|
||||
Findings need `file:line` and a concrete failure or violation of an explicit
|
||||
requirement. Missing required tests/checks are verification gaps, not proof of a
|
||||
runtime bug; name the unprotected behavior or unmet gate. Keep optional style or
|
||||
refactoring preferences out of defect findings unless that review was requested.
|
||||
|
||||
Available domain lenses are security, concurrency/durability, compatibility,
|
||||
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
||||
explicit adversarial request, a high-risk change, or a substantial PR review;
|
||||
then read only its matching role references. A routine standard pass does not
|
||||
load the playbook unless the reviewer needs a RustFS-specific probe.
|
||||
|
||||
A finding must name a concrete input/state/interleaving and wrong outcome, or a
|
||||
specific missing regression check, with `file:line`. Resolve it by fixing the
|
||||
diff or rebutting it with code-path/test/invariant evidence. After a non-trivial
|
||||
fix, rerun only affected lenses.
|
||||
Fix or rebut supported findings within the authorized scope. Once the required
|
||||
passes are complete, stop. Reopen only for changed code, new evidence, an
|
||||
unresolved finding, or an explicit re-review request; an unchanged diff does not
|
||||
need another pass at every conversation turn or workflow handoff.
|
||||
|
||||
For high-risk PRs, record one concise verdict per covered lens in the PR body.
|
||||
|
||||
## Pull Request Lifecycle
|
||||
|
||||
- Creating or updating a PR includes one immediate snapshot of checks,
|
||||
mergeability, reviews, and unresolved threads.
|
||||
- Unless the user explicitly requests monitoring, a release workflow requires
|
||||
it, or an automation already owns it, hand off after the PR is open with the
|
||||
current state and next event to watch. Do not delay ordinary handoff with
|
||||
fixed quiet-period sleeps.
|
||||
- For requested monitoring, use event-driven or bounded waits. Report only state
|
||||
changes, actionable failures, or a meaningful prolonged delay.
|
||||
- Investigate failures/comments before changing code. Fix task-attributable
|
||||
issues, rerun affected verification, push, reply or resolve the thread, then
|
||||
resume the requested monitor.
|
||||
- Never merge without required reviewer approval or explicit authority.
|
||||
- After an observed merge, verify the commit reached the base, then clean the
|
||||
task worktree/branch when safe. Preserve unmerged work for closed PRs unless
|
||||
deletion was explicitly authorized.
|
||||
|
||||
## Git and PR Baseline
|
||||
|
||||
- Follow Conventional Commits; keep the subject at most 72 characters.
|
||||
- Source comments, commits, PR titles, and PR bodies are in English.
|
||||
- Keep every heading from `.github/pull_request_template.md`; use `N/A` where
|
||||
needed and include commands actually run.
|
||||
- Use `--body-file` for multiline `gh pr create`/`gh pr edit` content.
|
||||
- PR/issue/discussion content must not contain the literal sequence `\n` or
|
||||
hard-wrapped prose paragraphs.
|
||||
- Do not include local absolute paths or tool-specific labels/prefixes in GitHub
|
||||
content.
|
||||
- Resolve review threads after the underlying issue is fixed. If declining a
|
||||
suggestion, reply with a short evidence-based reason.
|
||||
|
||||
## Security Baseline
|
||||
|
||||
- Never commit secrets, credentials, or key material.
|
||||
@@ -243,12 +171,6 @@ Use `.agents/skills/rustfs-logging-governance/SKILL.md` for logging changes.
|
||||
- `DataUsageCacheInfo` and `DataUsageEntry` keep their hand-written map
|
||||
serialization and new fields remain `#[serde(default)]` for older readers.
|
||||
|
||||
## Naming
|
||||
|
||||
Use Rust API naming: `SCREAMING_SNAKE_CASE` constants/statics, `snake_case`
|
||||
functions/variables, and `PascalCase` types. Do not rename unrelated existing
|
||||
violations.
|
||||
|
||||
## Scoped Guidance
|
||||
|
||||
Before editing, locate the nearest instructions with:
|
||||
@@ -258,4 +180,4 @@ git ls-files '*AGENTS.md'
|
||||
```
|
||||
|
||||
The nearest file wins for domain invariants. Keep generic workflow and
|
||||
validation policy in this root file.
|
||||
validation policy in this root file and its task-specific references.
|
||||
|
||||
+3
-3
@@ -62,7 +62,7 @@ rustfs/ # Workspace root (virtual manifest)
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ ├── ... # (see "Crate Reference" below)
|
||||
│ └── e2e_test/ # End-to-end integration tests
|
||||
└── docs/ # Design documents and analysis
|
||||
└── docs/ # Agent knowledge base: contracts, runbooks, testing rules (index: docs/architecture/README.md)
|
||||
```
|
||||
|
||||
### Main Crate Layers (`rustfs/src/`)
|
||||
@@ -92,7 +92,7 @@ refactors.
|
||||
|
||||
| Domain | Current workspace crates | Responsibility |
|
||||
|--------|--------------------------|----------------|
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-contracts`, `utils` | Shared configuration, data-usage models, heal/scanner domain contracts, utilities, and checksums. |
|
||||
| Foundation | `checksums`, `common`, `config`, `data-usage`, `heal-contracts`, `scanner-metrics`, `utils` | Shared configuration, data-usage models, heal domain contracts, scanner telemetry types, utilities, and checksums. |
|
||||
| I/O and storage | `concurrency`, `ecstore`, `filemeta`, `heal`, `io-core`, `io-metrics`, `lifecycle`, `lock`, `object-capacity`, `object-data-cache`, `replication`, `rio`, `rio-v2`, `s3-client`, `scanner`, `storage-api` | Erasure-coded object storage, metadata, recovery, lifecycle, replication, locking, cache, I/O pipelines, and the engine-side S3 client for remote tier/transition targets. |
|
||||
| Security and identity | `credentials`, `crypto`, `iam`, `keystone`, `kms`, `policy`, `security-governance`, `signer`, `tls-runtime`, `trusted-proxies` | Credentials, authentication, authorization, encryption, key management, TLS, and security contracts. |
|
||||
| Protocols and contracts | `extension-schema`, `madmin`, `protos`, `protocols`, `s3-ops`, `s3-types`, `s3select-api`, `s3select-query` | Admin, inter-node, S3, S3 Select, and optional protocol contracts. |
|
||||
@@ -135,7 +135,7 @@ default build (lifecycle:
|
||||
`crates/ecstore/src/bucket/replication/replication_state.rs`) — a naming
|
||||
collision, not copies; renaming is tracked in rustfs/backlog#1847.
|
||||
- `LastMinuteLatency` has two deliberately different implementations: the
|
||||
per-second bucketed accumulator in `crates/scanner-contracts/src/last_minute.rs` and
|
||||
per-second bucketed accumulator in `crates/scanner-metrics/src/last_minute.rs` and
|
||||
the in-memory endpoint-health sample tracker in
|
||||
`crates/ecstore/src/bucket/bucket_target_sys.rs` (its doc comment explains
|
||||
why it stays local).
|
||||
|
||||
@@ -8,10 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Multipart admission queue**: an `UploadPart` waiting for a foreground write permit now waits at most 10 s by default (`RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`, previously 30 s), so a queued part returns S3 `SlowDown` before the client's socket write timeout drops the connection. Separately, the API listener no longer forces a 4 MiB `SO_RCVBUF` on every accepted socket (kernel autotuning applies; `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` restores a fixed size), so a queued part no longer lets up to 8 MiB of unread body accumulate in kernel memory per connection, which is what throttled whole nodes under SDK-default multipart concurrency. Fixes #7385.
|
||||
- **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
|
||||
- **On-Demand Migration**: Lazy, pull-style migration of an existing S3-compatible bucket into RustFS. A local bucket is attached to an external source bucket; a GET for a key that does not exist locally fetches it from the source, streams it to the client, and stores it locally in the same pass, so every later read is served locally. The module is on by default; set `RUSTFS_ON_DEMAND_MIGRATION_ENABLED=false` on every node to turn it off. A bucket with no source configured behaves exactly as before — the runtime never intervenes on its reads and makes no outbound call. Operator guide at `docs/operations/on-demand-migration.md`.
|
||||
- Per-bucket configuration persisted as `on-demand-migration.json` in the bucket metadata: source provider (`s3`, `aws`, `minio`, `rustfs`, `r2`, `gcs`), endpoint, region, addressing style, credentials and TLS material, an optional key-prefix filter and source-prefix rewrite, and a policy block covering the inline size threshold, multipart part size, concurrency, queue capacity, timeouts, bandwidth limit and negative-cache TTL
|
||||
- Admin routes under `/rustfs/admin/v3/on-demand-migration/{bucket}`: `PUT` (with `?dry-run=true` to validate and probe the source without saving), `GET`, `DELETE`, `GET .../status`, plus `POST .../backfill?op=start|cancel` and `GET .../backfill` for the background full-backfill job with its resumable checkpoint. Authorized by the new `admin:GetBucketOnDemandMigration` and `admin:SetBucketOnDemandMigration` actions; every response redacts `secret_key` and `session_token`
|
||||
- Read paths: an object at or below `policy.inline_max_bytes` (16 MiB by default) is teed to the client and to the local store in a single source read; a larger object or a Range read streams through and a background pull stores the whole object. A HEAD miss is proxied to the source and stores nothing (`policy.head = local_only` disables it). Every source-backed response carries `x-rustfs-on-demand-migration: source`
|
||||
- Protections: a per-source circuit breaker, a per-key negative cache, singleflight per key, a concurrency limit and a bounded pull queue shared by the inline and background paths, an optional bandwidth limit, an anti-loop request marker, and the shared outbound-endpoint (SSRF) policy
|
||||
- Metrics under `rustfs_on_demand_migration_*` (`requests_total`, `pulled_bytes_total`, `pulled_objects_total`, `pull_failures_total`, `inflight_pulls`, `queue_depth`, `source_latency_seconds_*`, `breaker_state`), mirrored per node by the admin status route
|
||||
- Listings: `ListObjects` v1 remains local with ordinary key markers. `ListObjectsV2` can merge source objects when `policy.list_through = true`; this is off by default
|
||||
- Upgrade and rollback: finish upgrading every node before enabling ODM. An rc.5 node that writes bucket configuration drops the ODM fields from metadata; neither a later restart nor moving the service out of ECStore recovers them. Before rollback, disable ODM and securely retain the original full configuration and credentials. After every node returns to a compatible version, restore and validate that configuration. Redacted exports cannot replace the credential backup; source-only objects are unavailable through RustFS while ODM is disabled. See the upgrade and rollback section of `docs/operations/on-demand-migration.md`
|
||||
- Optional Google dependencies: default and `full` server builds retain native GCS support. `cargo build -p rustfs --no-default-features --features ftps,webdav` excludes Google SDKs while preserving configuration decoding and redaction; native GCS ODM and tier operations require the `gcs` feature. Do not use that build with existing GCS-tiered data
|
||||
- Limitations: PUT and DELETE never reach the source; a source object updated after it was pulled is not re-fetched; SSE-C source objects are unsupported and answer 424; `Last-Modified` on a pulled object is the local write time, with the source timestamp kept in metadata
|
||||
- **NATS JetStream Publish Path**: Opt-in at-least-once delivery for the NATS notify and audit targets. A NATS Core publish flushes to the connection without awaiting a broker acknowledgement, so an event can be lost across a broker restart or a reconnect after the send queue has already cleared it. A queued event now clears only after the JetStream `PublishAck`, so bucket notifications survive those interruptions. Off by default and byte-identical to the NATS Core path when disabled.
|
||||
- Three configuration keys per target: `JETSTREAM_ENABLE`, `JETSTREAM_STREAM_NAME`, and `JETSTREAM_ACK_TIMEOUT_SECS`, under the `RUSTFS_NOTIFY_NATS_` and `RUSTFS_AUDIT_NATS_` prefixes
|
||||
- Durable store-and-forward with a stable dedup id sent as the `Nats-Msg-Id` header, so a replay after a crash is collapsed by the server duplicate window
|
||||
|
||||
@@ -13,7 +13,7 @@ what Claude Code needs on top: commands and pointers.
|
||||
cargo build --release --bin rustfs # production binary
|
||||
cargo check -p <crate> # fast type-check one crate
|
||||
cargo test -p <crate> # test one crate
|
||||
cargo fmt --all # format (required before PR)
|
||||
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
|
||||
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
|
||||
make pre-pr # optional full gate for broad cross-module changes
|
||||
make build-docker BUILD_OS=ubuntu22.04
|
||||
@@ -27,6 +27,7 @@ make build-docker BUILD_OS=ubuntu22.04
|
||||
|
||||
## Where to look (do not duplicate here)
|
||||
|
||||
- Agent knowledge base index and doc-writing rules: [docs/architecture/README.md](docs/architecture/README.md)
|
||||
- Crate membership: `Cargo.toml` `[workspace].members`
|
||||
- Architecture, layering, crate map: [ARCHITECTURE.md](ARCHITECTURE.md)
|
||||
- Migration guardrails & readiness contracts: [docs/architecture/](docs/architecture/README.md)
|
||||
@@ -41,5 +42,5 @@ make build-docker BUILD_OS=ubuntu22.04
|
||||
|
||||
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
|
||||
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
|
||||
"Cross-Cutting Domain Invariants" — read them before touching metadata or
|
||||
"Cross-Cutting Storage Invariants" — read them before touching metadata or
|
||||
tiering code.
|
||||
|
||||
+11
-37
@@ -109,24 +109,17 @@ affected boundaries and risks. CI still runs its configured repository gates.
|
||||
|
||||
### 🔒 Git Pre-commit Hooks (optional)
|
||||
|
||||
Git hooks are **not** versioned in this repository, so a fresh clone has no
|
||||
active pre-commit hook. If you add your own `.git/hooks/pre-commit` (a good
|
||||
choice is a one-liner that runs `make pre-commit`), you can mark it executable
|
||||
with:
|
||||
The optional hook uses the checked-in `.pre-commit-config.yaml`. Install [pre-commit](https://pre-commit.com/#installation), then run this from the checkout or a linked worktree:
|
||||
|
||||
```bash
|
||||
make setup-hooks
|
||||
```
|
||||
|
||||
Or manually:
|
||||
The hook runs `cargo fmt --all --check` when staged files include Rust source. It does not compile the workspace or run tests. Fix formatting with `cargo fmt --all`, inspect and stage the result, then commit again.
|
||||
|
||||
```bash
|
||||
chmod +x .git/hooks/pre-commit
|
||||
```
|
||||
`pre-commit install` resolves Git's hook directory for linked worktrees and preserves an existing hook in migration mode. If you use `core.hooksPath`, keep that hook manager and integrate `pre-commit run` there; the installer refuses to silently replace that configuration.
|
||||
|
||||
With or without a hook, follow the verification tiers in `AGENTS.md`. Run the
|
||||
applicable scoped checks, and reserve `make pre-pr` for broad cross-module
|
||||
changes whose impact cannot be bounded by those checks.
|
||||
A local hook provides early formatting feedback. With or without it, follow the verification tiers in `AGENTS.md`, run relevant behavioral tests, and satisfy the CI merge gates. `make pre-commit` and `make dev-check` remain explicit broader commands.
|
||||
|
||||
### 📝 Formatting Configuration
|
||||
|
||||
@@ -138,31 +131,11 @@ fn_call_width = 90
|
||||
single_line_let_else_max_width = 100
|
||||
```
|
||||
|
||||
### 🚫 Commit Prevention
|
||||
|
||||
If you set up a pre-commit hook and your code doesn't meet the formatting requirements, the hook will:
|
||||
|
||||
1. **Block the commit** and show clear error messages
|
||||
2. **Provide exact commands** to fix the issues
|
||||
3. **Guide you through** the resolution process
|
||||
|
||||
Example output when formatting fails:
|
||||
|
||||
```
|
||||
❌ Code formatting check failed!
|
||||
💡 Please run 'cargo fmt --all' to format your code before committing.
|
||||
|
||||
🔧 Quick fix:
|
||||
cargo fmt --all
|
||||
git add .
|
||||
git commit
|
||||
```
|
||||
|
||||
### 🔄 Development Workflow
|
||||
|
||||
1. **Make your changes**
|
||||
2. **Format your code**: `make fmt` or `cargo fmt --all`
|
||||
3. **Run the fast gate**: `make pre-commit` (no clippy, no tests)
|
||||
3. **Select relevant checks** using the validation tier in `AGENTS.md`; use `make pre-commit` when its broader fast gate adds useful coverage
|
||||
4. **Commit your changes**: `git commit -m "your message"`
|
||||
5. **Complete the applicable multi-role adversarial review** for non-exempt changes (see `AGENTS.md`)
|
||||
6. **Run applicable scoped checks before opening/updating a PR**; consider
|
||||
@@ -206,11 +179,12 @@ Configure your IDE to:
|
||||
#### Pre-commit hook not running?
|
||||
|
||||
```bash
|
||||
# Check if hook is executable
|
||||
ls -la .git/hooks/pre-commit
|
||||
|
||||
# Make it executable if needed
|
||||
chmod +x .git/hooks/pre-commit
|
||||
pre-commit validate-config
|
||||
pre-commit run --all-files
|
||||
# Inspect any configured hook manager; do not overwrite it.
|
||||
git config --get core.hooksPath
|
||||
# Install if no separate hook manager is configured.
|
||||
make setup-hooks
|
||||
```
|
||||
|
||||
#### Formatting issues?
|
||||
|
||||
Generated
+533
-276
File diff suppressed because it is too large
Load Diff
+35
-29
@@ -52,7 +52,7 @@ members = [
|
||||
"crates/s3select-api", # S3 Select API interface
|
||||
"crates/s3select-query", # S3 Select query engine
|
||||
"crates/scanner", # Scanner for data integrity checks and health monitoring
|
||||
"crates/scanner-contracts", # Scanner metrics and cycle contracts
|
||||
"crates/scanner-metrics", # Scanner metrics and cycle telemetry
|
||||
"crates/security-governance", # Security governance contracts
|
||||
"crates/extension-schema", # Extension schema contracts
|
||||
"crates/signer", # client signer
|
||||
@@ -93,7 +93,7 @@ redundant_clone = "warn"
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-rc.5" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-rc.5" }
|
||||
rustfs-heal-contracts = { path = "crates/heal-contracts", version = "1.0.0-rc.5" }
|
||||
rustfs-scanner-contracts = { path = "crates/scanner-contracts", version = "1.0.0-rc.5" }
|
||||
rustfs-scanner-metrics = { path = "crates/scanner-metrics", version = "1.0.0-rc.5" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-rc.5" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-rc.5" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-rc.5" }
|
||||
@@ -145,7 +145,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
|
||||
async-channel = "2.5.0"
|
||||
async_zip = { default-features = false, version = "0.0.19" }
|
||||
mysql_async = { default-features = false, version = "0.37.1" }
|
||||
async-compression = { version = "0.4.43" }
|
||||
async-compression = { version = "0.4.44" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.92"
|
||||
async-nats = { version = "0.50.0", default-features = false }
|
||||
@@ -165,10 +165,10 @@ http-body = "1.1.0"
|
||||
http-body-util = "0.1.5"
|
||||
minlz = "1.2.3"
|
||||
reqwest = "0.13.4"
|
||||
rustfs-kafka-async = { version = "1.2.0" }
|
||||
rustfs-kafka-async = { version = "1.3.1" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.53.1" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.4" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.5" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.5"
|
||||
tokio-util = { version = "0.7.19" }
|
||||
@@ -191,6 +191,7 @@ rmp = { version = "0.8.15" }
|
||||
rmp-serde = { version = "1.3.1" }
|
||||
serde = { version = "1.0.229" }
|
||||
serde_ignored = { version = "0.1" }
|
||||
serde_with = { version = "3", default-features = false, features = ["macros", "std"] }
|
||||
serde_json = { version = "1.0.151" }
|
||||
serde_urlencoded = "0.7.1"
|
||||
|
||||
@@ -199,10 +200,10 @@ serde_urlencoded = "0.7.1"
|
||||
# matching stable releases are not available yet, while previous stable lines
|
||||
# have incompatible APIs. Keep them exact-pinned and monitor upstream for stable
|
||||
# releases.
|
||||
aes-gcm = { version = "=0.11.1" }
|
||||
argon2 = { version = "=0.6.0" }
|
||||
blake2 = "=0.11.0"
|
||||
chacha20poly1305 = { version = "=0.11.0" }
|
||||
aes-gcm = { version = "0.11.1" }
|
||||
argon2 = { version = "0.6.0" }
|
||||
blake2 = "0.11.0"
|
||||
chacha20poly1305 = { version = "0.11.0" }
|
||||
crc-fast = "1.10.0"
|
||||
hmac = { version = "0.13.0" }
|
||||
jsonwebtoken = { version = "11.0.0" }
|
||||
@@ -234,28 +235,32 @@ tokio-postgres-rustls = "0.14.0"
|
||||
# Utilities and Tools
|
||||
anyhow = "1.0.104"
|
||||
arc-swap = "1.9.2"
|
||||
# RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin until every parser hardening used by Snowball is released upstream. Remove after astral-sh/tokio-tar#118 is merged and a published release includes extension, physical-entry, and sparse limits, cancellation-safe sparse parsing, and error-fused entry streams.
|
||||
# RUSTFS_COMPAT_TODO(tokio-tar-extension-limits): keep the fork pin while Snowball and Swift still depend on it. Remove after Snowball uses a released tar-codec/tar-framing API that exposes precedence-resolved MinIO vendor records, RustFS preserves cancellation-safe ownership of large streamed members, footerless minio-go input is accepted only at an authenticated complete request boundary, the existing resource-limit, cancellation, and error-fuse regressions pass, and Swift no longer needs this fork.
|
||||
astral-tokio-tar = { git = "https://github.com/cxymds/tokio-tar.git", rev = "603756478b7668436e464519c77ccac22a99ba96" }
|
||||
# Candidate Snowball parser versions exercised by rustfs-zip compatibility fixtures.
|
||||
tar-codec = "0.0.14"
|
||||
tar-framing = "0.0.14"
|
||||
atoi = "3.1.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.11.0" }
|
||||
aws-config = { version = "1.12.0" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.117.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.144.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.113.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.118.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.145.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.114.0" }
|
||||
aws-smithy-async = { version = "1.3.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
||||
aws-smithy-runtime-api = { version = "1.15.0" }
|
||||
aws-smithy-types = { version = "1.6.2" }
|
||||
aws-smithy-runtime-api = { version = "1.16.0" }
|
||||
aws-smithy-types = { version = "1.6.3" }
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "8.0.4"
|
||||
brotli = "9.0.0"
|
||||
clap = { version = "4.6.6" }
|
||||
const-str = { version = "1.1.0" }
|
||||
convert_case = "0.12.0"
|
||||
criterion = { version = "0.8" }
|
||||
crossbeam-queue = "0.3.13"
|
||||
crossbeam-channel = "0.5.16"
|
||||
crossbeam-deque = "0.8.7"
|
||||
crossbeam-utils = "0.8.22"
|
||||
crossbeam-queue = "0.3.14"
|
||||
crossbeam-channel = "0.5.17"
|
||||
crossbeam-deque = "0.8.8"
|
||||
crossbeam-utils = "0.8.23"
|
||||
datafusion = { default-features = false, version = "55.0.0" }
|
||||
derive_builder = "0.20.2"
|
||||
enumset = "1.1.14"
|
||||
@@ -298,20 +303,20 @@ pretty_assertions = "1.4.1"
|
||||
rand = { version = "0.10.2" }
|
||||
ratelimit = "2.0.0"
|
||||
rayon = "1.12.0"
|
||||
reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
|
||||
rustfs-erasure-codec = { version = "8.0.2" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.34.0" }
|
||||
redis = { version = "1.6.0" }
|
||||
redis = { version = "1.7.0" }
|
||||
rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.4" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "28e9ebb23dd2fb7d667084f34121b4aa4807a5c6", version = "0.15.0", features = ["minio"] }
|
||||
s3s = { git = "https://github.com/rustfs/s3s.git", rev = "bdcb6259339c41369f9f1c60e3a42b5ab8da607b", version = "0.15.0", features = ["minio"] }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.3"
|
||||
smallvec = { version = "1.15.2" }
|
||||
smallvec = { version = "1.16.0" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.3.0" }
|
||||
@@ -339,7 +344,7 @@ windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.18" }
|
||||
zip = "8.6.0"
|
||||
zstd = "0.13.3"
|
||||
zstd = "0.14.0"
|
||||
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
@@ -359,15 +364,16 @@ libunftp = { version = "0.23.0" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "11.0.0" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.63.1" }
|
||||
russh = { version = "0.63.2" }
|
||||
russh-sftp = "2.4.0"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.5.1" }
|
||||
hotpath = { version = "0.24.0", default-features = false }
|
||||
rustfs-mimalloc = { version = "0.5.3" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "=0.25.0", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
|
||||
|
||||
@@ -48,16 +48,33 @@ Unlike other storage systems, RustFS is released under the permissible Apache 2.
|
||||
- **Open Source**: Licensed under Apache 2.0, encouraging unrestricted community contributions and commercial usage.
|
||||
- **User-Friendly**: Designed with simplicity in mind for easy deployment and management.
|
||||
|
||||
| Feature | Status | Feature | Status |
|
||||
| :---------------------- | :----------- | :----------------------- | :--------------- |
|
||||
| **S3 Core Features** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
||||
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
||||
| **Versioning** | ✅ Available | **Bucket Replication** | ✅ Available |
|
||||
| **Logging** | ✅ Available | **Lifecycle Management** | 🚧 Under Testing |
|
||||
| **Event Notifications** | ✅ Available | **Distributed Mode** | 🚧 Under Testing |
|
||||
| **K8s Helm Charts** | ✅ Available | **RustFS KMS** | 🚧 Under Testing |
|
||||
| **Keystone Auth** | ✅ Available | **Multi-Tenancy** | ✅ Available |
|
||||
| **Swift API** | ✅ Available | **Swift Metadata Ops** | 🚧 Partial |
|
||||
Status legend: ✅ Available — shipped and covered by CI gates; 🧪 Preview — shipped behind an opt-in flag or with a bounded compatibility claim.
|
||||
|
||||
| Feature | Status | Feature | Status |
|
||||
| :------------------------------- | :----------- | :--------------------------------- | :----------- |
|
||||
| **S3 Core Features** | ✅ Available | **Distributed Mode** | ✅ Available |
|
||||
| **Upload / Download** | ✅ Available | **Single Node Mode** | ✅ Available |
|
||||
| **Versioning** | ✅ Available | **Bitrot Protection** | ✅ Available |
|
||||
| **Object Lock (WORM)** | ✅ Available | **Healing & Scanner** | ✅ Available |
|
||||
| **Server-Side Encryption** | ✅ Available | **Pool Expansion / Decommission** | ✅ Available |
|
||||
| **RustFS KMS** | ✅ Available | **Bucket Replication** | ✅ Available |
|
||||
| **Lifecycle Management (ILM)** | ✅ Available | **Site Replication** | ✅ Available |
|
||||
| **ILM Tiering (Remote S3)** | ✅ Available | **Bucket Quota** | ✅ Available |
|
||||
| **S3 Select** | ✅ Available | **Event Notifications** | ✅ Available |
|
||||
| **S3 Tables (Iceberg REST)** | 🧪 Preview | **Audit Logging** | ✅ Available |
|
||||
| **IAM / Policies** | ✅ Available | **Logging & Observability** | ✅ Available |
|
||||
| **OIDC / SSO** | ✅ Available | **Web Console** | ✅ Available |
|
||||
| **Keystone Auth** | ✅ Available | **K8s Helm Charts** | ✅ Available |
|
||||
| **Swift API** | ✅ Available | **FTPS / WebDAV** | ✅ Available |
|
||||
| **Multi-Tenancy** | ✅ Available | **SFTP** | ✅ Available |
|
||||
| **MinIO On-Disk Compatibility** | 🧪 Preview | | |
|
||||
|
||||
Notes:
|
||||
|
||||
- **RustFS KMS**: Vault (KV2 / Transit) and AWS KMS backends are supported for production. The `Local` and `Static` backends are for development and testing only. See [KMS backend security properties](docs/operations/kms-backend-security.md).
|
||||
- **Swift API / SFTP**: opt-in cargo features (`--features swift`, `--features sftp`, or `full`). FTPS and WebDAV are enabled in the default build.
|
||||
- **S3 Tables**: ships as an Iceberg REST Catalog with automated PyIceberg and DuckDB coverage; other engines and vendor profiles carry bounded claims listed in the [S3 Tables support matrix](docs/architecture/s3-tables-support-matrix.md).
|
||||
- **MinIO On-Disk Compatibility**: gated behind the `rio-v2` feature and not part of the default build. Objects MinIO encrypted are not readable by RustFS. See [MinIO file-format interoperability](docs/architecture/minio-file-format-compat.md).
|
||||
|
||||
## RustFS vs MinIO Performance
|
||||
|
||||
@@ -92,6 +109,15 @@ Star RustFS on GitHub and be instantly notified of new releases.
|
||||
|
||||
## Quickstart
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Pool expansion notice:**
|
||||
>
|
||||
> - A single-node single-drive (SNSD) deployment is supported only as a standalone local path. It cannot expand in place or be added as a Pool. To move to a multi-drive topology, create a new deployment and migrate data through S3.
|
||||
> - Keep an existing multi-drive Pool's endpoints and Erasure Set width unchanged; expand by appending a new Pool. With ellipsis-based expansion, every Pool argument must contain an ellipsis expression and expand to at least two drive endpoints.
|
||||
> - Single-node multi-drive Pools and multi-node Pools with one drive per node are allowed, subject to valid Erasure Set geometry and EC settings; acceptance does not guarantee host-failure tolerance.
|
||||
>
|
||||
> These topology rules follow MinIO, but automatic parity selection differs between the projects. See the [Pool layout compatibility and regression tests](docs/testing/pool-layout-compatibility.md) before expanding a deployment.
|
||||
|
||||
To get started with RustFS, follow these steps:
|
||||
|
||||
### 1. One-click Installation (Option 1)
|
||||
|
||||
@@ -89,6 +89,15 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
|
||||
|
||||
## 快速开始
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Pool 扩容 Notice:**
|
||||
>
|
||||
> - 单节点单盘(SNSD)部署仅支持使用本地路径独立运行,不支持原地扩容,也不能作为 Pool 加入集群。如需改为多盘拓扑,请创建新部署并通过 S3 迁移数据。
|
||||
> - 已有多盘 Pool 的端点和 Erasure Set 宽度应保持不变,扩容应追加新的 Pool。使用省略号表达式扩容时,每个 Pool 参数都必须包含省略号表达式,并展开为至少两个磁盘端点。
|
||||
> - 允许单节点多盘 Pool,也允许多节点、每节点一盘的 Pool,但必须满足 Erasure Set 布局和 EC 配置要求;配置合法不代表能够容忍整台主机故障。
|
||||
>
|
||||
> 这些拓扑规则与 MinIO 一致,但两者的默认 parity 选择方式存在差异。扩容前请阅读 [Pool 布局兼容性与回归测试说明](docs/testing/pool-layout-compatibility.md)。
|
||||
|
||||
请按照以下步骤快速上手 RustFS:
|
||||
|
||||
### 1. 一键安装脚本 (选项 1)
|
||||
|
||||
+5
-2
@@ -33,8 +33,11 @@ Applies to all paths under `crates/`.
|
||||
|
||||
## Type Casting
|
||||
|
||||
- Never use `as` for numeric conversions that may truncate or overflow. Use `try_into()` with explicit error handling, or clamp with `value.max(0) as usize` when the domain is bounded.
|
||||
- `f64 as usize` saturates but is fragile; clamp to `[0, usize::MAX as f64]` first.
|
||||
- Never use `as` for numeric conversions that may truncate or overflow. Use
|
||||
`try_into()` with typed error handling; clamp or saturate only when the domain
|
||||
explicitly requires it.
|
||||
- Before converting floating-point input to an integer, validate finiteness,
|
||||
sign, and the destination range. A lower-bound clamp alone is insufficient.
|
||||
- Treat every `as` cast in a PR review as a potential bug; require justification.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -422,9 +422,9 @@ fn unix_now_ms() -> u64 {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A repair the MRF consumer landed, fanned out so retry ledgers can drop
|
||||
/// entries the journal no longer tracks (backlog#1894 axis B). The payload
|
||||
/// mirrors the intent identity so consumers match without re-parsing.
|
||||
/// Legacy, unverified repair notice. Its identity lacks kind, set scope,
|
||||
/// bucket incarnation and responsibility generation. Consumers must not use
|
||||
/// it to discharge persisted repair responsibility.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MrfRepairedEvent {
|
||||
pub bucket: Arc<str>,
|
||||
@@ -439,8 +439,8 @@ const MRF_REPAIRED_EVENT_CAP: usize = 4096;
|
||||
|
||||
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
||||
|
||||
/// Record that the MRF consumer landed a repair. Never blocks: the critical
|
||||
/// section is a deque push under a std mutex.
|
||||
/// Record a legacy notification for compatibility. This is not an
|
||||
/// acknowledgement of storage verification or durable repair completion.
|
||||
pub fn note_mrf_repaired(bucket: &str, object: &str, version_id: Option<[u8; 16]>) {
|
||||
let registry = MRF_REPAIRED_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||
let Ok(mut events) = registry.lock() else {
|
||||
@@ -515,6 +515,9 @@ mod tests {
|
||||
}
|
||||
coalescer_release(&key, Some(lease));
|
||||
let retry_lease = coalescer_admit(key.clone()).expect("released identity must admit a retry");
|
||||
assert_ne!(lease, retry_lease);
|
||||
coalescer_release(&key, Some(lease));
|
||||
assert_eq!(coalescer_admit(key.clone()), Err(MrfIngressResult::Coalesced));
|
||||
coalescer_release(&key, Some(retry_lease));
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,11 @@ 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.
|
||||
|
||||
## S3 API environment variables
|
||||
|
||||
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
|
||||
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
|
||||
|
||||
## 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.
|
||||
@@ -130,6 +135,62 @@ Scanner cycle budget controls:
|
||||
- timeout returns S3 `SlowDown`, so clients should use normal SDK retry handling.
|
||||
- this is not a fdatasync or group-commit switch. Track fdatasync batching separately with `rustfs_s3_put_object_rename_fdatasync_batch_files`.
|
||||
|
||||
## Foreground write admission environment variables
|
||||
|
||||
Large direct `PutObject` requests and multipart `UploadPart` requests share one
|
||||
per-process permit pool that bounds how many bodies are ingested and written
|
||||
concurrently. Small direct PUTs stay on the legacy path.
|
||||
|
||||
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE`
|
||||
- enables the default-on pool; `false` keeps only the soft request counter.
|
||||
- default is `true`.
|
||||
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_LIMIT`
|
||||
- permits in the pool; `0` derives half of `RUSTFS_OBJECT_MAX_CONCURRENT_DISK_READS`, clamped to `32`.
|
||||
- default is `0` (32 permits at stock settings).
|
||||
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES`
|
||||
- smallest direct `PutObject` that takes a permit; unknown-size requests always do.
|
||||
- default is `33554432` (32 MiB).
|
||||
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||
- how long a direct `PutObject` waits for a permit before returning S3 `SlowDown`.
|
||||
- default is `250`.
|
||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES`
|
||||
- smallest `UploadPart` that takes a permit; `0` gates every part.
|
||||
- default is `0`.
|
||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||
- how long an `UploadPart` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
|
||||
- default is `10000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing on a full pool.
|
||||
- RustFS does not read the request body while a part is queued, so the client's socket write stalls for the whole wait and whatever timeout the client or an intermediary has configured competes with this value. Keep it with margin below the shortest such timeout in use (botocore applies its 60 s `connect_timeout` to the body write; the AWS SDK for Java v2 has a 30 s socket write timeout; reverse proxies add their own body timeouts); a wait that outlives the client timeout surfaces as a dropped connection instead of `SlowDown`.
|
||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING`
|
||||
- maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
|
||||
- default is `0`, which derives 16 times the permit limit (512 at stock settings).
|
||||
- each queued HTTP/1 part holds whatever unread body the client already pushed into the connection's kernel receive buffer (an HTTP/2 part holds up to its flow-control window in process memory), so this depth also bounds that memory. RustFS leaves the receive buffer to kernel autotuning (see `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES` below), which keeps an unread connection at the kernel's initial size (128 KiB on current Linux).
|
||||
- `RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE`, `RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT`, `RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||
- experimental strict gate that applies to every foreground write regardless of size and replaces the pool above when enabled.
|
||||
- default is disabled; enabling it with limit `0` disables foreground write admission entirely.
|
||||
|
||||
## HTTP listener socket environment variables
|
||||
|
||||
- `RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES`
|
||||
- fixed `SO_RCVBUF` for the API listener, inherited by every accepted socket; `0` leaves the receive buffer to kernel autotuning.
|
||||
- default is `0`. Earlier releases hard-coded 4 MiB, which Linux doubles to 8 MiB and which disables autotuning, so every connection whose body was not being read yet (a multipart part queued for a foreground write permit) could accumulate up to 8 MiB of unread body in kernel memory; at SDK-default multipart concurrency that was enough to push a node into TCP memory pressure.
|
||||
- with autotuning the per-connection receive ceiling is the kernel's (`net.ipv4.tcp_rmem` max, 6 MiB on stock Linux) instead of the former fixed 8 MiB, so a single very high-bandwidth-delay connection may see a somewhat lower ceiling; raise `net.ipv4.tcp_rmem` first, and set this variable only on kernels without receive-buffer autotuning (illumos/Solaris) or where the sysctl cannot be changed.
|
||||
- the send buffer stays fixed at 4 MiB because the stock Linux send autotuning ceiling (`net.ipv4.tcp_wmem` max, 4 MiB) is lower than a GB-level response stream needs.
|
||||
|
||||
## Remote tier timeout environment variables
|
||||
|
||||
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
|
||||
- remote tier TCP connect timeout.
|
||||
- default is `10`.
|
||||
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
|
||||
- `RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS`
|
||||
- remote tier request timeout through response headers.
|
||||
- default is `86400` so large transition uploads keep a production-safe budget.
|
||||
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default. Very large values are accepted and act as a correspondingly long budget.
|
||||
- `RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS`
|
||||
- maximum idle time between remote tier response-body chunks.
|
||||
- default is `60`; the timer resets only when non-empty body data keeps progressing.
|
||||
- must be positive; zero fails tier client initialization, while an invalid integer is logged and falls back to the default.
|
||||
|
||||
## Drive timeout environment variables
|
||||
|
||||
- `RUSTFS_DRIVE_METADATA_TIMEOUT_SECS`
|
||||
@@ -157,6 +218,14 @@ Drive timeout profile preset:
|
||||
- Then `RUSTFS_DRIVE_MAX_TIMEOUT_DURATION` legacy fallback.
|
||||
- Then the profile-derived default (`default` or `high_latency`).
|
||||
|
||||
## Admin peer probe timeout
|
||||
|
||||
- `RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS`
|
||||
- total per-peer budget for the `server_info`/`storage_info` admin probe round; `server_info` may reconnect once and `storage_info` remains a single attempt.
|
||||
- default is `10` seconds, preserving the previous two-attempt worst-case budget.
|
||||
- values must be positive; `0` or an invalid value falls back to the default, and values above `60` are clamped to `60`.
|
||||
- the setting is read by the aggregating node only; it does not change the internode RPC wire contract. Any retry shares one round deadline rather than receiving a fresh timeout.
|
||||
|
||||
## Startup filesystem boundary policy
|
||||
|
||||
- `RUSTFS_UNSUPPORTED_FS_POLICY` controls startup behavior when RustFS detects local endpoint filesystems that are outside the supported production boundary.
|
||||
|
||||
@@ -90,3 +90,15 @@ 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;
|
||||
|
||||
/// Maximum retained versions per object.
|
||||
///
|
||||
/// The default follows MinIO and is effectively unlimited for practical
|
||||
/// deployments. Operators can lower it to bound per-object metadata growth.
|
||||
/// Environment variable: RUSTFS_API_OBJECT_MAX_VERSIONS
|
||||
/// MinIO-compatible alias: MINIO_API_OBJECT_MAX_VERSIONS
|
||||
/// Example: RUSTFS_API_OBJECT_MAX_VERSIONS=50000
|
||||
pub const ENV_API_OBJECT_MAX_VERSIONS: &str = "RUSTFS_API_OBJECT_MAX_VERSIONS";
|
||||
|
||||
/// Default for `RUSTFS_API_OBJECT_MAX_VERSIONS`.
|
||||
pub const DEFAULT_API_OBJECT_MAX_VERSIONS: u64 = 9_223_372_036_854_775_807;
|
||||
|
||||
@@ -39,6 +39,15 @@ pub const DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS: u64 = 20;
|
||||
pub const ENV_INTERNODE_RPC_TIMEOUT_SECS: &str = "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS";
|
||||
pub const DEFAULT_INTERNODE_RPC_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Total budget for one admin peer probe round, including any reconnect retry.
|
||||
///
|
||||
/// This is intentionally separate from the transport-level RPC timeout: admin
|
||||
/// probes may retry once, but the retry must consume the same round budget.
|
||||
pub const ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS: &str = "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS";
|
||||
pub const DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 10;
|
||||
pub const MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS: u64 = 60;
|
||||
const _: () = assert!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS <= MAX_ADMIN_PEER_PROBE_TIMEOUT_SECS);
|
||||
|
||||
// ── Client-side internode gRPC channel tuning (P0) ──
|
||||
// These mirror the server-side HTTP/2 transport tuning in `rustfs/src/server/http.rs`
|
||||
// on the *client* `tonic` `Endpoint` used for internode control-plane RPCs. Prior to
|
||||
@@ -312,6 +321,7 @@ mod tests {
|
||||
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_INTERVAL_SECS, 5);
|
||||
assert_eq!(DEFAULT_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS, 20);
|
||||
assert_eq!(DEFAULT_INTERNODE_RPC_TIMEOUT_SECS, 30);
|
||||
assert_eq!(DEFAULT_ADMIN_PEER_PROBE_TIMEOUT_SECS, 10);
|
||||
assert_eq!(DEFAULT_INTERNODE_HTTP_TUNING_PROFILE, "legacy");
|
||||
}
|
||||
|
||||
@@ -412,6 +422,7 @@ mod tests {
|
||||
"RUSTFS_INTERNODE_HTTP2_KEEPALIVE_TIMEOUT_SECS"
|
||||
);
|
||||
assert_eq!(ENV_INTERNODE_RPC_TIMEOUT_SECS, "RUSTFS_INTERNODE_RPC_TIMEOUT_SECS");
|
||||
assert_eq!(ENV_ADMIN_PEER_PROBE_TIMEOUT_SECS, "RUSTFS_ADMIN_PEER_PROBE_TIMEOUT_SECS");
|
||||
assert_eq!(ENV_INTERNODE_HTTP_TUNING_PROFILE, "RUSTFS_INTERNODE_HTTP_TUNING_PROFILE");
|
||||
assert_eq!(ENV_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST, "RUSTFS_INTERNODE_HTTP_POOL_MAX_IDLE_PER_HOST");
|
||||
assert_eq!(ENV_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS, "RUSTFS_INTERNODE_HTTP_POOL_IDLE_TIMEOUT_SECS");
|
||||
|
||||
@@ -137,6 +137,28 @@ pub const DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED: bool = false;
|
||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_WRITE);
|
||||
const _: () = assert!(!DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED);
|
||||
|
||||
/// Environment variable for remote tier TCP connect timeout in seconds.
|
||||
pub const ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS";
|
||||
/// Default remote tier TCP connect timeout in seconds.
|
||||
pub const DEFAULT_TIER_REMOTE_CONNECT_TIMEOUT_SECS: u64 = 10;
|
||||
|
||||
/// Environment variable for the remote tier request timeout in seconds.
|
||||
///
|
||||
/// This bounds upload/download request progress through response headers. The
|
||||
/// default is intentionally large so multi-TiB transition uploads keep their
|
||||
/// previous production budget while black-hole remotes no longer wait forever.
|
||||
pub const ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS";
|
||||
/// Default remote tier request timeout in seconds.
|
||||
pub const DEFAULT_TIER_REMOTE_REQUEST_TIMEOUT_SECS: u64 = 24 * 60 * 60;
|
||||
|
||||
/// Environment variable for remote tier response-body idle timeout in seconds.
|
||||
///
|
||||
/// The timer is re-armed on every non-empty response-body chunk, so slow but
|
||||
/// progressing remotes can continue while silent response bodies are cancelled.
|
||||
pub const ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: &str = "RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS";
|
||||
/// Default remote tier response-body idle timeout in seconds.
|
||||
pub const DEFAULT_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS: u64 = 60;
|
||||
|
||||
/// Request the object-transaction fencing contract used by storage-owned
|
||||
/// cleanup receipts and lock-window optimizations.
|
||||
///
|
||||
@@ -197,6 +219,27 @@ pub const DEFAULT_POOL_META_V3_FLEET_CONFIRMED: bool = false;
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V3_WRITE);
|
||||
const _: () = assert!(!DEFAULT_POOL_META_V3_FLEET_CONFIRMED);
|
||||
|
||||
/// Maximum unpacked size accepted for one Snowball archive member.
|
||||
///
|
||||
/// The value is expressed in bytes. Invalid values use the default, while
|
||||
/// valid values are clamped to [`MAX_SNOWBALL_ENTRY_BYTES`].
|
||||
pub const ENV_SNOWBALL_MAX_ENTRY_BYTES: &str = "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES";
|
||||
pub const DEFAULT_SNOWBALL_MAX_ENTRY_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
pub const MAX_SNOWBALL_ENTRY_BYTES: u64 = 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
|
||||
|
||||
/// Maximum cumulative unpacked object bytes accepted from one Snowball
|
||||
/// archive request.
|
||||
///
|
||||
/// This does not include tar headers or bounded PAX metadata. The value is
|
||||
/// expressed in bytes and is clamped to
|
||||
/// [`MAX_SNOWBALL_UNPACKED_BYTES`].
|
||||
pub const ENV_SNOWBALL_MAX_UNPACKED_BYTES: &str = "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES";
|
||||
pub const DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES: u64 = 10 * 1024 * 1024 * 1024;
|
||||
pub const MAX_SNOWBALL_UNPACKED_BYTES: u64 = 10 * 1024 * DEFAULT_SNOWBALL_MAX_ENTRY_BYTES;
|
||||
|
||||
const _: () = assert!(DEFAULT_SNOWBALL_MAX_ENTRY_BYTES <= MAX_SNOWBALL_ENTRY_BYTES);
|
||||
const _: () = assert!(DEFAULT_SNOWBALL_MAX_UNPACKED_BYTES <= MAX_SNOWBALL_UNPACKED_BYTES);
|
||||
|
||||
// =============================================================================
|
||||
// Concurrent Request Fix - Timeout and Backpressure Configuration
|
||||
// =============================================================================
|
||||
@@ -322,13 +365,54 @@ pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: &str =
|
||||
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 0;
|
||||
|
||||
/// Time in milliseconds an automatic foreground write waits for a permit.
|
||||
/// Time in milliseconds an automatic foreground direct PutObject waits for a permit.
|
||||
///
|
||||
/// A short wait smooths transient bursts while still returning S3
|
||||
/// `SlowDown`/503 before body ingest when the node is already saturated.
|
||||
pub const ENV_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str = "RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
||||
pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
|
||||
|
||||
/// Time in milliseconds a multipart UploadPart waits for a foreground write permit.
|
||||
///
|
||||
/// SDK-default multipart clients send every part of an upload concurrently, so
|
||||
/// a single node routinely sees several times more parts in flight than the
|
||||
/// permit pool allows. A queued part waits before body ingest, so the pool
|
||||
/// still bounds the number of parts being written, but the wait is not free:
|
||||
/// RustFS does not read the request body while the part is queued (hyper only
|
||||
/// sends `100 Continue` once the body is first polled, and the AWS SDKs send
|
||||
/// the body after a 1-3 s `Expect: 100-continue` grace anyway), so the
|
||||
/// client's socket write stalls once the kernel buffers fill, and whatever
|
||||
/// timeout the client or an intermediary has configured decides the outcome.
|
||||
/// botocore applies its `connect_timeout` (60 s) to the body write, the AWS
|
||||
/// SDK for Java v2 has a 30 s socket write timeout, and MinIO bounds the same
|
||||
/// wait with a 10 s request deadline. The wait must leave margin under the
|
||||
/// shortest of those, not merely fall below an SDK default, so the part
|
||||
/// receives S3 `SlowDown`/503 for the client to retry instead of losing its
|
||||
/// connection (issue #7385). `0` rejects immediately when the pool is full.
|
||||
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str =
|
||||
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 10_000;
|
||||
|
||||
// A queued part holds the client's body write open for the whole wait. The
|
||||
// shortest write timeout among mainstream S3 SDKs is the AWS SDK for Java v2's
|
||||
// 30 s socket write timeout; keep the compiled default at no more than a third
|
||||
// of it. This locks only the default; the environment variable may still raise
|
||||
// the wait past any client timeout.
|
||||
const _: () = assert!(DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS * 3 <= 30_000);
|
||||
|
||||
/// Maximum multipart UploadPart requests waiting for a foreground write permit per process.
|
||||
///
|
||||
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
|
||||
/// waiting, so a genuinely saturated node still fails fast instead of holding
|
||||
/// an unbounded set of connections open for the whole wait timeout. Each
|
||||
/// queued HTTP/1 part also holds whatever unread body the client already
|
||||
/// pushed into that connection's kernel receive buffer, and a queued HTTP/2
|
||||
/// part holds up to its flow-control window in process memory, so the depth
|
||||
/// bounds socket and window memory as well as connections.
|
||||
/// `0` derives the depth from the permit limit.
|
||||
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: &str = "RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING";
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: usize = 0;
|
||||
|
||||
const _: () = assert!(DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE);
|
||||
|
||||
/// Environment variable for minimum GetObject timeout in seconds.
|
||||
@@ -791,6 +875,16 @@ mod remote_version_state_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_tier_timeout_env_names_are_stable() {
|
||||
assert_eq!(super::ENV_TIER_REMOTE_CONNECT_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS");
|
||||
assert_eq!(super::ENV_TIER_REMOTE_REQUEST_TIMEOUT_SECS, "RUSTFS_TIER_REMOTE_REQUEST_TIMEOUT_SECS");
|
||||
assert_eq!(
|
||||
super::ENV_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS,
|
||||
"RUSTFS_TIER_REMOTE_RESPONSE_BODY_IDLE_TIMEOUT_SECS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_movement_part_checksum_gate_uses_stable_environment_names() {
|
||||
assert_eq!(super::ENV_DATA_MOVEMENT_PART_CHECKSUMS_WRITE, "RUSTFS_DATA_MOVEMENT_PART_CHECKSUMS_WRITE");
|
||||
@@ -820,4 +914,10 @@ mod remote_version_state_tests {
|
||||
assert_eq!(super::ENV_POOL_META_V3_WRITE, "RUSTFS_POOL_META_V3_WRITE");
|
||||
assert_eq!(super::ENV_POOL_META_V3_FLEET_CONFIRMED, "RUSTFS_POOL_META_V3_FLEET_CONFIRMED");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snowball_limit_environment_names_are_stable() {
|
||||
assert_eq!(super::ENV_SNOWBALL_MAX_ENTRY_BYTES, "RUSTFS_SNOWBALL_MAX_ENTRY_BYTES");
|
||||
assert_eq!(super::ENV_SNOWBALL_MAX_UNPACKED_BYTES, "RUSTFS_SNOWBALL_MAX_UNPACKED_BYTES");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,6 +159,24 @@ pub const DEFAULT_HTTP1_HEADER_READ_TIMEOUT: u64 = 75;
|
||||
pub const ENV_HTTP1_MAX_BUF_SIZE: &str = "RUSTFS_HTTP1_MAX_BUF_SIZE";
|
||||
pub const DEFAULT_HTTP1_MAX_BUF_SIZE: usize = 64 * 1024; // 64 KB
|
||||
|
||||
/// Environment variable for a fixed kernel receive buffer (`SO_RCVBUF`, bytes)
|
||||
/// on the API listener. Default: 0, which leaves the buffer to kernel
|
||||
/// autotuning.
|
||||
///
|
||||
/// A fixed `SO_RCVBUF` is inherited by every accepted socket and disables
|
||||
/// receive-buffer autotuning, so a connection whose request body is not being
|
||||
/// read yet (a multipart part queued for a foreground write permit) lets up to
|
||||
/// the fixed size of unread body accumulate in kernel memory — Linux doubles
|
||||
/// the requested value, so the former hard-coded 4 MiB held up to 8 MiB per
|
||||
/// queued connection (issue #7385). Autotuning keeps an unread connection at
|
||||
/// the kernel's initial size and grows only connections that are being
|
||||
/// drained. Set this only on kernels without receive-buffer autotuning
|
||||
/// (illumos/Solaris) or on very high-bandwidth-delay links where the kernel's
|
||||
/// autotuning ceiling (`net.ipv4.tcp_rmem` on Linux) is too low and cannot be
|
||||
/// raised.
|
||||
pub const ENV_HTTP_SOCKET_RECV_BUFFER_BYTES: &str = "RUSTFS_HTTP_SOCKET_RECV_BUFFER_BYTES";
|
||||
pub const DEFAULT_HTTP_SOCKET_RECV_BUFFER_BYTES: usize = 0;
|
||||
|
||||
/// Environment variable for the S3 request-body inter-chunk read timeout
|
||||
/// (seconds). Default: 300. Set to 0 to disable.
|
||||
///
|
||||
|
||||
@@ -25,7 +25,8 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
||||
| **policy** | [`src/policy/`](src/policy), `existing_object_tag_policy_test`, `bucket_policy_check_test`, `anonymous_access_test`, `security_boundary_test`, `multipart_auth_test` | IAM / bucket-policy / STS session policy, policy variables, anonymous access, DoS/SSRF boundaries. Own guide: [`src/policy/README.md`](src/policy/README.md) |
|
||||
| **protocols** | [`src/protocols/`](src/protocols) | FTPS, WebDAV, SFTP compliance. Fixed ports, own guide: [`src/protocols/README.md`](src/protocols/README.md) |
|
||||
| **reliant** | [`src/reliant/`](src/reliant) | Tests that reuse an **externally started** server (SQL/select, conditional writes, lifecycle, deleted-object reads, node-interact). Run via [`scripts/run_e2e_tests.sh`](../../scripts/run_e2e_tests.sh); see [`src/reliant/README.md`](src/reliant/README.md) |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **cluster** | `cluster_concurrency_test`, `stale_multipart_cleanup_cluster_test`, `namespace_lock_quorum_test`, `admin_timeout_regression_test`, `object_lambda_test`, `replication_extension_test`, `tier_stats_cluster_test` | Multi-node scenarios via `RustFSTestClusterEnvironment` |
|
||||
| **distributed 4×4** | [`src/distributed/`](src/distributed) | Storage-sensitive PR and nightly `e2e-distributed` lane: S3, object lock/WORM, versioning, bucket/site replication, quota, expand/decommission/rebalance, concurrency, chaos, 4-node upgrade of historical data and IAM AK/SK. Map: [`docs/testing/distributed-e2e.md`](../../docs/testing/distributed-e2e.md) |
|
||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||
|
||||
@@ -171,6 +172,7 @@ the same profile for membership and execution with one nightly worker.
|
||||
| KMS suite | `e2e-full` job, merge queue + main | **Active** |
|
||||
| Direct and mixed-version rolling upgrades from pinned previous release | `e2e-upgrade.yml`, storage-sensitive PRs + release tags + weekly | **Active** |
|
||||
| Cluster faults (`e2e-nightly` profile) | consolidated nightly workflow | **Active** (backlog#1149 ci-7) |
|
||||
| Distributed 4-node 4-disk (`e2e-distributed` profile) | `.github/workflows/e2e-distributed.yml` | **Active** (storage-sensitive PR / nightly / dispatch) |
|
||||
| Protocols (FTPS/WebDAV/SFTP) | consolidated nightly workflow, serial | **Active** (backlog#1149 ci-7) |
|
||||
| Replication (fast subset) | `e2e-smoke` profile, `e2e-tests` job, every PR | **Active** (backlog#1147 repl-1) |
|
||||
| Replication (slow + multi-node) | `e2e-repl-nightly` profile, consolidated nightly workflow | **Active** (backlog#1147 repl-1) |
|
||||
@@ -182,6 +184,8 @@ the wiring source of truth. Committed test-ID digests under
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Endpoint blackhole scenario skipped** — `heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_after_target_endpoint_blackhole` installs a loopback `iptables` DROP rule and therefore needs `CAP_NET_ADMIN` (root or passwordless `sudo -n iptables`). A host where `iptables` is missing or cannot read the OUTPUT chain (typical inside an unprivileged container, where the nf_tables backend reports "Permission denied" even under `sudo`) logs a `heal_interruption_skipped` warning and returns without exercising heal. Set `RUSTFS_E2E_REQUIRE_NET_FAULT_INJECTION=1` on lanes that do provision the capability so a broken runner fails instead of skipping.
|
||||
|
||||
**Reproduce a CI failure locally** — run the exact profile/lane:
|
||||
|
||||
```bash
|
||||
@@ -191,6 +195,9 @@ cargo nextest run --profile e2e-smoke -p e2e_test
|
||||
cargo nextest run --profile e2e-full -p e2e_test
|
||||
# Cluster fault nightly lane
|
||||
cargo nextest run --profile e2e-nightly -p e2e_test
|
||||
# 4-node 4-disk distributed lane (S3 / lock / versioning / replication / decommission / chaos / upgrade)
|
||||
# Upgrade cases need RUSTFS_UPGRADE_SOURCE_BINARY; without it they fail closed.
|
||||
cargo nextest run --profile e2e-distributed -p e2e_test
|
||||
# Replication nightly lane; awscurl is required for STS paths
|
||||
cargo nextest run --profile e2e-repl-nightly -p e2e_test
|
||||
# Fixed-port protocol nightly lane
|
||||
@@ -233,8 +240,8 @@ spawn error. Install the pinned CI version before running their profiles.
|
||||
[`src/policy/README.md`](src/policy/README.md),
|
||||
[`src/protocols/README.md`](src/protocols/README.md),
|
||||
[`src/reliant/README.md`](src/reliant/README.md)
|
||||
- Authoritative per-module counts:
|
||||
[`docs/testing/e2e-suite-inventory.md`](../../docs/testing/e2e-suite-inventory.md)
|
||||
- Per-module counts: `cargo nextest list -p e2e_test --profile <profile>`
|
||||
(one-liner in [`docs/testing/README.md`](../../docs/testing/README.md))
|
||||
- Test pyramid & flake policy: [`docs/testing/README.md`](../../docs/testing/README.md)
|
||||
|
||||
## CI smoke subset (`--profile e2e-smoke`)
|
||||
@@ -271,12 +278,12 @@ Note on `#[serial]`: nextest runs each test in its own process, so
|
||||
parallel-safe by construction (random port + isolated temp dir), which the
|
||||
current subset is.
|
||||
|
||||
### Authoritative test inventory
|
||||
### Test inventory
|
||||
|
||||
`docs/testing/e2e-suite-inventory.md` records the per-module test counts as
|
||||
listed by `cargo nextest list -p e2e_test`. Regenerate it when adding or
|
||||
moving e2e tests so acceptance numbers in the test-strategy issues
|
||||
(backlog#1147–#1155) stay auditable. When a profile membership change is
|
||||
Per-module counts are not committed; list them with
|
||||
`cargo nextest list -p e2e_test --profile <profile>` (the result is
|
||||
platform-dependent because some modules are linux-only; the `jq` one-liner is
|
||||
in `docs/testing/README.md`). When a profile membership change is
|
||||
intentional, review its JSON listing before updating the matching
|
||||
`.config/e2e-*-selection.txt` test-ID digest. Update only the platform that
|
||||
produced the listing:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
// Licensed under the Apache License, Version 2.0.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("git").args(args).current_dir(root).output().ok()?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
fn emit(name: &str, value: &str) {
|
||||
let value = if value.contains(['\n', '\r']) { "unknown" } else { value };
|
||||
println!("cargo:rustc-env=RUSTFS_E2E_BUILD_{name}={value}");
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let manifest = std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default();
|
||||
let root = Path::new(&manifest).join("../..");
|
||||
// Cover dependency/common sources as well as this crate. HEAD/ref/index
|
||||
// changes must refresh identity even when no Rust source mtime changes.
|
||||
for path in [
|
||||
"crates",
|
||||
"rustfs",
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
"rust-toolchain.toml",
|
||||
".cargo",
|
||||
".config",
|
||||
] {
|
||||
println!("cargo:rerun-if-changed={}", root.join(path).display());
|
||||
}
|
||||
let mut git_paths = vec!["HEAD".to_owned(), "index".to_owned(), "packed-refs".to_owned()];
|
||||
if let Some(reference) = git(&root, &["symbolic-ref", "-q", "HEAD"]) {
|
||||
git_paths.push(reference);
|
||||
}
|
||||
for path in git_paths {
|
||||
if let Some(path) = git(&root, &["rev-parse", "--git-path", &path]) {
|
||||
let path = Path::new(&path);
|
||||
let path = if path.is_absolute() {
|
||||
path.to_owned()
|
||||
} else {
|
||||
root.join(path)
|
||||
};
|
||||
if path.exists() {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
|
||||
let dirty = git(&root, &["status", "--porcelain", "--untracked-files=normal"]).is_none_or(|status| !status.is_empty());
|
||||
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
|
||||
let mut features = std::env::vars()
|
||||
.filter_map(|(key, _)| {
|
||||
key.strip_prefix("CARGO_FEATURE_")
|
||||
.map(|name| name.to_ascii_lowercase().replace('_', "-"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
features.sort();
|
||||
emit("COMMIT", &revision);
|
||||
emit("DIRTY", if dirty { "true" } else { "false" });
|
||||
emit("LOCK", &lock);
|
||||
emit("FEATURES", &features.join(","));
|
||||
for name in ["TARGET", "PROFILE"] {
|
||||
emit(name, &std::env::var(name).unwrap_or_else(|_| "unknown".to_owned()));
|
||||
}
|
||||
println!("cargo:rerun-if-env-changed=CARGO_ENCODED_RUSTFLAGS");
|
||||
let flags = std::env::var("CARGO_ENCODED_RUSTFLAGS").unwrap_or_default();
|
||||
let flags: String = flags.as_bytes().iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
emit("RUSTFLAGS_HEX", &flags);
|
||||
}
|
||||
@@ -55,18 +55,20 @@ type ChaosResult<T> = Result<T, Box<dyn Error + Send + Sync>>;
|
||||
/// A successful S3 GET only proves that a quorum can serve an object. Replacement
|
||||
/// tests need this lower-level record to prove that the rebuilt target holds the
|
||||
/// `xl.meta` selected for a specific version and every `part.N` it declares.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
pub(crate) struct VersionShardCensus {
|
||||
pub version_id: Option<String>,
|
||||
pub has_xl_meta: bool,
|
||||
pub data_dir: Option<String>,
|
||||
pub erasure_index: Option<usize>,
|
||||
pub data_blocks: Option<usize>,
|
||||
pub parity_blocks: Option<usize>,
|
||||
pub expected_part_numbers: BTreeSet<usize>,
|
||||
pub present_part_fingerprints: BTreeMap<usize, PartShardFingerprint>,
|
||||
pub inline_data_fingerprint: Option<PartShardFingerprint>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
|
||||
pub(crate) struct PartShardFingerprint {
|
||||
pub size: u64,
|
||||
pub sha256: String,
|
||||
@@ -88,13 +90,15 @@ impl VersionShardCensus {
|
||||
&& manifest.is_complete()
|
||||
&& self.data_dir == manifest.data_dir
|
||||
&& self.erasure_index == manifest.erasure_index
|
||||
&& self.data_blocks == manifest.data_blocks
|
||||
&& self.parity_blocks == manifest.parity_blocks
|
||||
&& self.expected_part_numbers == manifest.expected_part_numbers
|
||||
&& self.present_part_fingerprints == manifest.present_part_fingerprints
|
||||
&& self.inline_data_fingerprint == manifest.inline_data_fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(data: &[u8]) -> String {
|
||||
pub(crate) fn sha256_hex(data: &[u8]) -> String {
|
||||
let digest = Sha256::digest(data);
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
@@ -313,6 +317,8 @@ pub(crate) fn census_object_version_on_disk(
|
||||
has_xl_meta: false,
|
||||
data_dir: None,
|
||||
erasure_index: None,
|
||||
data_blocks: None,
|
||||
parity_blocks: None,
|
||||
expected_part_numbers: BTreeSet::new(),
|
||||
present_part_fingerprints: BTreeMap::new(),
|
||||
inline_data_fingerprint: None,
|
||||
@@ -360,6 +366,8 @@ pub(crate) fn census_object_version_on_disk(
|
||||
has_xl_meta: true,
|
||||
data_dir,
|
||||
erasure_index,
|
||||
data_blocks: Some(file_info.erasure.data_blocks),
|
||||
parity_blocks: Some(file_info.erasure.parity_blocks),
|
||||
expected_part_numbers,
|
||||
present_part_fingerprints,
|
||||
inline_data_fingerprint,
|
||||
@@ -413,6 +421,8 @@ mod tests {
|
||||
has_xl_meta: true,
|
||||
data_dir: Some("data-dir".to_string()),
|
||||
erasure_index: Some(3),
|
||||
data_blocks: Some(2),
|
||||
parity_blocks: Some(2),
|
||||
expected_part_numbers: BTreeSet::from([1]),
|
||||
present_part_fingerprints: BTreeMap::from([(1, shard_fingerprint(b"part").unwrap())]),
|
||||
inline_data_fingerprint: None,
|
||||
|
||||
@@ -22,7 +22,10 @@ mod tests {
|
||||
use aws_sdk_s3::config::{Credentials, Region, RequestChecksumCalculation};
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{ChecksumAlgorithm, ChecksumMode, CompletedMultipartUpload, CompletedPart};
|
||||
use aws_sdk_s3::types::{
|
||||
ChecksumAlgorithm, ChecksumMode, ChecksumType as SdkChecksumType, CompletedMultipartUpload, CompletedPart,
|
||||
ServerSideEncryption,
|
||||
};
|
||||
use aws_smithy_http_client::Builder as SmithyHttpClientBuilder;
|
||||
use md5::{Digest as Md5Digest, Md5};
|
||||
use rustfs_rio::{Checksum, ChecksumType as RioChecksumType};
|
||||
@@ -260,6 +263,117 @@ mod tests {
|
||||
info!("PASSED: HeadObject returns stored SHA256 digest");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_head_object_returns_sse_s3_checksum() {
|
||||
init_logging();
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_SSE_S3_MASTER_KEY", "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI="),
|
||||
("RUSTFS_CONSOLE_ENABLE", "false"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-sse-s3-checksum-head";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
let put = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key("encrypted.txt")
|
||||
.body(ByteStream::from_static(b"encrypted checksum"))
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 PutObject with CRC32 failed");
|
||||
let expected = put.checksum_crc32().expect("PutObject must return CRC32");
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("encrypted.txt")
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 HeadObject failed");
|
||||
|
||||
assert_eq!(head.checksum_crc32(), Some(expected));
|
||||
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key("encrypted-copy.txt")
|
||||
.copy_source(format!("{bucket}/encrypted.txt"))
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 CopyObject failed");
|
||||
let copy_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key("encrypted-copy.txt")
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 copied HeadObject failed");
|
||||
|
||||
assert_eq!(copy_head.checksum_crc32(), Some(expected));
|
||||
|
||||
let multipart_key = "encrypted-multipart.txt";
|
||||
let create = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(multipart_key)
|
||||
.server_side_encryption(ServerSideEncryption::Aes256)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 CreateMultipartUpload with CRC32 failed");
|
||||
let upload_id = create.upload_id().expect("CreateMultipartUpload must return an upload ID");
|
||||
let part = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from_static(b"encrypted multipart checksum"))
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 UploadPart with CRC32 failed");
|
||||
let completed_part = CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(part.e_tag().expect("UploadPart must return an ETag"))
|
||||
.checksum_crc32(part.checksum_crc32().expect("UploadPart must return CRC32"))
|
||||
.build();
|
||||
let complete = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().parts(completed_part).build())
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 CompleteMultipartUpload with CRC32 failed");
|
||||
let expected_multipart = complete.checksum_crc32().expect("CompleteMultipartUpload must return CRC32");
|
||||
let multipart_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(multipart_key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("SSE-S3 multipart HeadObject failed");
|
||||
|
||||
assert_eq!(multipart_head.checksum_crc32(), Some(expected_multipart));
|
||||
}
|
||||
|
||||
/// Multipart upload with checksum: CreateMultipartUpload, UploadPart(s) with checksum_sha256, CompleteMultipartUpload; then GetObject verifies content.
|
||||
/// Uses part size >= 5MB (server minimum) for two parts.
|
||||
#[tokio::test]
|
||||
@@ -485,6 +599,222 @@ mod tests {
|
||||
Some(full_checksum.as_str()),
|
||||
"Multipart object should report the same full-object CRC64NVME as direct upload"
|
||||
);
|
||||
assert_eq!(
|
||||
multipart_head.checksum_type(),
|
||||
Some(&SdkChecksumType::FullObject),
|
||||
"Multipart object with a full-object checksum must report FULL_OBJECT"
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a CRC32 FULL_OBJECT multipart upload and upload every part, returning
|
||||
/// the upload id and the `CompletedPart` list ready for CompleteMultipartUpload.
|
||||
async fn start_full_object_crc32_upload(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
parts: &[&Vec<u8>],
|
||||
) -> (String, Vec<CompletedPart>) {
|
||||
let create_result = client
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.checksum_type(SdkChecksumType::FullObject)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to create multipart upload");
|
||||
let upload_id = create_result.upload_id().expect("No upload_id").to_string();
|
||||
|
||||
let mut completed_parts = Vec::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
let part_number = index as i32 + 1;
|
||||
let uploaded = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from((*part).clone()))
|
||||
.checksum_algorithm(ChecksumAlgorithm::Crc32)
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("Failed to upload part {part_number}: {e:?}"));
|
||||
completed_parts.push(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.e_tag(uploaded.e_tag().expect("No etag for part"))
|
||||
.checksum_crc32(uploaded.checksum_crc32().expect("No CRC32 for part"))
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
(upload_id, completed_parts)
|
||||
}
|
||||
|
||||
/// A multipart upload completed with a **full-object** checksum must report
|
||||
/// `x-amz-checksum-type: FULL_OBJECT` on both GET and HEAD, the way AWS does.
|
||||
///
|
||||
/// `complete_multipart_upload` used to persist the object-level checksum
|
||||
/// record with the pre-merge checksum type, so the MULTIPART /
|
||||
/// INCLUDES_MULTIPART flags never reached disk. `rustfs_rio::read_checksums`
|
||||
/// only emits the FULL_OBJECT entry inside its MULTIPART branch, so these
|
||||
/// objects came back from GET and HEAD with no checksum-type header at all.
|
||||
/// Found while root-causing rustfs#6825.
|
||||
#[tokio::test]
|
||||
async fn test_full_object_multipart_reports_full_object_checksum_type() {
|
||||
init_logging();
|
||||
info!("TEST: full-object multipart upload round-trips x-amz-checksum-type: FULL_OBJECT");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-full-object-checksum-type";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 241) as u8).collect();
|
||||
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 29) % 241) as u8).collect();
|
||||
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
||||
|
||||
// CRC32 with an explicit FULL_OBJECT type: the object checksum is the
|
||||
// CRC32 of the whole object, not the composite hash of the part digests.
|
||||
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
|
||||
.expect("crc32 checksum")
|
||||
.encoded;
|
||||
|
||||
let key = "full-object-multipart.bin";
|
||||
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
// Restate the full-object intent and value on CompleteMultipartUpload,
|
||||
// exactly as an AWS SDK client does: `x-amz-checksum-type: FULL_OBJECT`
|
||||
// plus `x-amz-checksum-crc32`, with no `x-amz-checksum-algorithm`
|
||||
// header (CompleteMultipartUpload has no such member).
|
||||
.checksum_type(SdkChecksumType::FullObject)
|
||||
.checksum_crc32(full_object_crc32.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to complete multipart upload");
|
||||
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to head object");
|
||||
assert_eq!(
|
||||
head.checksum_type(),
|
||||
Some(&SdkChecksumType::FullObject),
|
||||
"HeadObject must report x-amz-checksum-type: FULL_OBJECT"
|
||||
);
|
||||
assert_eq!(
|
||||
head.checksum_crc32(),
|
||||
Some(full_object_crc32.as_str()),
|
||||
"HeadObject must report the full-object CRC32, with no -<parts> suffix"
|
||||
);
|
||||
|
||||
let get = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.checksum_mode(ChecksumMode::Enabled)
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to get object");
|
||||
assert_eq!(
|
||||
get.checksum_type(),
|
||||
Some(&SdkChecksumType::FullObject),
|
||||
"GetObject must report x-amz-checksum-type: FULL_OBJECT"
|
||||
);
|
||||
assert_eq!(
|
||||
get.checksum_crc32(),
|
||||
Some(full_object_crc32.as_str()),
|
||||
"GetObject must report the full-object CRC32, with no -<parts> suffix"
|
||||
);
|
||||
|
||||
let body = get.body.collect().await.expect("Failed to read body").into_bytes();
|
||||
assert_eq!(body.as_ref(), content.as_slice(), "GetObject body must match the uploaded content");
|
||||
|
||||
info!("PASSED: full-object multipart reports FULL_OBJECT on GET and HEAD");
|
||||
}
|
||||
|
||||
/// Declaring a checksum type on CompleteMultipartUpload that contradicts the
|
||||
/// one recorded at CreateMultipartUpload must be rejected, and rejected as a
|
||||
/// client error (4xx), not a server error.
|
||||
#[tokio::test]
|
||||
async fn test_complete_multipart_rejects_contradicting_checksum_type() {
|
||||
init_logging();
|
||||
info!("TEST: CompleteMultipartUpload rejects a checksum type that contradicts the upload");
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("Failed to create test environment");
|
||||
env.start_rustfs_server(vec![]).await.expect("Failed to start RustFS");
|
||||
|
||||
let client = create_s3_client(&env);
|
||||
let bucket = "test-checksum-type-mismatch";
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||
let part1: Vec<u8> = (0..PART_SIZE).map(|i| (i % 239) as u8).collect();
|
||||
let part2: Vec<u8> = (0..PART_SIZE).map(|i| ((i + 31) % 239) as u8).collect();
|
||||
let content: Vec<u8> = part1.iter().chain(part2.iter()).copied().collect();
|
||||
let full_object_crc32 = Checksum::new_from_data(RioChecksumType::CRC32, &content)
|
||||
.expect("crc32 checksum")
|
||||
.encoded;
|
||||
|
||||
let key = "checksum-type-mismatch.bin";
|
||||
let (upload_id, completed_parts) = start_full_object_crc32_upload(&client, bucket, key, &[&part1, &part2]).await;
|
||||
|
||||
let err = client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
// The upload was created as FULL_OBJECT; claiming COMPOSITE here
|
||||
// contradicts it.
|
||||
.checksum_type(SdkChecksumType::Composite)
|
||||
.checksum_crc32(full_object_crc32.clone())
|
||||
.send()
|
||||
.await
|
||||
.expect_err("COMPOSITE on a FULL_OBJECT upload must be rejected");
|
||||
|
||||
let service_err = err.into_service_error();
|
||||
let code = service_err.meta().code().unwrap_or("<no code>").to_string();
|
||||
let message = service_err.meta().message().unwrap_or_default().to_string();
|
||||
|
||||
// Before the fix the storage layer refused the combination with a generic
|
||||
// error and the caller got `500 InternalError` -- "please try again" for a
|
||||
// request that can only ever fail.
|
||||
assert_eq!(
|
||||
code, "InvalidRequest",
|
||||
"a contradicting checksum type is a client error, got {code}: {message}"
|
||||
);
|
||||
assert!(
|
||||
message.contains("FULL_OBJECT") && message.contains("COMPOSITE"),
|
||||
"the message must name the recorded and requested types, got {message}"
|
||||
);
|
||||
|
||||
// The upload is untouched by the rejected completion, so a well-formed
|
||||
// retry on the same upload id still succeeds.
|
||||
let listed = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("upload must survive the rejected completion");
|
||||
assert_eq!(listed.parts().len(), 2, "both parts must still be listed after the rejection");
|
||||
|
||||
info!("PASSED: contradicting checksum type rejected as InvalidRequest");
|
||||
}
|
||||
|
||||
/// Integration test for the AWS 2026-04 additional checksum algorithms
|
||||
|
||||
+204
-12
@@ -57,6 +57,8 @@ const RUSTFS_FULL_FEATURE: &str = "full";
|
||||
const TEST_PORT_MIN: u16 = 20_000;
|
||||
// Keep allocator ports below the ephemeral range used by bind(..., 0) test helpers.
|
||||
const TEST_PORT_RANGE: u16 = 10_000;
|
||||
const TEST_PORT_MIN_ENV: &str = "RUSTFS_E2E_TEST_PORT_MIN";
|
||||
const TEST_PORT_RANGE_ENV: &str = "RUSTFS_E2E_TEST_PORT_RANGE";
|
||||
const TEST_PORT_COUNTER_PATH: &str = "/tmp/rustfs_e2e_next_port";
|
||||
const TEST_PORT_LOCK_DIR: &str = "/tmp/rustfs_e2e_port_allocator.lock";
|
||||
const TEST_PORT_LOCK_STALE_AFTER: Duration = Duration::from_secs(30);
|
||||
@@ -99,22 +101,74 @@ impl Drop for PortAllocatorGuard {
|
||||
}
|
||||
}
|
||||
|
||||
fn advance_test_port(port: u16) -> u16 {
|
||||
let offset = (port - TEST_PORT_MIN + 1) % TEST_PORT_RANGE;
|
||||
TEST_PORT_MIN + offset
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
struct TestPortAllocatorConfig {
|
||||
min: u16,
|
||||
range: u16,
|
||||
}
|
||||
|
||||
fn seeded_test_port() -> u16 {
|
||||
let offset = (Uuid::new_v4().as_u128() % u128::from(TEST_PORT_RANGE)) as u16;
|
||||
TEST_PORT_MIN + offset
|
||||
impl TestPortAllocatorConfig {
|
||||
fn max_exclusive(self) -> u32 {
|
||||
u32::from(self.min) + u32::from(self.range)
|
||||
}
|
||||
|
||||
fn contains(self, port: &u16) -> bool {
|
||||
(u32::from(self.min)..self.max_exclusive()).contains(&u32::from(*port))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_next_test_port() -> u16 {
|
||||
fn parse_test_port_allocator_config(
|
||||
min_override: Option<&str>,
|
||||
range_override: Option<&str>,
|
||||
) -> Result<TestPortAllocatorConfig, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let min = match min_override {
|
||||
Some(value) => value
|
||||
.parse::<u16>()
|
||||
.map_err(|err| format!("{TEST_PORT_MIN_ENV} must be a valid u16: {err}"))?,
|
||||
None => TEST_PORT_MIN,
|
||||
};
|
||||
let range = match range_override {
|
||||
Some(value) => value
|
||||
.parse::<u16>()
|
||||
.map_err(|err| format!("{TEST_PORT_RANGE_ENV} must be a valid u16: {err}"))?,
|
||||
None => TEST_PORT_RANGE,
|
||||
};
|
||||
if range == 0 {
|
||||
return Err(format!("{TEST_PORT_RANGE_ENV} must be greater than zero").into());
|
||||
}
|
||||
if min < 1024 {
|
||||
return Err(format!("{TEST_PORT_MIN_ENV} must be at least 1024").into());
|
||||
}
|
||||
let max_exclusive = u32::from(min) + u32::from(range);
|
||||
if max_exclusive > u32::from(u16::MAX) + 1 {
|
||||
return Err(format!("{TEST_PORT_MIN_ENV} + {TEST_PORT_RANGE_ENV} exceeds u16 port space").into());
|
||||
}
|
||||
Ok(TestPortAllocatorConfig { min, range })
|
||||
}
|
||||
|
||||
fn test_port_allocator_config() -> Result<TestPortAllocatorConfig, Box<dyn std::error::Error + Send + Sync>> {
|
||||
parse_test_port_allocator_config(
|
||||
std::env::var(TEST_PORT_MIN_ENV).ok().as_deref(),
|
||||
std::env::var(TEST_PORT_RANGE_ENV).ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn advance_test_port(port: u16, config: TestPortAllocatorConfig) -> u16 {
|
||||
let offset = (port - config.min + 1) % config.range;
|
||||
config.min + offset
|
||||
}
|
||||
|
||||
fn seeded_test_port(config: TestPortAllocatorConfig) -> u16 {
|
||||
let offset = (Uuid::new_v4().as_u128() % u128::from(config.range)) as u16;
|
||||
config.min + offset
|
||||
}
|
||||
|
||||
fn read_next_test_port(config: TestPortAllocatorConfig) -> u16 {
|
||||
stdfs::read_to_string(TEST_PORT_COUNTER_PATH)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u16>().ok())
|
||||
.filter(|port| (TEST_PORT_MIN..TEST_PORT_MIN + TEST_PORT_RANGE).contains(port))
|
||||
.unwrap_or_else(seeded_test_port)
|
||||
.filter(|port| config.contains(port))
|
||||
.unwrap_or_else(|| seeded_test_port(config))
|
||||
}
|
||||
|
||||
fn remove_stale_port_allocator_lock() {
|
||||
@@ -629,11 +683,12 @@ impl RustFSTestEnvironment {
|
||||
pub async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use std::net::TcpListener;
|
||||
let _guard = PortAllocatorGuard::acquire().await?;
|
||||
let mut next_port = read_next_test_port();
|
||||
let config = test_port_allocator_config()?;
|
||||
let mut next_port = read_next_test_port(config);
|
||||
|
||||
for _ in 0..TEST_PORT_RANGE {
|
||||
for _ in 0..config.range {
|
||||
let port = next_port;
|
||||
next_port = advance_test_port(next_port);
|
||||
next_port = advance_test_port(next_port, config);
|
||||
write_next_test_port(next_port)?;
|
||||
|
||||
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
|
||||
@@ -1699,6 +1754,114 @@ impl RustFSTestClusterEnvironment {
|
||||
process.wait()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Append a new single-node erasure pool to a stopped multi-pool cluster.
|
||||
///
|
||||
/// Used to simulate pool expansion on localhost: every pool already owns
|
||||
/// exactly one node with `drives_per_node >= 2` (the only multi-pool layout
|
||||
/// the single-host `RUSTFS_VOLUMES` syntax can express). The new node is
|
||||
/// allocated a fresh port and empty drive directories; callers must
|
||||
/// [`Self::start`] afterwards so every process picks up the extended
|
||||
/// volumes argument. Existing data directories are left untouched.
|
||||
pub async fn append_single_node_pool(&mut self) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if self.nodes.iter().any(|node| node.process.is_some()) {
|
||||
return Err("stop the cluster before appending a pool".into());
|
||||
}
|
||||
if self.topology.drives_per_node < 2 {
|
||||
return Err(
|
||||
"append_single_node_pool requires drives_per_node >= 2 (the server parser rejects a single-drive ellipses pool)"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut pools = self.topology.normalized_pools();
|
||||
for (pool_idx, nodes) in pools.iter().enumerate() {
|
||||
if nodes.len() != 1 {
|
||||
return Err(format!(
|
||||
"pool {pool_idx} spans {} nodes; append_single_node_pool requires one node per pool",
|
||||
nodes.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let new_idx = self.nodes.len();
|
||||
let port = RustFSTestEnvironment::find_available_port().await?;
|
||||
let address = format!("127.0.0.1:{port}");
|
||||
let data_dirs: Vec<String> = (0..self.topology.drives_per_node)
|
||||
.map(|drive| format!("{}/node{}/drive{}", self.temp_dir, new_idx, drive))
|
||||
.collect();
|
||||
for dir in &data_dirs {
|
||||
fs::create_dir_all(dir).await?;
|
||||
}
|
||||
|
||||
self.nodes.push(ClusterNode {
|
||||
url: format!("http://{address}"),
|
||||
address,
|
||||
data_dir: data_dirs[0].clone(),
|
||||
data_dirs,
|
||||
pool_idx: pools.len(),
|
||||
process: None,
|
||||
});
|
||||
pools.push(vec![new_idx]);
|
||||
self.topology.node_count = self.nodes.len();
|
||||
self.topology.pools = pools;
|
||||
self.node_extra_env.push(Vec::new());
|
||||
self.node_capture_log_paths.push(None);
|
||||
self.volume_proxy_addresses.push(None);
|
||||
|
||||
if !self.extra_env.iter().any(|(key, _)| key == "RUSTFS_UNSAFE_BYPASS_DISK_CHECK") {
|
||||
self.extra_env
|
||||
.push(("RUSTFS_UNSAFE_BYPASS_DISK_CHECK".to_string(), "true".to_string()));
|
||||
}
|
||||
|
||||
Ok(new_idx)
|
||||
}
|
||||
|
||||
/// Gracefully stop one cluster node and wait for its process to exit.
|
||||
///
|
||||
/// This is intentionally separate from [`Self::stop_node`]: the latter is
|
||||
/// a hard kill used by crash-recovery tests, while this path lets RustFS
|
||||
/// complete its normal shutdown hooks before a test restarts the node.
|
||||
pub async fn stop_node_gracefully(&mut self, node_idx: usize) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
self.ensure_node_index(node_idx)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let Some(process) = self.nodes[node_idx].process.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let pid = process.id().to_string();
|
||||
let signal_status = Command::new("kill").args(["-TERM", &pid]).status()?;
|
||||
if !signal_status.success() {
|
||||
return Err(format!("failed to send SIGTERM to cluster node {node_idx} (pid {pid})").into());
|
||||
}
|
||||
|
||||
let mut process = self.nodes[node_idx]
|
||||
.process
|
||||
.take()
|
||||
.ok_or_else(|| format!("cluster node {node_idx} process disappeared while stopping"))?;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(45);
|
||||
loop {
|
||||
if let Some(status) = process.try_wait()? {
|
||||
info!("Cluster node {} stopped gracefully with {}", node_idx, status);
|
||||
return Ok(());
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
return Err(format!("cluster node {node_idx} did not stop gracefully within 45 seconds").into());
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = node_idx;
|
||||
Err("graceful cluster-node stop is only supported on Unix E2E hosts".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RustFSTestClusterEnvironment {
|
||||
@@ -2000,6 +2163,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_port_allocator_uses_default_range() {
|
||||
assert_eq!(
|
||||
parse_test_port_allocator_config(None, None).expect("default port allocator config"),
|
||||
TestPortAllocatorConfig {
|
||||
min: TEST_PORT_MIN,
|
||||
range: TEST_PORT_RANGE
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_port_allocator_accepts_explicit_test_range() {
|
||||
let config = parse_test_port_allocator_config(Some("31000"), Some("128")).expect("explicit port range");
|
||||
|
||||
assert_eq!(advance_test_port(31127, config), 31000);
|
||||
assert!(config.contains(&31000));
|
||||
assert!(config.contains(&31127));
|
||||
assert!(!config.contains(&31128));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_port_allocator_rejects_invalid_override() {
|
||||
assert!(parse_test_port_allocator_config(Some("1023"), Some("1")).is_err());
|
||||
assert!(parse_test_port_allocator_config(Some("65000"), Some("1000")).is_err());
|
||||
assert!(parse_test_port_allocator_config(Some("31000"), Some("0")).is_err());
|
||||
assert!(parse_test_port_allocator_config(Some("not-a-port"), Some("128")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
|
||||
let workspace = Path::new("workspace");
|
||||
|
||||
@@ -35,11 +35,15 @@ where
|
||||
{
|
||||
let mut last_usage = DataUsageInfo::default();
|
||||
let mut last_query_error = None;
|
||||
for _ in 0..45 {
|
||||
for _ in 0..90 {
|
||||
match get_data_usage_info(env).await {
|
||||
Ok(usage) => {
|
||||
last_query_error = None;
|
||||
if usage.buckets_usage.contains_key(bucket) && predicate(&usage) {
|
||||
if usage.is_complete_bucket_usage_snapshot()
|
||||
&& usage.usage_snapshot_converged != Some(false)
|
||||
&& usage.buckets_usage.contains_key(bucket)
|
||||
&& predicate(&usage)
|
||||
{
|
||||
return Ok(usage);
|
||||
}
|
||||
last_usage = usage;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, retrying_get_equals, unique_bucket,
|
||||
wait_for_ready, wait_until,
|
||||
};
|
||||
use crate::chaos::{census_object_version_on_disk, signed_admin_post};
|
||||
use crate::common::{build_test_s3_config, init_logging};
|
||||
use crate::fault_proxy::FaultMode;
|
||||
use aws_sdk_s3::Client;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Barrier, mpsc};
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[tokio::test]
|
||||
async fn kill_and_restart_node_preserves_objects() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("killnode");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let body = vec![0x11u8; 128 * 1024];
|
||||
put_object(&dist.client(0)?, &bucket, "keep.bin", body.clone()).await?;
|
||||
|
||||
dist.cluster.stop_node(3)?;
|
||||
retrying_get_equals(&dist.client(0)?, &bucket, "keep.bin", &body, Duration::from_secs(20)).await?;
|
||||
|
||||
dist.cluster.start_node(3).await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
assert_object_bytes(&dist.client(3)?, &bucket, "keep.bin", &body).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_cluster_restart_preserves_objects() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("pwr");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let body = vec![0x44u8; 64 * 1024];
|
||||
put_object(&dist.client(1)?, &bucket, "survive.bin", body.clone()).await?;
|
||||
|
||||
dist.cluster.stop();
|
||||
dist.cluster.start().await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
assert_object_bytes(&dist.client(node_idx)?, &bucket, "survive.bin", &body).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fresh_drive_replacement_is_physically_healed_without_data_change() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start_with_env(DistLayout::FourByFour, &[("RUSTFS_HEAL_ENABLED", "true")]).await?;
|
||||
let bucket = unique_bucket("baddrive");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let body = payload_for("fresh-drive/durable.bin", 8 * 1024 * 1024);
|
||||
put_object(&dist.client(1)?, &bucket, "durable.bin", body.clone()).await?;
|
||||
|
||||
let replaced_drive = PathBuf::from(&dist.cluster.nodes[0].data_dirs[0]);
|
||||
let baseline = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||
assert!(
|
||||
baseline.is_complete(),
|
||||
"replacement target did not hold a complete baseline shard: {baseline:?}"
|
||||
);
|
||||
assert!(
|
||||
!baseline.expected_part_numbers.is_empty(),
|
||||
"replacement witness must use physical part shards: {baseline:?}"
|
||||
);
|
||||
|
||||
dist.cluster.stop_node(0)?;
|
||||
let format_path = replaced_drive.join(".rustfs.sys/format.json");
|
||||
let format = std::fs::read(&format_path)?;
|
||||
let retired_drive = PathBuf::from(format!("{}.retired", replaced_drive.display()));
|
||||
std::fs::rename(&replaced_drive, &retired_drive)?;
|
||||
std::fs::create_dir_all(format_path.parent().ok_or("replacement format path omitted parent")?)?;
|
||||
std::fs::write(&format_path, format)?;
|
||||
let empty = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||
assert!(!empty.has_xl_meta, "fresh replacement unexpectedly retained object metadata: {empty:?}");
|
||||
|
||||
dist.cluster.start_node(0).await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
let heal_body =
|
||||
r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||
let heal_url = format!("{}/rustfs/admin/v3/heal/{bucket}?forceStart=true", dist.cluster.nodes[1].url);
|
||||
signed_admin_post(&heal_url, Some(heal_body), &dist.cluster.access_key, &dist.cluster.secret_key).await?;
|
||||
wait_until(
|
||||
Duration::from_secs(90),
|
||||
|| async {
|
||||
let healed = census_object_version_on_disk(&replaced_drive, &bucket, "durable.bin", None)?;
|
||||
Ok(healed.matches_manifest(&baseline))
|
||||
},
|
||||
"fresh replacement contains the original complete shard manifest",
|
||||
)
|
||||
.await?;
|
||||
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
assert_object_bytes(&dist.client(node_idx)?, &bucket, "durable.bin", &body).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_gets_survive_peer_node_kill() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("getkill");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let body = payload_for("inflight/steady.bin", 8 * 1024 * 1024);
|
||||
put_object(&dist.client(0)?, &bucket, "steady.bin", body.clone()).await?;
|
||||
|
||||
let live: Vec<_> = (0..3).map(|idx| dist.client(idx)).collect::<Result<Vec<_>, _>>()?;
|
||||
let worker_count = 12;
|
||||
let release = Arc::new(Barrier::new(worker_count + 1));
|
||||
let (started_tx, mut started_rx) = mpsc::unbounded_channel();
|
||||
let mut handles = Vec::new();
|
||||
for idx in 0..worker_count {
|
||||
let client = live[idx % live.len()].clone();
|
||||
let bucket = bucket.clone();
|
||||
let body = body.clone();
|
||||
let release = release.clone();
|
||||
let started_tx = started_tx.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
let response = client.get_object().bucket(&bucket).key("steady.bin").send().await?;
|
||||
if response.content_length() != Some(body.len() as i64) {
|
||||
return Err::<(), Box<dyn std::error::Error + Send + Sync>>(
|
||||
format!("worker {idx} received a wrong content length").into(),
|
||||
);
|
||||
}
|
||||
started_tx.send(idx)?;
|
||||
release.wait().await;
|
||||
let actual = response.body.collect().await?.into_bytes();
|
||||
if actual.as_ref() != body.as_slice() {
|
||||
return Err(format!("worker {idx} received corrupted bytes after peer kill").into());
|
||||
}
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
drop(started_tx);
|
||||
for _ in 0..worker_count {
|
||||
timeout(Duration::from_secs(30), started_rx.recv())
|
||||
.await?
|
||||
.ok_or("a streaming GET exited before reaching the kill barrier")?;
|
||||
}
|
||||
|
||||
dist.cluster.stop_node(3)?;
|
||||
release.wait().await;
|
||||
for handle in handles {
|
||||
handle.await??;
|
||||
}
|
||||
|
||||
dist.cluster.start_node(3).await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
assert_object_bytes(&dist.client(3)?, &bucket, "steady.bin", &body).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn blackholed_node_client_network_preserves_cluster_availability_and_recovers() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let proxy = crate::fault_proxy::FaultProxy::start(dist.cluster.nodes[3].address.parse()?).await?;
|
||||
let proxied_url = format!("http://{}", proxy.local_addr());
|
||||
let proxied_client = Client::from_conf(build_test_s3_config(
|
||||
&proxied_url,
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
None,
|
||||
"distributed-network-chaos",
|
||||
));
|
||||
|
||||
let result: TestResult = async {
|
||||
let bucket = unique_bucket("netfault");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let baseline = payload_for("network/baseline.bin", 1024 * 1024);
|
||||
put_object(&dist.client(0)?, &bucket, "baseline.bin", baseline.clone()).await?;
|
||||
assert_object_bytes(&proxied_client, &bucket, "baseline.bin", &baseline).await?;
|
||||
|
||||
proxy.set_mode(FaultMode::Blackhole);
|
||||
assert_eq!(proxy.mode(), FaultMode::Blackhole);
|
||||
if let Ok(Ok(_)) = timeout(
|
||||
Duration::from_secs(5),
|
||||
proxied_client.get_object().bucket(&bucket).key("baseline.bin").send(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err("blackholed node endpoint unexpectedly completed a GET".into());
|
||||
}
|
||||
|
||||
let during = payload_for("network/during.bin", 1024 * 1024);
|
||||
timeout(Duration::from_secs(30), async {
|
||||
put_object(&dist.client(1)?, &bucket, "during-blackhole.bin", during.clone()).await?;
|
||||
assert_object_bytes(&dist.client(2)?, &bucket, "baseline.bin", &baseline).await?;
|
||||
assert_object_bytes(&dist.client(0)?, &bucket, "during-blackhole.bin", &during).await?;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
|
||||
})
|
||||
.await??;
|
||||
|
||||
proxy.set_mode(FaultMode::Pass);
|
||||
retrying_get_equals(&proxied_client, &bucket, "during-blackhole.bin", &during, Duration::from_secs(30)).await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
proxy.set_mode(FaultMode::Pass);
|
||||
proxy.shutdown().await;
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, assert_object_bytes, payload_for, put_object, unique_bucket};
|
||||
use crate::common::init_logging;
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_high_concurrency_mixed_workload_is_consistent_on_every_node() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("conc");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let clients = Arc::new(dist.clients()?);
|
||||
let worker_count = 24;
|
||||
let rounds = 4;
|
||||
let barrier = Arc::new(Barrier::new(worker_count));
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for idx in 0..worker_count {
|
||||
let clients = clients.clone();
|
||||
let barrier = barrier.clone();
|
||||
let bucket = bucket.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
let writer = &clients[idx % clients.len()];
|
||||
let reader = &clients[(idx + 1) % clients.len()];
|
||||
let copier = &clients[(idx + 2) % clients.len()];
|
||||
let mut retained = Vec::with_capacity(rounds);
|
||||
for round in 0..rounds {
|
||||
let key = format!("source/worker-{idx:02}-round-{round}.bin");
|
||||
let copy_key = format!("retained/worker-{idx:02}-round-{round}.bin");
|
||||
let body = payload_for(&key, 64 * 1024);
|
||||
put_object(writer, &bucket, &key, body.clone()).await?;
|
||||
|
||||
let head = reader.head_object().bucket(&bucket).key(&key).send().await?;
|
||||
if head.content_length() != Some(body.len() as i64) {
|
||||
return Err(format!("HEAD returned the wrong size for {key}: {head:?}").into());
|
||||
}
|
||||
assert_object_bytes(reader, &bucket, &key, &body).await?;
|
||||
|
||||
copier
|
||||
.copy_object()
|
||||
.bucket(&bucket)
|
||||
.key(©_key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.send()
|
||||
.await?;
|
||||
assert_object_bytes(writer, &bucket, ©_key, &body).await?;
|
||||
|
||||
writer.delete_object().bucket(&bucket).key(&key).send().await?;
|
||||
let missing = reader
|
||||
.head_object()
|
||||
.bucket(&bucket)
|
||||
.key(&key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("deleted source key must not remain visible");
|
||||
if missing.raw_response().map(|response| response.status().as_u16()) != Some(404) {
|
||||
return Err(format!("deleted source {key} returned an unexpected result: {missing:?}").into());
|
||||
}
|
||||
retained.push((copy_key, body));
|
||||
}
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(retained)
|
||||
}));
|
||||
}
|
||||
|
||||
let mut inventory = Vec::new();
|
||||
for handle in handles {
|
||||
inventory.extend(handle.await??);
|
||||
}
|
||||
|
||||
let expected_keys: BTreeSet<_> = inventory.iter().map(|(key, _)| key.as_str()).collect();
|
||||
for (node_idx, client) in clients.iter().enumerate() {
|
||||
let listed = client.list_objects_v2().bucket(&bucket).prefix("retained/").send().await?;
|
||||
let listed_keys: BTreeSet<_> = listed.contents().iter().filter_map(|object| object.key()).collect();
|
||||
assert_eq!(listed_keys, expected_keys, "node {node_idx} returned a divergent retained-key listing");
|
||||
for (key, body) in &inventory {
|
||||
assert_object_bytes(client, &bucket, key, body)
|
||||
.await
|
||||
.map_err(|error| format!("node {node_idx} failed to read {key}: {error}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||
decommission_status_json, payload_for, put_inventory_retrying, retrying_get_equals, retrying_put, start_decommission,
|
||||
unique_bucket, wait_for_decommission_complete, wait_for_decommission_running_with_progress,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Barrier;
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_puts_during_decommission_do_not_lose_baseline_or_new_objects() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||
let bucket = unique_bucket("concdecom");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let baseline_client = dist.client(0)?;
|
||||
let inventory = put_inventory_retrying(&baseline_client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||
dist.expand_to_four_pools().await?;
|
||||
|
||||
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
|
||||
let clients = Arc::new(dist.clients()?);
|
||||
let barrier = Arc::new(Barrier::new(17));
|
||||
let mut handles = Vec::new();
|
||||
for idx in 0..16 {
|
||||
let clients = clients.clone();
|
||||
let barrier = barrier.clone();
|
||||
let bucket = bucket.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
barrier.wait().await;
|
||||
let client = &clients[idx % clients.len()];
|
||||
let key = format!("live/{idx:02}.bin");
|
||||
let body = payload_for(&key, 8 * 1024);
|
||||
retrying_put(client, &bucket, &key, body.clone(), Duration::from_secs(45)).await?;
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>((key, body))
|
||||
}));
|
||||
}
|
||||
|
||||
wait_for_decommission_running_with_progress(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||
barrier.wait().await;
|
||||
|
||||
let mut live_objects = Vec::new();
|
||||
for handle in handles {
|
||||
live_objects.push(handle.await??);
|
||||
}
|
||||
let status = decommission_status_json(&dist.cluster).await?;
|
||||
if !decommission_running_with_progress(&status, DECOMMISSION_POOL_ID)? {
|
||||
return Err(format!("decommission did not remain active across concurrent PUTs: {status}").into());
|
||||
}
|
||||
|
||||
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||
|
||||
let checker = dist.client(2)?;
|
||||
assert_inventory(&checker, &bucket, &inventory).await?;
|
||||
for (key, body) in live_objects {
|
||||
retrying_get_equals(&checker, &bucket, &key, &body, Duration::from_secs(30)).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, enable_versioning, put_inventory_retrying,
|
||||
sha256_hex, start_decommission, unique_bucket, wait_for_decommission_active, wait_for_decommission_complete,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn decommission_does_not_alter_object_sha256_across_pools() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||
let bucket = unique_bucket("integrity");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = dist.client(0)?;
|
||||
enable_versioning(&client, &bucket).await?;
|
||||
let inventory = put_inventory_retrying(&client, &bucket, 96, 256 * 1024, Duration::from_secs(30)).await?;
|
||||
let before: Vec<(String, String)> = inventory.iter().map(|(key, body)| (key.clone(), sha256_hex(body))).collect();
|
||||
|
||||
let versioned_key = "history/versioned.bin";
|
||||
let version_one = b"historical bytes before data movement".to_vec();
|
||||
let version_two = b"current bytes before data movement".to_vec();
|
||||
let version_one_id = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(versioned_key)
|
||||
.body(ByteStream::from(version_one.clone()))
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("historical PUT omitted version ID")?
|
||||
.to_string();
|
||||
let version_two_id = client
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(versioned_key)
|
||||
.body(ByteStream::from(version_two.clone()))
|
||||
.send()
|
||||
.await?
|
||||
.version_id()
|
||||
.ok_or("current PUT omitted version ID")?
|
||||
.to_string();
|
||||
|
||||
let multipart_key = "multipart/moved.bin";
|
||||
let first_part = vec![0x31; 5 * 1024 * 1024];
|
||||
let second_part = vec![0x72; 1024 * 1024];
|
||||
let upload = client
|
||||
.create_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.send()
|
||||
.await?;
|
||||
let upload_id = upload.upload_id().ok_or("movement multipart upload omitted upload ID")?;
|
||||
let uploaded_one = client
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(first_part.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let uploaded_two = client
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(2)
|
||||
.body(ByteStream::from(second_part.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(uploaded_one.e_tag().ok_or("movement part 1 omitted ETag")?)
|
||||
.build(),
|
||||
)
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(2)
|
||||
.e_tag(uploaded_two.e_tag().ok_or("movement part 2 omitted ETag")?)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
dist.expand_to_four_pools().await?;
|
||||
|
||||
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||
|
||||
let after_client = dist.client(2)?;
|
||||
assert_inventory(&after_client, &bucket, &inventory).await?;
|
||||
for (key, expected_hash) in before {
|
||||
let got = after_client.get_object().bucket(&bucket).key(&key).send().await?;
|
||||
let body = got.body.collect().await?.into_bytes();
|
||||
assert_eq!(sha256_hex(body.as_ref()), expected_hash, "checksum changed for {key} after decommission");
|
||||
}
|
||||
for (version_id, expected) in [(&version_one_id, &version_one), (&version_two_id, &version_two)] {
|
||||
let got = after_client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(versioned_key)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(got.as_ref(), expected.as_slice(), "version {version_id} changed after decommission");
|
||||
}
|
||||
let mut expected_multipart = first_part;
|
||||
expected_multipart.extend_from_slice(&second_part);
|
||||
let got_multipart = after_client
|
||||
.get_object()
|
||||
.bucket(&bucket)
|
||||
.key(multipart_key)
|
||||
.send()
|
||||
.await?
|
||||
.body
|
||||
.collect()
|
||||
.await?
|
||||
.into_bytes();
|
||||
assert_eq!(
|
||||
sha256_hex(got_multipart.as_ref()),
|
||||
sha256_hex(&expected_multipart),
|
||||
"multipart checksum changed after decommission"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, list_pools_json, put_inventory,
|
||||
put_inventory_retrying, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_active,
|
||||
wait_for_decommission_complete, wait_for_rebalance_active, wait_for_rebalance_complete,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_pool_expand_preserves_objects_then_rebalance() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||
let bucket = unique_bucket("expand");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = dist.client(0)?;
|
||||
let inventory = put_inventory(&client, &bucket, 64, 256 * 1024).await?;
|
||||
assert_inventory(&client, &bucket, &inventory).await?;
|
||||
|
||||
for expected_nodes in 2..=4 {
|
||||
let new_node = dist.append_pool_and_restart().await?;
|
||||
assert_eq!(new_node + 1, expected_nodes);
|
||||
assert_inventory(&dist.client(new_node)?, &bucket, &inventory).await?;
|
||||
}
|
||||
assert_eq!(dist.cluster.nodes.len(), 4);
|
||||
|
||||
// Prove that the expanded pool map is durable, and clear any recovery
|
||||
// latch raised while the newly-added pool replicas converged.
|
||||
dist.restart_current_binary_gracefully().await?;
|
||||
|
||||
let after_expand = dist.client(0)?;
|
||||
assert_inventory(&after_expand, &bucket, &inventory).await?;
|
||||
let peer = dist.client(3)?;
|
||||
assert_inventory(&peer, &bucket, &inventory).await?;
|
||||
|
||||
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||
assert_inventory(&peer, &bucket, &inventory).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_pool_decommission_moves_objects_without_loss() -> TestResult {
|
||||
init_logging();
|
||||
let mut dist = DistCluster::start(DistLayout::SingleNodeFourDrive).await?;
|
||||
let bucket = unique_bucket("decom");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = dist.client(0)?;
|
||||
let inventory = put_inventory_retrying(&client, &bucket, 96, 128 * 1024, Duration::from_secs(30)).await?;
|
||||
dist.expand_to_four_pools().await?;
|
||||
|
||||
let pools_before = list_pools_json(&dist.cluster).await?;
|
||||
let pool_count = pools_before
|
||||
.as_array()
|
||||
.map(Vec::len)
|
||||
.or_else(|| pools_before.get("pools").and_then(serde_json::Value::as_array).map(Vec::len))
|
||||
.ok_or_else(|| format!("pool list omitted an array: {pools_before}"))?;
|
||||
assert_eq!(pool_count, 4, "expected exactly four pools before decommission: {pools_before}");
|
||||
|
||||
start_decommission(&dist.cluster, DECOMMISSION_POOL_ID).await?;
|
||||
wait_for_decommission_active(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(30)).await?;
|
||||
wait_for_decommission_complete(&dist.cluster, DECOMMISSION_POOL_ID, Duration::from_secs(180)).await?;
|
||||
|
||||
let after = dist.client(2)?;
|
||||
assert_inventory(&after, &bucket, &inventory).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{
|
||||
DistCluster, DistLayout, TestResult, assert_object_bytes, get_object_bytes, put_object, unique_bucket, wait_until,
|
||||
};
|
||||
use crate::common::init_logging;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_multipart_and_cross_node_listing_agree() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("extra");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let client = dist.client(0)?;
|
||||
|
||||
let key = "multipart.bin";
|
||||
let part1 = vec![0x41u8; 5 * 1024 * 1024];
|
||||
let part2 = vec![0x42u8; 5 * 1024 * 1024];
|
||||
let upload = client.create_multipart_upload().bucket(&bucket).key(key).send().await?;
|
||||
let upload_id = upload.upload_id().ok_or("missing upload id")?.to_string();
|
||||
|
||||
let uploaded1 = client
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from(part1.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
let uploaded2 = client
|
||||
.upload_part()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.part_number(2)
|
||||
.body(ByteStream::from(part2.clone()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.upload_id(&upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(uploaded1.e_tag().unwrap_or_default())
|
||||
.build(),
|
||||
)
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(2)
|
||||
.e_tag(uploaded2.e_tag().unwrap_or_default())
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let mut expected = part1;
|
||||
expected.extend_from_slice(&part2);
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
assert_object_bytes(&dist.client(node_idx)?, &bucket, key, &expected).await?;
|
||||
}
|
||||
|
||||
put_object(&client, &bucket, "list/a", b"a".to_vec()).await?;
|
||||
put_object(&dist.client(2)?, &bucket, "list/b", b"b".to_vec()).await?;
|
||||
let mut seen = Vec::new();
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
let listed = dist
|
||||
.client(node_idx)?
|
||||
.list_objects_v2()
|
||||
.bucket(&bucket)
|
||||
.prefix("list/")
|
||||
.send()
|
||||
.await?;
|
||||
let keys: Vec<String> = listed
|
||||
.contents()
|
||||
.iter()
|
||||
.filter_map(|object| object.key().map(str::to_string))
|
||||
.collect();
|
||||
seen.push(keys);
|
||||
}
|
||||
for keys in &seen[1..] {
|
||||
assert_eq!(&seen[0], keys, "list results diverged across nodes: {seen:?}");
|
||||
}
|
||||
|
||||
let got = get_object_bytes(&dist.client(3)?, &bucket, "list/a").await?;
|
||||
assert_eq!(got, b"a");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_list_buckets_agree_across_all_nodes() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let bucket = unique_bucket("listed");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
put_object(&dist.client(0)?, &bucket, "seed.bin", b"seed".to_vec()).await?;
|
||||
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
let client = dist.client(node_idx)?;
|
||||
let name = bucket.clone();
|
||||
wait_until(
|
||||
Duration::from_secs(20),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let name = name.clone();
|
||||
async move {
|
||||
let listed = client.list_buckets().send().await?;
|
||||
Ok(listed.buckets().iter().any(|entry| entry.name() == Some(name.as_str())))
|
||||
}
|
||||
},
|
||||
&format!("node {node_idx} lists {bucket}"),
|
||||
)
|
||||
.await?;
|
||||
wait_until(
|
||||
Duration::from_secs(20),
|
||||
|| {
|
||||
let client = dist.client(node_idx).expect("client");
|
||||
let name = bucket.clone();
|
||||
async move { Ok(get_object_bytes(&client, &name, "seed.bin").await.ok() == Some(b"seed".to_vec())) }
|
||||
},
|
||||
&format!("node {node_idx} reads seed.bin"),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! 4-node 4-drive distributed e2e coverage.
|
||||
//!
|
||||
//! Selected by `[profile.e2e-distributed]` and run from
|
||||
//! `.github/workflows/e2e-distributed.yml`. Excluded from `e2e-full` because
|
||||
//! each case starts four real `rustfs` processes.
|
||||
|
||||
mod chaos_test;
|
||||
mod concurrency_stability_test;
|
||||
mod concurrent_data_movement_test;
|
||||
mod data_integrity_movement_test;
|
||||
mod expand_decommission_rebalance_test;
|
||||
mod extra_test;
|
||||
mod harness;
|
||||
mod object_lock_test;
|
||||
mod observability_test;
|
||||
mod replication_quota_test;
|
||||
mod s3_basic_test;
|
||||
mod s3_during_data_movement_test;
|
||||
mod site_replication_test;
|
||||
mod upgrade_test;
|
||||
mod versioning_test;
|
||||
@@ -0,0 +1,219 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, unique_bucket};
|
||||
use crate::common::init_logging;
|
||||
use crate::object_lock::common::{
|
||||
delete_object_with_bypass, put_object_lock_configuration, put_object_with_legal_hold, put_object_with_retention,
|
||||
};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use aws_sdk_s3::operation::delete_object::DeleteObjectError;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
DefaultRetention, ObjectLockConfiguration, ObjectLockEnabled, ObjectLockLegalHoldStatus, ObjectLockRetentionMode,
|
||||
ObjectLockRule,
|
||||
};
|
||||
use chrono::{Duration as ChronoDuration, Utc};
|
||||
|
||||
fn delete_denied(error: &SdkError<DeleteObjectError>, context: &str) -> TestResult {
|
||||
let code = error.as_service_error().and_then(ProvideErrorMetadata::code);
|
||||
if code == Some("AccessDenied") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{context}: expected AccessDenied, got {error:?}").into())
|
||||
}
|
||||
}
|
||||
|
||||
async fn expect_versioned_delete_denied(
|
||||
client: &Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
version_id: &str,
|
||||
bypass: bool,
|
||||
context: &str,
|
||||
) -> TestResult {
|
||||
match delete_object_with_bypass(client, bucket, key, Some(version_id), bypass).await {
|
||||
Ok(_) => Err(format!("{context}: DeleteObject of retained version must be denied").into()),
|
||||
Err(error) => delete_denied(error.as_ref(), context),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_object_lock_worm_blocks_delete() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let client = dist.client(0)?;
|
||||
let peer = dist.client(2)?;
|
||||
let bucket = unique_bucket("objlock");
|
||||
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(&bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let retain_until = Utc::now() + ChronoDuration::days(1);
|
||||
|
||||
let compliance_key = "compliance.bin";
|
||||
let compliance_version = put_object_with_retention(
|
||||
&client,
|
||||
&bucket,
|
||||
compliance_key,
|
||||
b"locked-compliance",
|
||||
ObjectLockRetentionMode::Compliance,
|
||||
retain_until,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Unversioned DELETE is allowed: it only creates a delete marker. WORM
|
||||
// applies to a specific version id.
|
||||
let marker = peer.delete_object().bucket(&bucket).key(compliance_key).send().await?;
|
||||
assert_eq!(
|
||||
marker.delete_marker(),
|
||||
Some(true),
|
||||
"unversioned DELETE on a locked object must create a delete marker"
|
||||
);
|
||||
|
||||
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, false, "COMPLIANCE without bypass")
|
||||
.await?;
|
||||
expect_versioned_delete_denied(&peer, &bucket, compliance_key, &compliance_version, true, "COMPLIANCE with bypass").await?;
|
||||
|
||||
let governance_key = "governance.bin";
|
||||
let governance_version = put_object_with_retention(
|
||||
&client,
|
||||
&bucket,
|
||||
governance_key,
|
||||
b"locked-governance",
|
||||
ObjectLockRetentionMode::Governance,
|
||||
retain_until,
|
||||
)
|
||||
.await?;
|
||||
|
||||
expect_versioned_delete_denied(&peer, &bucket, governance_key, &governance_version, false, "GOVERNANCE without bypass")
|
||||
.await?;
|
||||
delete_object_with_bypass(&peer, &bucket, governance_key, Some(&governance_version), true).await?;
|
||||
let deleted_governance = peer
|
||||
.head_object()
|
||||
.bucket(&bucket)
|
||||
.key(governance_key)
|
||||
.version_id(&governance_version)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("GOVERNANCE bypass must remove the retained version");
|
||||
assert_eq!(
|
||||
deleted_governance.raw_response().map(|response| response.status().as_u16()),
|
||||
Some(404),
|
||||
"deleted GOVERNANCE version returned an unexpected HEAD result: {deleted_governance:?}"
|
||||
);
|
||||
|
||||
let hold_key = "legal-hold.bin";
|
||||
let hold_version =
|
||||
put_object_with_legal_hold(&client, &bucket, hold_key, b"legal-hold", ObjectLockLegalHoldStatus::On).await?;
|
||||
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, false, "legal hold without bypass").await?;
|
||||
expect_versioned_delete_denied(&peer, &bucket, hold_key, &hold_version, true, "legal hold with bypass").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_default_retention_is_visible_and_non_lock_bucket_rejects_configuration() -> TestResult {
|
||||
init_logging();
|
||||
let dist = DistCluster::start(DistLayout::FourByFour).await?;
|
||||
let writer = dist.client(0)?;
|
||||
let reader = dist.client(3)?;
|
||||
let bucket = unique_bucket("default-lock");
|
||||
|
||||
writer
|
||||
.create_bucket()
|
||||
.bucket(&bucket)
|
||||
.object_lock_enabled_for_bucket(true)
|
||||
.send()
|
||||
.await?;
|
||||
put_object_lock_configuration(&writer, &bucket, ObjectLockRetentionMode::Governance, Some(1), None).await?;
|
||||
|
||||
let key = "default-governance.bin";
|
||||
let put = writer
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"default retention payload"))
|
||||
.send()
|
||||
.await?;
|
||||
let version_id = put.version_id().ok_or("default-retained PUT omitted version ID")?;
|
||||
|
||||
let config = reader.get_object_lock_configuration().bucket(&bucket).send().await?;
|
||||
let default_retention = config
|
||||
.object_lock_configuration()
|
||||
.and_then(|configuration| configuration.rule())
|
||||
.and_then(|rule| rule.default_retention())
|
||||
.ok_or("GetObjectLockConfiguration omitted default retention")?;
|
||||
assert_eq!(default_retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
|
||||
assert_eq!(default_retention.days(), Some(1));
|
||||
|
||||
let retention = reader
|
||||
.get_object_retention()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await?;
|
||||
let retention = retention.retention().ok_or("GetObjectRetention omitted applied retention")?;
|
||||
assert_eq!(retention.mode().map(|mode| mode.as_str()), Some("GOVERNANCE"));
|
||||
let retain_until = retention
|
||||
.retain_until_date()
|
||||
.ok_or("default retention omitted retain-until date")?;
|
||||
assert!(retain_until.secs() > Utc::now().timestamp(), "default retention is not in the future");
|
||||
|
||||
let versioning = reader.get_bucket_versioning().bucket(&bucket).send().await?;
|
||||
assert_eq!(versioning.status().map(|status| status.as_str()), Some("Enabled"));
|
||||
expect_versioned_delete_denied(&reader, &bucket, key, version_id, false, "default GOVERNANCE retention without bypass")
|
||||
.await?;
|
||||
|
||||
let plain_bucket = unique_bucket("no-lock");
|
||||
dist.create_bucket(&plain_bucket).await?;
|
||||
let configuration = ObjectLockConfiguration::builder()
|
||||
.object_lock_enabled(ObjectLockEnabled::Enabled)
|
||||
.rule(
|
||||
ObjectLockRule::builder()
|
||||
.default_retention(
|
||||
DefaultRetention::builder()
|
||||
.mode(ObjectLockRetentionMode::Governance)
|
||||
.days(1)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
let error = writer
|
||||
.put_object_lock_configuration()
|
||||
.bucket(&plain_bucket)
|
||||
.object_lock_configuration(configuration)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("an unversioned bucket must reject Object Lock enablement");
|
||||
let service_error = error
|
||||
.as_service_error()
|
||||
.ok_or("non-lock bucket rejection was not an S3 service error")?;
|
||||
assert_eq!(service_error.code(), Some("InvalidBucketState"), "unexpected error: {error:?}");
|
||||
assert_eq!(
|
||||
service_error.message(),
|
||||
Some("Object Lock configuration cannot be enabled on existing buckets"),
|
||||
"unexpected error: {error:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
// Copyright 2026 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use super::harness::{DistCluster, DistLayout, TestResult, cluster_admin_ok, unique_bucket, wait_for_ready};
|
||||
use crate::common::{admin_request, init_logging, local_http_client};
|
||||
use aws_sdk_s3::operation::RequestId;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use bytes::Bytes;
|
||||
use http::Method;
|
||||
use http_body_util::{BodyExt, Empty};
|
||||
use hyper::body::Incoming;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use local_ip_address::local_ip;
|
||||
use rustfs_madmin::metrics::RealtimeMetrics;
|
||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||
use serde_json::Value;
|
||||
use std::convert::Infallible;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, timeout};
|
||||
|
||||
async fn spawn_audit_collector() -> TestResult<(String, mpsc::UnboundedReceiver<Value>, JoinHandle<()>)> {
|
||||
let listener = TcpListener::bind("0.0.0.0:0").await?;
|
||||
let endpoint = format!("http://{}/audit", std::net::SocketAddr::new(local_ip()?, listener.local_addr()?.port()));
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let service = service_fn(move |request: Request<Incoming>| {
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
let method = request.method().clone();
|
||||
if let Ok(body) = request.into_body().collect().await
|
||||
&& method == Method::POST
|
||||
&& let Ok(payload) = serde_json::from_slice::<Value>(&body.to_bytes())
|
||||
{
|
||||
if let Some(records) = payload["Records"].as_array() {
|
||||
for entry in records {
|
||||
let _ = tx.send(entry.clone());
|
||||
}
|
||||
} else {
|
||||
let _ = tx.send(payload);
|
||||
}
|
||||
}
|
||||
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok((endpoint, rx, handle))
|
||||
}
|
||||
|
||||
async fn wait_for_audit_entry(
|
||||
rx: &mut mpsc::UnboundedReceiver<Value>,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
request_id: &str,
|
||||
) -> TestResult<Value> {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let mut seen = Vec::new();
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(format!(
|
||||
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
|
||||
seen.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let entry = match timeout(remaining, rx.recv()).await {
|
||||
Ok(Some(entry)) => entry,
|
||||
Ok(None) => return Err("audit collector stopped before the expected entry arrived".into()),
|
||||
Err(_) => {
|
||||
return Err(format!(
|
||||
"audit webhook did not receive PutObject for {bucket}/{key}; received {} other records: {seen:?}",
|
||||
seen.len()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
};
|
||||
if entry["api"]["name"].as_str() == Some("s3:PutObject")
|
||||
&& entry["api"]["bucket"].as_str() == Some(bucket)
|
||||
&& entry["api"]["object"].as_str() == Some(key)
|
||||
&& entry["requestID"].as_str() == Some(request_id)
|
||||
{
|
||||
return Ok(entry);
|
||||
}
|
||||
if seen.len() < 8 {
|
||||
seen.push(format!(
|
||||
"api={:?} bucket={:?} object={:?} requestID={:?}",
|
||||
entry["api"]["name"].as_str(),
|
||||
entry["api"]["bucket"].as_str(),
|
||||
entry["api"]["object"].as_str(),
|
||||
entry["requestID"].as_str()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent() -> TestResult {
|
||||
init_logging();
|
||||
let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?;
|
||||
let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization();
|
||||
let audit_env = [
|
||||
("RUSTFS_AUDIT_ENABLE", "true"),
|
||||
("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"),
|
||||
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()),
|
||||
(ENV_OUTBOUND_ALLOW_ORIGINS, audit_origin.as_str()),
|
||||
];
|
||||
let mut dist = DistCluster::new_stopped_with_env(DistLayout::FourByFour, &audit_env).await?;
|
||||
for node_idx in 0..dist.cluster.nodes.len() {
|
||||
let queue_dir = format!("{}/audit-queue-node-{node_idx}", dist.cluster.temp_dir);
|
||||
tokio::fs::create_dir_all(&queue_dir).await?;
|
||||
dist.cluster
|
||||
.set_node_env(node_idx, "RUSTFS_AUDIT_WEBHOOK_QUEUE_DIR_DISTRIBUTED", queue_dir)?;
|
||||
}
|
||||
dist.cluster.start().await?;
|
||||
wait_for_ready(&dist.cluster).await?;
|
||||
|
||||
let http = local_http_client();
|
||||
for node in &dist.cluster.nodes {
|
||||
for probe in ["ready", "live"] {
|
||||
let response = http.get(format!("{}/health/{probe}", node.url)).send().await?;
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"node {} {probe} probe failed: {}",
|
||||
node.address,
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let info_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/info", None).await?;
|
||||
let info: Value = serde_json::from_str(&info_body)?;
|
||||
let servers = info["info"]["servers"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("admin info omitted servers: {info}"))?;
|
||||
assert_eq!(servers.len(), 4, "admin info did not report all four nodes: {info}");
|
||||
|
||||
let storage_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/storageinfo", None).await?;
|
||||
let storage: Value = serde_json::from_str(&storage_body)?;
|
||||
let disks = storage["info"]["disks"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("storageinfo omitted disks: {storage}"))?;
|
||||
assert_eq!(disks.len(), 16, "storageinfo did not report all sixteen drives: {storage}");
|
||||
assert!(
|
||||
disks.iter().all(|disk| {
|
||||
disk["state"].as_str().is_some_and(|state| state.eq_ignore_ascii_case("ok"))
|
||||
&& disk["runtimeState"]
|
||||
.as_str()
|
||||
.is_some_and(|state| state.eq_ignore_ascii_case("online"))
|
||||
}),
|
||||
"storageinfo reported a drive that was not healthy and online: {storage}"
|
||||
);
|
||||
|
||||
for (node_idx, node) in dist.cluster.nodes.iter().enumerate() {
|
||||
let (status, metrics_body) = admin_request(
|
||||
&node.url,
|
||||
Method::GET,
|
||||
"/rustfs/admin/v3/metrics?n=1&by-host=true&by-disk=true",
|
||||
None,
|
||||
&dist.cluster.access_key,
|
||||
&dist.cluster.secret_key,
|
||||
)
|
||||
.await?;
|
||||
assert!(status.is_success(), "node {node_idx} metrics failed: {status} {metrics_body}");
|
||||
let sample: RealtimeMetrics = serde_json::from_str(
|
||||
metrics_body
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or_else(|| format!("node {node_idx} returned empty metrics"))?,
|
||||
)?;
|
||||
assert!(sample.finally, "node {node_idx} metrics sample was not terminal");
|
||||
assert!(sample.errors.is_empty(), "node {node_idx} metrics reported errors: {:?}", sample.errors);
|
||||
assert!(!sample.hosts.is_empty(), "node {node_idx} metrics omitted hosts");
|
||||
}
|
||||
|
||||
let targets_body = cluster_admin_ok(&dist.cluster, Method::GET, "/rustfs/admin/v3/audit/target/list", None).await?;
|
||||
let targets: Value = serde_json::from_str(&targets_body)?;
|
||||
let configured = targets["audit_endpoints"]
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("audit target list omitted audit_endpoints: {targets}"))?
|
||||
.iter()
|
||||
.any(|target| target["account_id"].as_str() == Some("distributed") && target["service"].as_str() == Some("webhook"));
|
||||
assert!(configured, "configured audit webhook was missing: {targets}");
|
||||
|
||||
let bucket = unique_bucket("audit");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let key = "correlated/audit-object.bin";
|
||||
let put = dist
|
||||
.client(2)?
|
||||
.put_object()
|
||||
.bucket(&bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"distributed audit payload"))
|
||||
.send()
|
||||
.await?;
|
||||
let request_id = put.request_id().ok_or("PutObject response omitted request ID")?;
|
||||
let audit = wait_for_audit_entry(&mut audit_entries, &bucket, key, request_id).await?;
|
||||
assert_eq!(
|
||||
audit["api"]["status_code"].as_i64(),
|
||||
Some(200),
|
||||
"audit entry did not report success: {audit}"
|
||||
);
|
||||
assert!(
|
||||
!audit.to_string().contains(&dist.cluster.secret_key),
|
||||
"audit entry leaked the root secret key"
|
||||
);
|
||||
|
||||
collector.abort();
|
||||
Ok(())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user