mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-09 21:56:03 +00:00
Compare commits
220 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c6f5f22c0 | |||
| d7b6a8c10d | |||
| e8ffd72575 | |||
| 0b03d535c3 | |||
| 3afb389247 | |||
| 86b6569071 | |||
| 0940fbe1b2 | |||
| 9e7c5dc932 | |||
| 45806bf295 | |||
| 42f00d8532 | |||
| 3e46d61a91 | |||
| 88183daf10 | |||
| 8be1e9b2c1 | |||
| 9b4b366209 | |||
| 43436ad5a7 | |||
| 0c7b6188b2 | |||
| 3ffc3704af | |||
| de3ac27a6a | |||
| 27d66c159f | |||
| 2d0e82e9f7 | |||
| c92fabea3a | |||
| b3b0d892bb | |||
| d9ae1654b6 | |||
| 15949180c9 | |||
| f65305e97f | |||
| 28ac3ab4e7 | |||
| cf41b1d3cf | |||
| ca876930b3 | |||
| c05b3ee01e | |||
| 1076e2fb4f | |||
| 51e6733a9c | |||
| e546ae9c62 | |||
| ed2b2cdd19 | |||
| 35b5cfcf8d | |||
| 084477e079 | |||
| bb1b5dea16 | |||
| 1c87413788 | |||
| e1fee0569a | |||
| a6e5bdcbd2 | |||
| 923f51cf12 | |||
| d7c2fc7587 | |||
| 7db206466b | |||
| e549252ac6 | |||
| 17807b05fb | |||
| e16274ab14 | |||
| 5eecd657e7 | |||
| 929d906131 | |||
| 6b802a91eb | |||
| 14b3cffe8f | |||
| c62384e58d | |||
| 789f1832a4 | |||
| 3f30f6c139 | |||
| 584a52ce3d | |||
| 76e025a50c | |||
| b40eb193df | |||
| 023c987397 | |||
| a4b265bf77 | |||
| c176220a34 | |||
| 82ac2dff05 | |||
| e24eae9eaa | |||
| 8617f2701b | |||
| 8d5592f83e | |||
| b5c9229e77 | |||
| a974e50b1d | |||
| 081910e825 | |||
| a722fa80d5 | |||
| 9ecb500cbf | |||
| 7f7e4fe40b | |||
| 684e6f313a | |||
| b3a02f305e | |||
| 0ea9c19569 | |||
| ae662da256 | |||
| b5d33a1f4e | |||
| d288496752 | |||
| 40baedba4f | |||
| 0c3fd48c22 | |||
| 3e8b0e7c83 | |||
| dd2af9ff88 | |||
| 5db0e14d04 | |||
| 9429225cf4 | |||
| 857584b3c2 | |||
| cfda9b451c | |||
| 354e49de4c | |||
| 8e987ce0a6 | |||
| 8462b3492b | |||
| 929f836e40 | |||
| c7dec044eb | |||
| ac44f8968e | |||
| 14a59f7770 | |||
| 084e9c4b82 | |||
| 46907c05cf | |||
| 921cc7ad94 | |||
| bfa8df00e7 | |||
| 33a8469e27 | |||
| 128080ceb9 | |||
| ae9fe62fb1 | |||
| 929a9f27c4 | |||
| 15376a7fa6 | |||
| 5d789006dd | |||
| f4bd53a63c | |||
| 21fee7f7ff | |||
| 23ed00fc85 | |||
| f49ffa2fec | |||
| ee5f287324 | |||
| 158c613d0e | |||
| a950f914ca | |||
| 1de9b40a30 | |||
| 831468b2e9 | |||
| 600d037b25 | |||
| 3fa2b334be | |||
| 1b549d5907 | |||
| 11c4ce96eb | |||
| 4508a0985d | |||
| ed1b9f25d6 | |||
| 274c2bf402 | |||
| 9aebcefa9c | |||
| 1748814bbf | |||
| ee5f76c180 | |||
| 73957d0faf | |||
| 4d68c32b75 | |||
| 8c6689ff13 | |||
| 595f9f662d | |||
| 4a2b15cb82 | |||
| 2ba7f95547 | |||
| 7e0c67111b | |||
| 0008cbcb29 | |||
| 7b3dad6bae | |||
| c0754f5b1c | |||
| 0db77be5c6 | |||
| 5dc0e3b402 | |||
| cc5487e7de | |||
| 0b05b6c6ff | |||
| c507da8f75 | |||
| 550dabeffd | |||
| 4d7f0344d3 | |||
| 084338add6 | |||
| 8d339da706 | |||
| 817ad0a682 | |||
| 80321e5bb4 | |||
| e7475cfa4d | |||
| 72e85210f1 | |||
| 8c15025a5a | |||
| 6f2ec66263 | |||
| 62542ddc57 | |||
| a8bb53218d | |||
| 0a9f0f59a7 | |||
| 5355d9f8f8 | |||
| 50d7a049ee | |||
| 3149c87cf2 | |||
| b7fa6a4615 | |||
| 6fe83f87a4 | |||
| df6981d88e | |||
| e65788f2e8 | |||
| 590adad5ae | |||
| be5d14c985 | |||
| 703086d71d | |||
| a159f312f0 | |||
| 2f6f095298 | |||
| efd8ef005f | |||
| 60fa33773a | |||
| ee752b0b03 | |||
| 99c1f4418b | |||
| 7ce0ac72cf | |||
| 944e26d432 | |||
| f0b0a99260 | |||
| 33ddc10ffd | |||
| 3adfe65193 | |||
| 66b2a0f907 | |||
| 585b5e1c52 | |||
| bc468a4986 | |||
| 6610ee58dc | |||
| c1d9a075de | |||
| 807f85cbd7 | |||
| f46e230340 | |||
| d7e88529c9 | |||
| 7dc15b8ca6 | |||
| 6b05fb6b42 | |||
| 5da56288be | |||
| 9ca9b3481b | |||
| 8641043b71 | |||
| d6bb61d420 | |||
| 4d1807c17f | |||
| 10967d0815 | |||
| 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 |
@@ -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
|
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
|
# 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.
|
routes a review to RustFS-specific probes without loading unrelated domains.
|
||||||
|
|
||||||
## Select Lenses
|
## Select Lenses
|
||||||
@@ -31,15 +31,18 @@ adversarial review.
|
|||||||
|
|
||||||
## Review Protocol
|
## Review Protocol
|
||||||
|
|
||||||
1. Freeze the exact final diff/head and list the selected lenses.
|
1. Freeze the exact final diff/head (or the design under review) and list the
|
||||||
2. Run the review shape required by root `AGENTS.md`.
|
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
|
3. For each selected lens, either report a concrete finding or a null verdict
|
||||||
naming the attacks performed.
|
naming the attacks performed.
|
||||||
4. A finding needs `file:line`, a triggering input/state/interleaving, the wrong
|
4. Apply root `AGENTS.md`'s finding standard. Test each candidate against callers,
|
||||||
outcome, and a focused fix or missing regression check.
|
existing coverage, and invariants before accepting it; an adversarial role
|
||||||
5. Fix or rebut every finding with code-path, test, or invariant evidence.
|
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
|
6. After a non-trivial edit, rerun only lenses affected by that edit against the
|
||||||
new exact diff.
|
new exact diff.
|
||||||
|
|
||||||
Do not turn a null verdict into a long checklist. Record concise evidence that
|
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.
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
- For every behavior claim, name the focused test/check that fails if the
|
- 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
|
changed hunk is reverted. If none is practical, require the reason and
|
||||||
residual risk.
|
residual risk.
|
||||||
- Confirm tests exercise the real production path and assert returned values,
|
- Confirm tests exercise the real production path and distinguish the intended
|
||||||
exact bytes, stored state, or the specific error variant—not only success,
|
behavior from the named regression. A success, `is_err()`, or no-panic check
|
||||||
`is_err()`, or no panic.
|
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
|
- For new flags/modes, verify each branch and ask which test fails if the branch
|
||||||
is inverted.
|
is inverted.
|
||||||
- For new error propagation, inject the failure and assert the caller observes
|
- For new error propagation, inject the failure and assert the caller observes
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: arch-checks
|
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
|
# Architecture Guard Checks
|
||||||
|
|
||||||
All five run in `make pre-commit` / `make pre-pr` and in CI. Fix the cause;
|
Read only the section for the failing guard. Use `.config/make/` and the current
|
||||||
never weaken a check to get green.
|
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`
|
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
|
||||||
|
|
||||||
@@ -54,7 +55,8 @@ Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
|
|||||||
Markdown file under `docs/` (architecture, operations, testing, index) must not
|
Markdown file under `docs/` (architecture, operations, testing, index) must not
|
||||||
reference repo file paths that no longer exist. If your refactor moved code,
|
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`
|
update the docs that point at it — the error message lists `doc -> stale-path`
|
||||||
pairs. Cite paths plus symbol names, never line numbers (see `docs/README.md`).
|
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`
|
## `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
|
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.
|
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
|
## Core Workflow
|
||||||
|
|
||||||
### 1) Scope and assumptions
|
### 1) Scope and assumptions
|
||||||
- Confirm change source (diff, commit, PR, files), target branch, language/runtime, and version.
|
- Derive the change source, target branch, and relevant runtime/version from the
|
||||||
- If context is missing, state assumptions before deeper analysis.
|
supplied diff and metadata. Ask only when missing context could change the verdict.
|
||||||
- Focus only on requested scope; avoid reviewing unrelated files.
|
- Focus only on requested scope; avoid reviewing unrelated files.
|
||||||
|
|
||||||
### 2) Risk map
|
### 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
|
- unchecked assumptions and null/empty/error-path handling
|
||||||
- stale tests, fixtures, and configs
|
- stale tests, fixtures, and configs
|
||||||
- hidden coupling to shared helpers/constants/features
|
- 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
|
### 4) Findings-first output
|
||||||
- Order findings by severity:
|
- Order supported findings by P0–P3 severity; preserve the Rust ratings above.
|
||||||
- P0: critical failure, security breach, or data loss risk
|
Include `path:line`, the failure and impact, a focused fix, and its validation.
|
||||||
- P1: high-impact regression
|
- If no supported issues remain, state `No findings` with the reviewed scope and
|
||||||
- P2: medium risk correctness gap
|
any material verification limitation. Do not append optional improvements to
|
||||||
- P3: low risk/quality debt
|
make a clean review look productive.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### 5) Close
|
Close after the required review. Recommend additional verification only for an
|
||||||
- Report assumptions and unknowns.
|
identified unresolved risk or required gate; reuse evidence for unchanged code.
|
||||||
- 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: ...
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: issue-triage
|
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.
|
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
|
# Issue Triage
|
||||||
@@ -20,6 +20,8 @@ Read the issue body to understand what was requested. Extract:
|
|||||||
- Any linked PRs or commits mentioned in the body or comments.
|
- Any linked PRs or commits mentioned in the body or comments.
|
||||||
- Any checklist items or sub-issues.
|
- 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
|
### 2. Search for related work
|
||||||
|
|
||||||
Search git history for commits referencing the issue:
|
Search git history for commits referencing the issue:
|
||||||
@@ -29,26 +31,29 @@ git log --oneline --all --grep="<N>" | head -30
|
|||||||
|
|
||||||
Search for related PRs:
|
Search for related PRs:
|
||||||
```bash
|
```bash
|
||||||
gh pr list --search "fixes #<N> OR closes #<N> OR #<N>" --state all --json number,title,state,mergedAt
|
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:
|
If the issue mentions specific PRs, check their status:
|
||||||
```bash
|
```bash
|
||||||
gh pr view <PR_N> --json state,mergedAt,title
|
gh pr view <PR_N> --repo <implementation-repo> --json state,mergedAt,title,mergeCommit,baseRefName
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Verify implementation
|
### 3. Verify implementation
|
||||||
|
|
||||||
For each linked or related PR that is merged, verify the fix is actually present on the current main branch:
|
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
|
```bash
|
||||||
git log --oneline main | grep -i "<keyword>"
|
git fetch <implementation-remote> <base-branch>
|
||||||
# or
|
git merge-base --is-ancestor <merge-commit> <implementation-remote>/<base-branch>
|
||||||
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:
|
If the issue describes a specific defect, inspect the fetched base's code rather than assuming the current checkout contains it:
|
||||||
```bash
|
```bash
|
||||||
grep -n "<pattern>" crates/<relevant>/src/<file>.rs
|
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:
|
For issues with checklists, verify each item individually. If sub-items are tracked as separate issues, check those too:
|
||||||
@@ -58,13 +63,15 @@ gh issue view <SUB_N> --repo <owner/repo> --json state
|
|||||||
|
|
||||||
### 4. Determine verdict
|
### 4. Determine verdict
|
||||||
|
|
||||||
- **All items fixed and merged**: Close with a summary comment listing what was fixed and which PRs.
|
- **All items fixed and merged**: Recommend closing; name the verified PRs and behavior.
|
||||||
- **Some items fixed, some remaining**: Comment with status of each item. Do not close.
|
- **Some items fixed, some remaining**: Keep open; report each remaining item.
|
||||||
- **Not yet implemented**: Comment with a summary of what remains. Do not close.
|
- **Not yet implemented**: Keep open; report what remains.
|
||||||
- **Superseded or no longer relevant**: Close with explanation.
|
- **Superseded or no longer relevant**: Recommend closing with evidence.
|
||||||
|
|
||||||
### 5. Take action
|
### 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:
|
Close with comment:
|
||||||
```bash
|
```bash
|
||||||
gh issue close <N> --repo <owner/repo> --comment "<body>"
|
gh issue close <N> --repo <owner/repo> --comment "<body>"
|
||||||
@@ -75,9 +82,9 @@ Comment without closing:
|
|||||||
gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md
|
gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md
|
||||||
```
|
```
|
||||||
|
|
||||||
Update issue labels if needed:
|
Update labels only when label changes are authorized, using existing repository labels; never add tool-specific labels:
|
||||||
```bash
|
```bash
|
||||||
gh issue edit <N> --repo <owner/repo> --add-label "completed" --remove-label "needs-triage"
|
gh issue edit <N> --repo <owner/repo> --add-label "<existing-label>"
|
||||||
```
|
```
|
||||||
|
|
||||||
Always use `--body-file` for multiline content, never inline `--body`.
|
Always use `--body-file` for multiline content, never inline `--body`.
|
||||||
@@ -85,27 +92,16 @@ Always use `--body-file` for multiline content, never inline `--body`.
|
|||||||
### 6. Handle multi-issue batches
|
### 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"):
|
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`
|
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.
|
2. For each issue, run steps 1-5 above.
|
||||||
3. Report a summary table of all triaged issues with verdicts.
|
3. Report a summary table of all triaged issues with verdicts.
|
||||||
|
|
||||||
## Output format
|
## Report
|
||||||
|
|
||||||
### Issue Triage: #<N> — <title>
|
Identify the issue and current state, verified implementation/PR evidence,
|
||||||
|
remaining items, verdict, and action actually taken. Use a table for batches;
|
||||||
**State**: OPEN / CLOSED
|
a single issue does not require a heading for each field. Follow step 4's
|
||||||
**Linked PRs**: <list with merge status>
|
verdicts without repeating the assessment in another template.
|
||||||
|
|
||||||
#### 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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: plugin-contract-guard
|
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
|
# 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."
|
|
||||||
@@ -1,147 +1,93 @@
|
|||||||
---
|
---
|
||||||
name: pr-review
|
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.
|
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
|
# 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.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- Read `AGENTS.md` for the repository's adversarial validation policy and change-style rules.
|
- 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).
|
||||||
- The `adversarial-validation` skill handles the review role playbooks; this skill orchestrates the workflow around it.
|
- 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
|
## Workflow
|
||||||
|
|
||||||
### 1. Gather PR context
|
### 1. Gather PR context
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh pr view <N> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName
|
gh pr view <N> --repo <owner/repo> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName,baseRefOid,headRefOid
|
||||||
gh pr diff <N> --name-only
|
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:
|
Read the PR body and linked issues to understand the change's purpose. If the PR references an issue, fetch that too:
|
||||||
```bash
|
```bash
|
||||||
gh issue view <ISSUE> --json title,body,state
|
gh issue view <ISSUE> --repo <issue-owner/repo> --json title,body,state
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Fetch the diff and classify the change
|
### 2. Fetch the diff and classify the change
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch origin pull/<N>/head:pr-<N>
|
git fetch <repo-remote> <baseRefName> refs/pull/<N>/head
|
||||||
git diff main...pr-<N> --stat
|
git diff <baseRefOid>...<headRefOid> --stat
|
||||||
```
|
```
|
||||||
|
|
||||||
Classify the change by risk tier (per AGENTS.md):
|
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.
|
||||||
- **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
|
### 3. Review the changed behavior
|
||||||
|
|
||||||
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:
|
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.
|
||||||
- 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.
|
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.
|
||||||
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
|
### 4. Check CI status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gh pr checks <N>
|
gh pr checks <N> --repo <owner/repo>
|
||||||
```
|
```
|
||||||
|
|
||||||
If any checks fail, investigate:
|
Investigate a failed check when it bears on a finding or the user requested CI diagnosis/merge readiness:
|
||||||
```bash
|
```bash
|
||||||
gh run view --log-failed --job=<JOB_ID>
|
gh run view --repo <owner/repo> --log-failed --job=<JOB_ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
Determine whether failures are pre-existing (on main), flaky, or caused by the PR.
|
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
|
### 5. Synthesize findings
|
||||||
|
|
||||||
Combine all subagent findings into a structured review:
|
Report the PR, reviewed base/head, and risk tier, then summarize the assessment.
|
||||||
- **Summary**: one-paragraph overview of the change and overall assessment.
|
Use the selected review's P0–P3 ratings and root finding standard: supported
|
||||||
- **Findings**: each finding with severity (critical/major/minor/nit), file:line, concrete failure scenario, and suggested fix.
|
findings with `file:line`, failure scenario, and fix, or `No findings`.
|
||||||
- **CI status**: pass/fail with notes on any failures.
|
State the observed check status, including pending or unavailable checks, and
|
||||||
- **Verdict**: APPROVE, REQUEST_CHANGES, or COMMENT.
|
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
|
### 6. Post the review
|
||||||
|
|
||||||
Write the review body to a temp file and post via CLI:
|
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
|
```bash
|
||||||
# Request changes
|
# Request changes
|
||||||
gh pr review <N> --request-changes --body-file /tmp/pr_review.md
|
gh pr review <N> --repo <owner/repo> --request-changes --body-file /tmp/pr_review.md
|
||||||
|
|
||||||
# Approve
|
# Approve
|
||||||
gh pr review <N> --approve --body-file /tmp/pr_review.md
|
gh pr review <N> --repo <owner/repo> --approve --body-file /tmp/pr_review.md
|
||||||
|
|
||||||
# Comment only (no verdict)
|
# Comment only (no verdict)
|
||||||
gh pr review <N> --comment --body-file /tmp/pr_review.md
|
gh pr review <N> --repo <owner/repo> --comment --body-file /tmp/pr_review.md
|
||||||
```
|
```
|
||||||
|
|
||||||
For inline comments on specific lines, use the GitHub API:
|
For authorized inline comments, use [the submission example](references/posting.md).
|
||||||
```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`.
|
Always use `--body-file` or `--input`, never inline multiline `--body`.
|
||||||
|
|
||||||
### 7. Handle follow-up
|
### 7. Handle follow-up
|
||||||
|
|
||||||
If the review requests changes:
|
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.
|
||||||
- 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
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
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
|
# 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`
|
Use this skill for a dedicated Rust review to cover rules that `cargo clippy`
|
||||||
does not catch.
|
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
|
## Quick Start
|
||||||
|
|
||||||
1. Identify changed `.rs` files.
|
1. Identify changed `.rs` files.
|
||||||
2. Run automated checks on changed files.
|
2. Run the matching candidate searches on changed files.
|
||||||
3. Run manual review checklist on the diff.
|
3. Apply the manual checklist sections whose behavior the diff touches.
|
||||||
4. Resolve or rebut every finding with evidence; P0/P1 findings cannot be deferred.
|
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
|
## Automated Checks
|
||||||
|
|
||||||
@@ -35,7 +41,7 @@ rg -n 'Result<.*String>' <changed-files>
|
|||||||
rg -n 'Box<dyn.*Error' <changed-files>
|
rg -n 'Box<dyn.*Error' <changed-files>
|
||||||
|
|
||||||
# 5. println/eprintln in production
|
# 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)
|
# 6. Ordering::Relaxed usage (verify each is intentional)
|
||||||
rg -n 'Ordering::Relaxed' <changed-files>
|
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
|
- [ ] Test volume and line count are never treated as production-code growth
|
||||||
|
|
||||||
### Serde
|
### 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
|
- [ ] `#[serde(default)]` not used on security-critical fields without validation
|
||||||
|
|
||||||
### Code Hygiene
|
### Code Hygiene
|
||||||
@@ -104,20 +110,7 @@ For the Rust diff under review, verify:
|
|||||||
|
|
||||||
## Output Template
|
## Output Template
|
||||||
|
|
||||||
```
|
Use the calling review's output format. For a standalone review, report supported
|
||||||
## Rust Code Quality Report
|
findings with severity, location, impact, fix, and validation, or `No findings`.
|
||||||
|
Include only material unverified checks. Candidate counts are not a quality
|
||||||
### Automated Scan
|
metric and do not need a separate scan report.
|
||||||
- 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)
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -4,9 +4,14 @@ description: "Run the end-to-end RustFS console gate, version bump, preview vali
|
|||||||
---
|
---
|
||||||
# RustFS Release Publish (preview-validated pipeline)
|
# RustFS Release Publish (preview-validated pipeline)
|
||||||
|
|
||||||
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
|
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (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:
|
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`.
|
- 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`).
|
- 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:
|
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:
|
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.
|
- 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.
|
- 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
|
### 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.
|
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.
|
||||||
|
|
||||||
1. Read the latest published Console tag and compare it with Console `main`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CONSOLE_REPO="rustfs/console"
|
|
||||||
CONSOLE_LATEST=$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)
|
|
||||||
gh api "repos/${CONSOLE_REPO}/compare/${CONSOLE_LATEST}...main" \
|
|
||||||
--jq '{status, ahead_by, behind_by, commits: [.commits[] | {sha, message: .commit.message}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ahead_by == 0`: no merged Console change is waiting for release. Still verify the current latest asset using step 4, then continue to Phase 1.
|
|
||||||
- `ahead_by > 0` and `behind_by == 0`: publish Console before continuing. Report the merged commits and select the next unused `vX.Y.Z` tag. Default to the next patch version when the changes are fixes or backward-compatible UI work; stop for confirmation if a minor/major bump is plausible.
|
|
||||||
- Any diverged history or `behind_by > 0`: stop and resolve the Console release baseline explicitly. Do not guess a range or publish RustFS.
|
|
||||||
|
|
||||||
2. Clone/fetch `rustfs/console` into a scratch directory and record its exact `main` commit. Before creating a tag, check for a `v*` tag or Release workflow already associated with that hash. If one is in progress, wait for it instead of creating another version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CONSOLE_SCRATCH=$(mktemp -d)
|
|
||||||
gh repo clone "$CONSOLE_REPO" "$CONSOLE_SCRATCH/console"
|
|
||||||
git -C "$CONSOLE_SCRATCH/console" fetch origin main --tags
|
|
||||||
CONSOLE_HASH=$(git -C "$CONSOLE_SCRATCH/console" rev-parse origin/main)
|
|
||||||
git -C "$CONSOLE_SCRATCH/console" tag --points-at "$CONSOLE_HASH" 'v*'
|
|
||||||
gh run list -R "$CONSOLE_REPO" --workflow release.yml --commit "$CONSOLE_HASH" --limit 5
|
|
||||||
```
|
|
||||||
|
|
||||||
If no release exists or is running for `CONSOLE_HASH`, create the selected annotated tag at that exact hash and push it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C "$CONSOLE_SCRATCH/console" tag -a "<console-tag>" -m "Release <console-tag>" "$CONSOLE_HASH"
|
|
||||||
git -C "$CONSOLE_SCRATCH/console" push origin "<console-tag>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Console tags include the `v` prefix. Pushing the tag triggers `.github/workflows/release.yml` (`🚀 Release`). Remove `CONSOLE_SCRATCH` after the gate completes.
|
|
||||||
|
|
||||||
3. Find the exact tag run and wait for completion:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh run list -R "$CONSOLE_REPO" --workflow release.yml --branch "<console-tag>" --limit 1
|
|
||||||
gh run watch -R "$CONSOLE_REPO" "<console-run-id>" --exit-status
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Block until the published Release is non-draft, the latest endpoint returns the expected tag, and `rustfs-console-<console-tag>.zip` is uploaded, non-empty, and carries a `sha256:` digest:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh release view -R "$CONSOLE_REPO" "<console-tag>" --json isDraft,isPrerelease,assets,url
|
|
||||||
test "$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)" = "<console-tag>"
|
|
||||||
test "$(gh api "repos/${CONSOLE_REPO}/releases/tags/<console-tag>" \
|
|
||||||
--jq '[.assets[] | select(.name == "rustfs-console-<console-tag>.zip" and .state == "uploaded" and .size > 0 and (.digest | startswith("sha256:")))] | length')" -eq 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Treat a missing/mismatched asset, digest, latest tag, or failed/cancelled workflow as BLOCKED. Do not start Phase 1 until the Console gate passes. Record `CONSOLE_TAG`, `CONSOLE_HASH`, Console run URL, and Release URL for the final report.
|
|
||||||
|
|
||||||
## Phase 1 — Version bump to the final target (once)
|
## Phase 1 — Version bump to the final target (once)
|
||||||
|
|
||||||
@@ -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.
|
- 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.
|
- 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.
|
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.
|
||||||
|
|
||||||
```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.
|
|
||||||
|
|
||||||
### Manual confirmation gate
|
### 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
|
# 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`.
|
Validated baseline: release pattern used in PR `#2957`.
|
||||||
|
|
||||||
## Required inputs
|
## Required inputs
|
||||||
|
|
||||||
- Exact target version, for example `1.0.0-beta.4`.
|
- Exact target version, for example `1.0.0-beta.4`.
|
||||||
- Delivery scope:
|
- Delivery scope: local (`edit/verify`), git (`commit/push`), or GitHub
|
||||||
- Local only (`edit/verify`).
|
(`commit/push/PR`). Derive it from the conversation; when unspecified, prepare
|
||||||
- Local + git (`commit/push`).
|
and verify locally without blocking on a delivery question.
|
||||||
- Full GitHub flow (`commit/push/PR`).
|
|
||||||
|
|
||||||
If target version is missing or ambiguous, stop and ask before editing.
|
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
|
## Read before editing
|
||||||
|
|
||||||
- `AGENTS.md` (root and nearest path-specific files).
|
- `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`.
|
- Current branch status and diff against `origin/main`.
|
||||||
|
|
||||||
## Default release file scope
|
## 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
|
## Step-by-step workflow
|
||||||
|
|
||||||
1. Confirm intent and isolate scope
|
1. Confirm intent and isolate scope
|
||||||
- Confirm target version string exactly.
|
- Use the exact target and delivery scope already supplied; ask only for a missing or ambiguous target or a material release-policy choice.
|
||||||
- Confirm whether user requested local-only or full GitHub flow.
|
|
||||||
- Inspect current branch and ensure only release-related files are touched for this task.
|
- Inspect current branch and ensure only release-related files are touched for this task.
|
||||||
|
|
||||||
2. Update workspace versions
|
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
|
4. Verify before shipping
|
||||||
- Run:
|
- Run:
|
||||||
- `make pre-commit`
|
- `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:
|
- Preferred split when both parts changed:
|
||||||
- `chore(release): prepare <version>` for `Cargo.toml` and `Cargo.lock`.
|
- `chore(release): prepare <version>` for `Cargo.toml` and `Cargo.lock`.
|
||||||
- `chore(release): align release assets for <version>` for docs and packaging files.
|
- `chore(release): align release assets for <version>` for docs and packaging files.
|
||||||
- If user asks for one commit, use one commit.
|
- If user asks for one commit, use one commit.
|
||||||
- Stage only intended release files; do not include unrelated working tree changes.
|
- 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:
|
- 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:
|
- Create PR with template headings unchanged:
|
||||||
- `gh pr create --base main --head <branch> --title ... --body-file ...`
|
- `gh pr create --base main --head <branch> --title ... --body-file ...`
|
||||||
- PR title/body must be English.
|
- PR title/body must be English.
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ matched security surface, the concise security reference under
|
|||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Freeze the exact diff/head and identify the changed trust boundaries.
|
1. Freeze the exact diff/head and identify the changed trust boundaries.
|
||||||
2. Read [advisory-patterns.md](references/advisory-patterns.md), then apply only
|
2. Inspect the headings in [advisory-patterns.md](references/advisory-patterns.md),
|
||||||
the matching sections. Useful headings are
|
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,
|
auth/admin, IAM/STS/OIDC, policy/plugins, S3/copy/multipart, protocols, paths,
|
||||||
secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde.
|
secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde.
|
||||||
3. Trace unauthenticated, low-privilege, wrong-action/owner/bucket, malformed,
|
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
|
### 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.
|
- `#[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.
|
- 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
|
## Useful Search Seeds
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: test-coverage-improver
|
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
|
# Test Coverage Improver
|
||||||
@@ -9,58 +9,49 @@ Use this skill when you need a prioritized, risk-aware plan to improve tests fro
|
|||||||
|
|
||||||
## Usage assumptions
|
## Usage assumptions
|
||||||
- Focus scope is either changed lines/files, a module, or the whole repository.
|
- 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.
|
- If required context is missing, call out assumptions explicitly before proposing work.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Define scope and baseline
|
1. Define scope and baseline
|
||||||
- Confirm target language, framework, and branch.
|
- Derive the revision and scope from the request, diff, or supplied report.
|
||||||
- Confirm whether the scope is changed files only or full-repo.
|
- 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
|
2. Obtain coverage evidence
|
||||||
- Rust: `cargo llvm-cov` (or `cargo tarpaulin`) with existing repo config.
|
- First inspect a matching existing artifact; do not regenerate it merely
|
||||||
- JavaScript/TypeScript: `npm test -- --coverage` and read `coverage/coverage-final.json`.
|
because this skill was selected.
|
||||||
- Python: `pytest --cov=<pkg> --cov-report=json` and read `coverage.json`.
|
- If measurement is needed, read the Coverage section of
|
||||||
- Collect total, per-file, and changed-line coverage.
|
[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
|
3. Rank highest-risk gaps
|
||||||
- Prioritize changed code, branch coverage gaps, and low-confidence boundaries.
|
- Prioritize changed code, branch coverage gaps, and low-confidence boundaries.
|
||||||
- Apply the risk rubric in [coverage-prioritization.md](references/coverage-prioritization.md).
|
- 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.
|
- For each gap, capture: file, lines, uncovered branches, and estimated risk score.
|
||||||
|
|
||||||
4. Propose high-impact tests
|
4. Propose high-impact tests
|
||||||
- For each shortlisted gap, output:
|
- For each gap, name the behavior and regression, distinguishing assertions,
|
||||||
- Intent and expected behavior.
|
relevant normal/edge/failure cases, necessary setup, and estimated effort.
|
||||||
- Normal, edge, and failure scenarios.
|
- Include only scenarios and setup that apply; reuse shared fixture details.
|
||||||
- Assertions and side effects to verify.
|
|
||||||
- Setup needs (fixtures, mocks, integration dependencies).
|
|
||||||
- Estimated effort (`S/M/L`).
|
|
||||||
|
|
||||||
5. Close with validation plan
|
5. Close with validation plan
|
||||||
- State which gaps remain after proposals.
|
- 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).
|
- List assumptions or blockers (environment, fixtures, flaky dependencies).
|
||||||
|
|
||||||
## Output template
|
## Report
|
||||||
|
|
||||||
### Coverage Snapshot
|
Summarize the supported metrics, then combine each ranked gap with its proposed
|
||||||
- total / branch coverage
|
test and validation. Include source lines only when supplied or inspected;
|
||||||
- changed-file coverage
|
mark missing metrics or locations as unknown. Do not duplicate gaps and tests
|
||||||
- top missing regions by size
|
in separate templates or fill empty categories for an otherwise small report.
|
||||||
|
|
||||||
### 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
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
interface:
|
interface:
|
||||||
display_name: "Test Coverage Improver"
|
display_name: "Test Coverage Improver"
|
||||||
short_description: "Find top uncovered risk areas and propose high-impact tests."
|
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
|
# Tier / ILM Debugging
|
||||||
|
|
||||||
Full playbook: [docs/operations/tier-ilm-debugging.md](../../../docs/operations/tier-ilm-debugging.md)
|
Playbook: [docs/operations/tier-ilm-debugging.md](../../../docs/operations/tier-ilm-debugging.md).
|
||||||
— read it before changing tier code.
|
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:
|
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
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-linux=4696a43b167ac608b3b8677027c9fe9fdac3396d37c8cca11dce531c720ac6d2
|
sha256-linux=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
|
||||||
sha256-darwin=9785867929047dfd8c6f768e0d2b1e0a8fdba85216f4a4139093b1619d03ff07
|
sha256-darwin=563bff8f1171d6dbe166ff8440310dbe98430e466aa3ecd8dc39e3c872b320f7
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=53b05ac745905809d3828c6994bdd8ecf9d20b2b61a8a9d80fe15eb62f932193
|
sha256-darwin=874c881d7b45f12378a5817c7f42c95c4981960a2ec9ce12dcf4af239ae1f9d5
|
||||||
sha256-linux=7c892afa4b9d1591b46bd79c976b647109a277284fddb3b98edced4b0297eda2
|
sha256-linux=9515861be899ceb10e2e0ef93c34208bb7a7a8a7f8067a02db4cfba23270ebd6
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
sha256-darwin=a5665318c9bdc0947514fb7008ba1b83b114b739fac775c3c446f207058b7c7a
|
sha256-darwin=83a7dcaffd5a789517ae9f02a224f66a9713937885cff96fca2ad7e216f197ae
|
||||||
sha256-linux=45d80e1723de5d25bb5b81f3ef5c82f583efc3e4f036a8cd2bb99e4f1eca9e51
|
sha256-linux=626c10f8c964507ff987b6c86069e9019dc6d2ae7fb02db9be5df5aa8cc5145b
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=95c8adc016bbc0df9fb2afa24a108bcdf6567ec4d0518725a6cae301593ab556
|
sha256=0e338d305260229e17ccfb2adc48a6212dbdfea36a9ebfb5a4e0d38658e6cc45
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
sha256=5db88c6fec94d4f269c7d9cfc128bd2adc27b3d7021127e2fa0b1daccc5f900f
|
sha256=6d18f9cce820c51d5589de944e8cc185f73eeca0ea9a9916651943e3759169d0
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ offline-enrollment-e2e-check: core-deps ## Build and exercise the dedicated offl
|
|||||||
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
test-wiring-check: ## Check tests stay registered and selected by their intended runners
|
||||||
@echo "🧪 Checking test wiring..."
|
@echo "🧪 Checking test wiring..."
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --check-workflow
|
||||||
|
|
||||||
.PHONY: log-analyzer-rules-check
|
.PHONY: log-analyzer-rules-check
|
||||||
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
log-analyzer-rules-check: core-deps ## Check log-analyzer rule anchors still exist verbatim in source
|
||||||
|
|||||||
@@ -32,6 +32,16 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_hotpath_warp_ab_gate.sh
|
./scripts/test_hotpath_warp_ab_gate.sh
|
||||||
./scripts/test_hotpath_warp_abba.sh
|
./scripts/test_hotpath_warp_abba.sh
|
||||||
./scripts/test_scanner_validation_harness.sh
|
./scripts/test_scanner_validation_harness.sh
|
||||||
|
./scripts/test_scanner_heal_checkpoint_crash_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_authority_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_scoped_ack_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_legacy_rollback_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_g14_multiset_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_scheduler_pressure_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_status_outcome_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_maintenance_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_w13_mrf_evidence.sh
|
||||||
|
./scripts/test_scanner_heal_w16_recovery_evidence.sh
|
||||||
./scripts/test_exact_1mib_handoff_abba.sh
|
./scripts/test_exact_1mib_handoff_abba.sh
|
||||||
./scripts/test_pinned_paired_abba_bench.sh
|
./scripts/test_pinned_paired_abba_bench.sh
|
||||||
./scripts/test_manual_transition_runbooks.sh
|
./scripts/test_manual_transition_runbooks.sh
|
||||||
@@ -39,6 +49,7 @@ script-tests: ## Run shell script tests
|
|||||||
./scripts/test_python_bin.sh
|
./scripts/test_python_bin.sh
|
||||||
./scripts/check_embedded_secrets.sh --self-test
|
./scripts/check_embedded_secrets.sh --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||||
|
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_security_coverage.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/check_scheduled_validation_freshness.py --self-test
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
$(RUSTFS_PYTHON_BIN) ./scripts/test_security_workflow.py
|
||||||
@@ -47,6 +58,7 @@ script-tests: ## Run shell script tests
|
|||||||
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
bash -n ./scripts/validate_object_data_cache_cold_stampede.sh
|
||||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
$(RUSTFS_PYTHON_BIN) ./scripts/check_object_data_cache_follower_samples.py --self-test
|
||||||
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
./scripts/validate_object_data_cache_cold_stampede.sh --self-test
|
||||||
|
./scripts/run_scanner_heal_evidence_case.sh --self-test
|
||||||
|
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
|
test: core-deps script-tests ## Run all tests (needs cargo-nextest; RUSTFS_ALLOW_CARGO_TEST_FALLBACK=1 to override)
|
||||||
|
|||||||
@@ -197,6 +197,12 @@ test-group = 'e2e-cluster-nightly'
|
|||||||
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
filter = 'package(e2e_test) & (test(/^kms::kms_vault_test::/) | test(/^kms::kms_rekey_sweep_test::/) | test(/^kms::configured_roundtrip_test::test_configured_vault_kms_admin_and_versioned_cleanup$/))'
|
||||||
test-group = 'e2e-vault'
|
test-group = 'e2e-vault'
|
||||||
|
|
||||||
|
# This four-disk, 65-member rollback probe already drives up to 32 concurrent
|
||||||
|
# durable deletions. Reserve this nextest run's capacity for its progress oracle.
|
||||||
|
[[profile.default.overrides]]
|
||||||
|
filter = 'package(rustfs-ecstore) & test(=store::init::tests::dispatch_manifest_rollback_bounded_concurrency_reaches_tail_behind_slow_member)'
|
||||||
|
threads-required = "num-test-threads"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
# ci profile — the strict CI gate (ci.yml `cargo nextest run --profile ci`)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"schema": 1,
|
"schema": 2,
|
||||||
"cases": {
|
"cases": {
|
||||||
"background-target-restart": {
|
"background-target-restart": {
|
||||||
"gate": "G14",
|
"gate": "G14",
|
||||||
@@ -8,33 +8,442 @@
|
|||||||
"suite": "e2e_test",
|
"suite": "e2e_test",
|
||||||
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart",
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_remote_shards_after_background_target_restart",
|
||||||
"oracle": "background-target-restart.json",
|
"oracle": "background-target-restart.json",
|
||||||
|
"evidence": "process-restart",
|
||||||
|
"unclean_shutdown_marker": false,
|
||||||
"min_objects": 9,
|
"min_objects": 9,
|
||||||
"max_objects": 65,
|
"max_objects": 65,
|
||||||
"topology": {"nodes": 4, "drives_per_node": 1},
|
"topology": {"nodes": 4, "drives_per_node": 1},
|
||||||
|
"erasure": {"data_blocks": 2, "parity_blocks": 2},
|
||||||
"scope": "Target process restart, exact unversioned S3 bodies and replacement-disk shards; not power loss or EC8+4."
|
"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},
|
||||||
|
"erasure": {"data_blocks": 2, "parity_blocks": 2},
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"ec84-target-drive-restart": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W20/W21",
|
||||||
|
"lane": "e2e-distributed",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "distributed::heal_test::three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart",
|
||||||
|
"oracle": "ec84-target-drive-restart.json",
|
||||||
|
"evidence": "process-restart",
|
||||||
|
"unclean_shutdown_marker": false,
|
||||||
|
"min_objects": 5,
|
||||||
|
"max_objects": 5,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 4},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"scope": "3-node x 4-drive single-set EC8+4, graceful target restart, preformatted replacement drive, exact unversioned S3 bodies and physical target shards; not mixed-version, multi-pool or long-window ABBA."
|
||||||
|
},
|
||||||
|
"background-target-restart-ec8-4": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart",
|
||||||
|
"oracle": "background-target-restart-ec8-4.json",
|
||||||
|
"evidence": "process-restart",
|
||||||
|
"unclean_shutdown_marker": false,
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 4},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"scope": "Target process restart during partial background rebuild on a single 3x4 EC8+4 set; exact unversioned S3 bodies and replacement-drive shards; not power loss, multi-set, or multi-pool."
|
||||||
|
},
|
||||||
|
"background-target-crash-ec8-4": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash",
|
||||||
|
"oracle": "background-target-crash-ec8-4.json",
|
||||||
|
"evidence": "process-crash-restart",
|
||||||
|
"unclean_shutdown_marker": true,
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 4},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"scope": "Target process killed during partial background rebuild on a single 3x4 EC8+4 set; real unclean-shutdown marker, exact unversioned S3 bodies and replacement-drive shards; not power loss, multi-set, or multi-pool."
|
||||||
|
},
|
||||||
|
"background-target-restart-ec8-4-multi-set": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_across_multi_set_after_background_target_restart",
|
||||||
|
"oracle": "background-target-restart-ec8-4-multi-set.json",
|
||||||
|
"evidence": "process-restart",
|
||||||
|
"unclean_shutdown_marker": false,
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 8},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"sets": 2,
|
||||||
|
"pools": 1,
|
||||||
|
"scope": "Target process restart during partial background rebuild on a 3x8 EC8+4 layout with two erasure sets in one pool; exact unversioned S3 bodies and replacement-drive shards; not power loss or multi-pool."
|
||||||
|
},
|
||||||
|
"background-target-crash-ec8-4-multi-pool": {
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W21",
|
||||||
|
"lane": "e2e-nightly",
|
||||||
|
"suite": "e2e_test",
|
||||||
|
"name": "heal_erasure_disk_rebuild_test::tests::test_cluster_root_heal_recovers_ec84_shards_across_multi_pool_after_background_target_crash",
|
||||||
|
"oracle": "background-target-crash-ec8-4-multi-pool.json",
|
||||||
|
"evidence": "process-crash-restart",
|
||||||
|
"unclean_shutdown_marker": true,
|
||||||
|
"min_objects": 9,
|
||||||
|
"max_objects": 65,
|
||||||
|
"topology": {"nodes": 3, "drives_per_node": 12},
|
||||||
|
"erasure": {"data_blocks": 8, "parity_blocks": 4},
|
||||||
|
"erasure_set_drive_count": 12,
|
||||||
|
"sets": 3,
|
||||||
|
"pools": 3,
|
||||||
|
"outage_target_manifest_required": false,
|
||||||
|
"scope": "Target process crash during partial background rebuild on three single-node EC8+4 pools; exact baseline S3 bodies and replacement-drive shards, with outage object verified through S3 but not forced onto the replaced target drive."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"release_pending": {
|
"release_lanes": {
|
||||||
"G01": "W02/W04 complete root and quota authority coverage",
|
"single-set-restart": {
|
||||||
"G02": "W03 bounded checkpoint progress and independent version inventory",
|
"status": "implemented",
|
||||||
"G03": "W17/W18 exact scoped ACK with durable publication and mixed peers",
|
"cases": ["background-target-restart", "background-target-crash"],
|
||||||
"G04": "W03/W15/W16 crash at every cache/root/floor/intent boundary",
|
"covers": ["four-node one-drive topology", "unversioned objects", "target restart/crash"]
|
||||||
"G05": "W06/W07 per-object outcomes and bounded terminal retention",
|
},
|
||||||
"G06": "W06/W08/W23 concurrent status, legacy clients and truncation",
|
"authority-coverage": {
|
||||||
"G07": "W12/W13/W14 durable MRF responsibility at every commit boundary",
|
"status": "pending",
|
||||||
"G08": "W12/W13/W14 MRF capacity, disk-full and replica-loss matrix",
|
"gates": ["G01", "G12"],
|
||||||
"G09": "W13/W18/W23 actual mixed-version reader/writer and rollback payloads",
|
"requires": ["root authority coverage", "quota authority coverage"]
|
||||||
"G10": "W05/W09/W10/W11 bounded scheduling and pressure recovery",
|
},
|
||||||
"G11": "W04/W19/W24 maintenance and complete producer coverage",
|
"checkpoint-and-crash": {
|
||||||
"G12": "W02/W15/W16 both quota paths during reset and settlement",
|
"status": "pending",
|
||||||
"G13": "W07/W14 quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
|
"gates": ["G02", "G04", "R-E"],
|
||||||
"G14": "W20/W21 same-window field evidence; 3x4 EC8+4 and multi-set/pool coverage",
|
"requires": ["bounded checkpoint progress", "boundary crash matrix", "fixed-budget restart evidence"]
|
||||||
"P1": "W20 measured cold-walk share and foreground latency/throughput",
|
},
|
||||||
"P2": "W20/W24 measured post-stop convergence and cold segment reuse",
|
"status-and-outcome": {
|
||||||
"P3": "W20 measured two-hour pressure/heal capacity and recovery window",
|
"status": "pending",
|
||||||
"P4": "W20 measured MRF scale and replay cost with retained responsibility",
|
"gates": ["G05", "G06", "R-D"],
|
||||||
"R-E": "W03/W05 fixed-budget real process restart through enumeration and classification",
|
"requires": ["per-object outcomes", "legacy status clients", "manager/event/ledger disposition"]
|
||||||
"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"
|
"mrf-responsibility": {
|
||||||
}
|
"status": "pending",
|
||||||
|
"gates": ["G07", "G08", "P4"],
|
||||||
|
"requires": ["durable MRF responsibility", "disk-full and replica-loss matrix", "MRF replay cost"]
|
||||||
|
},
|
||||||
|
"mixed-version-rollback": {
|
||||||
|
"status": "pending",
|
||||||
|
"gates": ["G03", "G09", "R-L"],
|
||||||
|
"requires": ["mixed-version peers", "rollback payloads", "crash-safe source retirement"]
|
||||||
|
},
|
||||||
|
"scheduler-pressure": {
|
||||||
|
"status": "pending",
|
||||||
|
"gates": ["G10", "P1", "P2", "P3"],
|
||||||
|
"requires": ["bounded scheduling", "foreground latency and throughput", "two-hour pressure evidence"]
|
||||||
|
},
|
||||||
|
"maintenance-producers": {
|
||||||
|
"status": "pending",
|
||||||
|
"gates": ["G11", "G13"],
|
||||||
|
"requires": ["complete producer coverage", "quorum-minus-one and remount matrix"]
|
||||||
|
},
|
||||||
|
"ec8-4-multiset": {
|
||||||
|
"status": "pending",
|
||||||
|
"gates": ["G14"],
|
||||||
|
"requires": ["3x4 EC8+4 topology", "multi-set coverage", "multi-pool coverage"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"release_requirements": [
|
||||||
|
{
|
||||||
|
"gate": "G01",
|
||||||
|
"task": "W02/W04",
|
||||||
|
"lane": "authority-coverage",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Complete root and quota authority coverage",
|
||||||
|
"requires": ["root authority evidence", "quota authority evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"root_authority_evidence",
|
||||||
|
"quota_authority_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G02",
|
||||||
|
"task": "W03",
|
||||||
|
"lane": "checkpoint-and-crash",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Bounded checkpoint progress and independent version inventory",
|
||||||
|
"requires": ["bounded checkpoint oracle", "independent version inventory"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"bounded_checkpoint_oracle",
|
||||||
|
"independent_version_inventory"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G03",
|
||||||
|
"task": "W17/W18",
|
||||||
|
"lane": "mixed-version-rollback",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Exact scoped ACK with durable publication and mixed peers",
|
||||||
|
"requires": ["durable scoped ACK publication", "mixed-peer evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"durable_root_publication_proof",
|
||||||
|
"scoped_ack_request_identity",
|
||||||
|
"participating_peer_capability_snapshot",
|
||||||
|
"mixed_peer_ack_fallback_oracle"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G04",
|
||||||
|
"task": "W03/W15/W16",
|
||||||
|
"lane": "checkpoint-and-crash",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Crash at every cache, root, floor and intent boundary",
|
||||||
|
"requires": ["cache boundary crash evidence", "root/floor/intent crash evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"cache_boundary_crash_evidence",
|
||||||
|
"root_floor_intent_crash_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G05",
|
||||||
|
"task": "W06/W07",
|
||||||
|
"lane": "status-and-outcome",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Per-object outcomes and bounded terminal retention",
|
||||||
|
"requires": ["per-object outcome oracle", "terminal retention bounds"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"per_object_outcome_oracle",
|
||||||
|
"terminal_retention_bounds"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G06",
|
||||||
|
"task": "W06/W08/W23",
|
||||||
|
"lane": "status-and-outcome",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Concurrent status, legacy clients and truncation",
|
||||||
|
"requires": ["concurrent status evidence", "legacy client compatibility", "truncation behavior"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"concurrent_status_evidence",
|
||||||
|
"legacy_client_compatibility",
|
||||||
|
"truncation_behavior"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G07",
|
||||||
|
"task": "W12/W13/W14",
|
||||||
|
"lane": "mrf-responsibility",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Durable MRF responsibility at every commit boundary",
|
||||||
|
"requires": ["MRF responsibility oracle", "commit-boundary crash matrix"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"mrf_responsibility_oracle",
|
||||||
|
"commit_boundary_crash_matrix"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G08",
|
||||||
|
"task": "W12/W13/W14",
|
||||||
|
"lane": "mrf-responsibility",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "MRF capacity, disk-full and replica-loss matrix",
|
||||||
|
"requires": ["MRF capacity evidence", "disk-full matrix", "replica-loss matrix"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"mrf_capacity_evidence",
|
||||||
|
"disk_full_matrix",
|
||||||
|
"replica_loss_matrix"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G09",
|
||||||
|
"task": "W13/W18/W23",
|
||||||
|
"lane": "mixed-version-rollback",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Actual mixed-version reader/writer and rollback payloads",
|
||||||
|
"requires": ["mixed-version reader evidence", "mixed-version writer evidence", "rollback payload evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"mixed_version_reader_evidence",
|
||||||
|
"mixed_version_writer_evidence",
|
||||||
|
"rollback_payload_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G10",
|
||||||
|
"task": "W05/W09/W10/W11",
|
||||||
|
"lane": "scheduler-pressure",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Bounded scheduling and pressure recovery",
|
||||||
|
"requires": ["scheduler bound evidence", "pressure recovery evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"scheduler_bound_evidence",
|
||||||
|
"pressure_recovery_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G11",
|
||||||
|
"task": "W04/W19/W24",
|
||||||
|
"lane": "maintenance-producers",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Maintenance and complete producer coverage",
|
||||||
|
"requires": [
|
||||||
|
"maintenance producer matrix",
|
||||||
|
"complete producer inventory",
|
||||||
|
"segment activation preflight"
|
||||||
|
],
|
||||||
|
"evidence_fields": [
|
||||||
|
"maintenance_producer_matrix",
|
||||||
|
"complete_producer_inventory",
|
||||||
|
"segment_activation_preflight"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G12",
|
||||||
|
"task": "W02/W15/W16",
|
||||||
|
"lane": "authority-coverage",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Both quota paths during reset and settlement",
|
||||||
|
"requires": ["reset quota-path evidence", "settlement quota-path evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"reset_quota_path_evidence",
|
||||||
|
"settlement_quota_path_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G13",
|
||||||
|
"task": "W07/W14",
|
||||||
|
"lane": "maintenance-producers",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Quorum-minus-one, unknown disks, remount, Object Lock, dry-run, grace and commit tail",
|
||||||
|
"requires": ["quorum-minus-one matrix", "unknown-disk/remount matrix", "Object Lock dry-run grace evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"quorum_minus_one_matrix",
|
||||||
|
"unknown_disk_remount_matrix",
|
||||||
|
"object_lock_dry_run_grace_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "G14",
|
||||||
|
"task": "W20/W21",
|
||||||
|
"lane": "ec8-4-multiset",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Same-window field evidence with 3x4 EC8+4 and multi-set/pool coverage",
|
||||||
|
"requires": [
|
||||||
|
"same-window field evidence",
|
||||||
|
"3x4 EC8+4 evidence",
|
||||||
|
"multi-set evidence",
|
||||||
|
"multi-pool evidence",
|
||||||
|
"distributed segment invalidation evidence"
|
||||||
|
],
|
||||||
|
"evidence_fields": [
|
||||||
|
"same_window_field_evidence",
|
||||||
|
"ec8_4_evidence",
|
||||||
|
"multi_set_evidence",
|
||||||
|
"multi_pool_evidence",
|
||||||
|
"distributed_segment_invalidation_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "P1",
|
||||||
|
"task": "W20",
|
||||||
|
"lane": "scheduler-pressure",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Measured cold-walk share and foreground latency/throughput",
|
||||||
|
"requires": ["cold-walk share measurement", "foreground latency/throughput measurement"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"cold_walk_share_measurement",
|
||||||
|
"foreground_latency_throughput_measurement",
|
||||||
|
"profile_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "P2",
|
||||||
|
"task": "W20/W24",
|
||||||
|
"lane": "scheduler-pressure",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Measured post-stop convergence and cold segment reuse",
|
||||||
|
"requires": ["post-stop convergence measurement", "cold segment reuse measurement"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"post_stop_convergence_measurement",
|
||||||
|
"cold_segment_reuse_measurement"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "P3",
|
||||||
|
"task": "W20",
|
||||||
|
"lane": "scheduler-pressure",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Measured two-hour pressure/heal capacity and recovery window",
|
||||||
|
"requires": ["two-hour pressure measurement", "heal capacity measurement", "recovery-window measurement"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"two_hour_pressure_measurement",
|
||||||
|
"heal_capacity_measurement",
|
||||||
|
"recovery_window_measurement"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "P4",
|
||||||
|
"task": "W20",
|
||||||
|
"lane": "mrf-responsibility",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Measured MRF scale and replay cost with retained responsibility",
|
||||||
|
"requires": ["MRF scale measurement", "MRF replay-cost measurement", "retained responsibility evidence", "cleanup/GC soak evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"mrf_scale_measurement",
|
||||||
|
"mrf_replay_cost_measurement",
|
||||||
|
"retained_responsibility_evidence",
|
||||||
|
"mrf_cleanup_gc_soak_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "R-E",
|
||||||
|
"task": "W03/W05",
|
||||||
|
"lane": "checkpoint-and-crash",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Fixed-budget real process restart through enumeration and classification",
|
||||||
|
"requires": ["fixed-budget restart evidence", "enumeration evidence", "classification evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"fixed_budget_restart_evidence",
|
||||||
|
"enumeration_evidence",
|
||||||
|
"classification_evidence"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "R-D",
|
||||||
|
"task": "W07/W14",
|
||||||
|
"lane": "status-and-outcome",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Manager-to-event-to-ledger exact disposition, including grace",
|
||||||
|
"requires": ["manager disposition evidence", "event disposition evidence", "ledger disposition evidence", "grace handling"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"manager_disposition_evidence",
|
||||||
|
"event_disposition_evidence",
|
||||||
|
"ledger_disposition_evidence",
|
||||||
|
"grace_handling"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"gate": "R-L",
|
||||||
|
"task": "W13/W14",
|
||||||
|
"lane": "mixed-version-rollback",
|
||||||
|
"status": "pending",
|
||||||
|
"description": "Legacy source conflicts, migration gaps and crash-safe source retirement",
|
||||||
|
"requires": ["legacy source-conflict evidence", "migration-gap evidence", "crash-safe source retirement evidence"],
|
||||||
|
"evidence_fields": [
|
||||||
|
"legacy_source_conflict_evidence",
|
||||||
|
"migration_gap_evidence",
|
||||||
|
"crash_safe_source_retirement_evidence"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,4 @@
|
|||||||
# if the selected count drops below this number, so a rename or removal that
|
# if the selected count drops below this number, so a rename or removal that
|
||||||
# thins the security smoke gate must update this file in the same PR.
|
# thins the security smoke gate must update this file in the same PR.
|
||||||
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
# Adding tests does not require a bump, but bumping keeps the guard tight.
|
||||||
18
|
26
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
||||||
# should show richer nested spans during request-path verification.
|
# should show richer nested spans during request-path verification.
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
@@ -89,6 +90,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
||||||
# should show richer nested spans during request-path verification.
|
# should show richer nested spans during request-path verification.
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
@@ -135,6 +137,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
||||||
# should show richer nested spans during request-path verification.
|
# should show richer nested spans during request-path verification.
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
@@ -181,6 +184,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfs-cluster-admin}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfs-cluster-secret}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
# `info` is enough for startup logs/metrics. Use `debug` if Tempo/Jaeger
|
||||||
# should show richer nested spans during request-path verification.
|
# should show richer nested spans during request-path verification.
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
||||||
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
||||||
@@ -72,6 +73,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
||||||
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
||||||
@@ -118,6 +120,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
||||||
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
||||||
@@ -164,6 +167,7 @@ services:
|
|||||||
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
- RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin-local}
|
||||||
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
- RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY:-rustfssecret-local}
|
||||||
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
- RUSTFS_OBS_ENDPOINT=${RUSTFS_OBS_ENDPOINT:-http://host.docker.internal:4318}
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
- RUSTFS_OBS_LOGGER_LEVEL=${RUSTFS_OBS_LOGGER_LEVEL:-info}
|
||||||
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
- RUSTFS_OBS_USE_STDOUT=${RUSTFS_OBS_USE_STDOUT:-false}
|
||||||
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
- RUSTFS_OBS_LOG_STDOUT_ENABLED=${RUSTFS_OBS_LOG_STDOUT_ENABLED:-false}
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ services:
|
|||||||
- RUSTFS_ADDRESS=:9000
|
- RUSTFS_ADDRESS=:9000
|
||||||
- RUSTFS_CONSOLE_ENABLE=true
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
@@ -220,6 +221,7 @@ services:
|
|||||||
- RUSTFS_ADDRESS=:9000
|
- RUSTFS_ADDRESS=:9000
|
||||||
- RUSTFS_CONSOLE_ENABLE=true
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
@@ -239,6 +241,7 @@ services:
|
|||||||
- RUSTFS_ADDRESS=:9000
|
- RUSTFS_ADDRESS=:9000
|
||||||
- RUSTFS_CONSOLE_ENABLE=true
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
@@ -258,6 +261,7 @@ services:
|
|||||||
- RUSTFS_ADDRESS=:9000
|
- RUSTFS_ADDRESS=:9000
|
||||||
- RUSTFS_CONSOLE_ENABLE=true
|
- RUSTFS_CONSOLE_ENABLE=true
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
- RUSTFS_OBS_LOGGER_LEVEL=debug
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ Three pre-built Grafana dashboards are included for monitoring RustFS GET perfor
|
|||||||
| **GET Resource Impact** | `grafana-get-resource-impact.json` | Monitors resource usage: concurrent requests, IO queue utilization, disk permit wait, RSS trend |
|
| **GET Resource Impact** | `grafana-get-resource-impact.json` | Monitors resource usage: concurrent requests, IO queue utilization, disk permit wait, RSS trend |
|
||||||
| **Object Data Cache** | `grafana-object-data-cache.json` | Monitors the GET body cache (`rustfs_object_data_cache_*`): hit ratio, lookup/plan/fill outcomes, fill duration quantiles, hit vs fill throughput, entries/weighted bytes, inflight fills, memory-pressure skips, invalidations, and size-class breakdowns |
|
| **Object Data Cache** | `grafana-object-data-cache.json` | Monitors the GET body cache (`rustfs_object_data_cache_*`): hit ratio, lookup/plan/fill outcomes, fill duration quantiles, hit vs fill throughput, entries/weighted bytes, inflight fills, memory-pressure skips, invalidations, and size-class breakdowns |
|
||||||
|
|
||||||
|
### Storage Metrics
|
||||||
|
|
||||||
|
Storage panels require `prometheus-rules/rustfs-storage.yml` and the cluster resource attribute. See the [storage metrics guide](../../docs/operations/storage-metrics.md) for ownership, observer selection, freshness, and rolling upgrades.
|
||||||
|
|
||||||
### Prometheus Alert Rules
|
### Prometheus Alert Rules
|
||||||
|
|
||||||
The file `prometheus-rules/rustfs-get-optimization-alerts.yaml` contains pre-configured alerting rules:
|
The file `prometheus-rules/rustfs-get-optimization-alerts.yaml` contains pre-configured alerting rules:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ services:
|
|||||||
- RUSTFS_SECRET_KEY=rustfsadmin
|
- RUSTFS_SECRET_KEY=rustfsadmin
|
||||||
- RUSTFS_OBS_LOGGER_LEVEL=info
|
- RUSTFS_OBS_LOGGER_LEVEL=info
|
||||||
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
- RUSTFS_OBS_ENDPOINT=http://otel-collector:4318
|
||||||
|
- OTEL_RESOURCE_ATTRIBUTES=${OTEL_RESOURCE_ATTRIBUTES:-rustfs.cluster.id=rustfs-dev}
|
||||||
- RUSTFS_OBS_PROFILING_ENDPOINT=http://pyroscope:4040
|
- RUSTFS_OBS_PROFILING_ENDPOINT=http://pyroscope:4040
|
||||||
volumes:
|
volumes:
|
||||||
- rustfs-data:/data/rustfs
|
- rustfs-data:/data/rustfs
|
||||||
|
|||||||
@@ -223,14 +223,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_buckets_total{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_buckets_total\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "__auto",
|
"legendFormat": "__auto",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Total Buckets",
|
"title": "Total Buckets",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -289,14 +290,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_objects_total{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_objects_total\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "__auto",
|
"legendFormat": "__auto",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Total Objects",
|
"title": "Total Objects",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -427,7 +429,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_used_bytes{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_used_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "Used",
|
"legendFormat": "Used",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
@@ -438,7 +440,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_raw_total_bytes{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_raw_total_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"legendFormat": "Total",
|
"legendFormat": "Total",
|
||||||
"range": true,
|
"range": true,
|
||||||
@@ -450,7 +452,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_used_bytes{job=~\"$job\"}) / sum(rustfs_cluster_capacity_raw_total_bytes{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_used_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"} / ignoring(source_metric) rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_raw_total_bytes\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"instant": false,
|
"instant": false,
|
||||||
"legendFormat": "Percent",
|
"legendFormat": "Percent",
|
||||||
@@ -459,7 +461,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Capacity",
|
"title": "Capacity",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -525,7 +528,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_stale_drives{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_stale_drives\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "Stale Drives",
|
"legendFormat": "Stale Drives",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
@@ -536,14 +539,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum(rustfs_cluster_capacity_missing_drives{job=~\"$job\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_capacity_missing_drives\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "Missing Drives",
|
"legendFormat": "Missing Drives",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Capacity Observation",
|
"title": "Capacity Observation",
|
||||||
"type": "stat"
|
"type": "stat",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -1989,8 +1993,8 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (drive) (rustfs_system_drive_used_bytes{job=~\"$job\", drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{drive}} (bytes)",
|
"legendFormat": "{{server}} | {{drive}} (bytes)",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
@@ -2000,16 +2004,17 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "sum by (drive) (rustfs_system_drive_used_bytes{job=~\"$job\", drive=~\"$drive\"}) / sum by (drive)(rustfs_system_drive_total_bytes{job=~\"$job\", drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"} / ignoring(source_metric) rustfs:storage:current{source_metric=\"rustfs_system_drive_total_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
|
||||||
"hide": false,
|
"hide": false,
|
||||||
"instant": false,
|
"instant": false,
|
||||||
"legendFormat": "{{drive}} (percent)",
|
"legendFormat": "{{server}} | {{drive}} (percent)",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "System Drive Usage",
|
"title": "System Drive Usage",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -2097,14 +2102,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "max by (drive) (rustfs_system_drive_capacity_observation_age_seconds{job=~\"$job\", drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_capacity_observation_age_seconds\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{drive}}",
|
"legendFormat": "{{server}} | {{drive}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Drive Capacity Observation Age",
|
"title": "Drive Capacity Observation Age",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -2190,8 +2196,8 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "max by (drive) (rustfs_system_drive_capacity_observation_state{job=~\"$job\", drive=~\"$drive\", state=\"stale\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_capacity_observation_state\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",state=\"stale\"}",
|
||||||
"legendFormat": "{{drive}} stale",
|
"legendFormat": "{{server}} | {{drive}} stale",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
},
|
},
|
||||||
@@ -2201,14 +2207,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "max by (drive) (rustfs_system_drive_capacity_observation_state{job=~\"$job\", drive=~\"$drive\", state=\"missing\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_system_drive_capacity_observation_state\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",state=\"missing\"}",
|
||||||
"legendFormat": "{{drive}} missing",
|
"legendFormat": "{{server}} | {{drive}} missing",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Drive Capacity Observation State",
|
"title": "Drive Capacity Observation State",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -4551,7 +4558,7 @@
|
|||||||
"index": 0,
|
"index": 0,
|
||||||
"text": "INACTIVE"
|
"text": "INACTIVE"
|
||||||
},
|
},
|
||||||
"to": 1e-9
|
"to": 1e-09
|
||||||
},
|
},
|
||||||
"type": "range"
|
"type": "range"
|
||||||
}
|
}
|
||||||
@@ -6563,7 +6570,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "rustfs_cluster_health_drives_online_count{job=~\"$job\"}",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_health_drives_online_count\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "online - {{job}}",
|
"legendFormat": "online - {{job}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
@@ -6574,7 +6581,7 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "rustfs_cluster_health_drives_offline_count{job=~\"$job\"}",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_health_drives_offline_count\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "offline - {{job}}",
|
"legendFormat": "offline - {{job}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B"
|
"refId": "B"
|
||||||
@@ -6585,14 +6592,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "rustfs_cluster_health_drives_count{job=~\"$job\"}",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_health_drives_count\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "total - {{job}}",
|
"legendFormat": "total - {{job}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "C"
|
"refId": "C"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Cluster Drive Health Counts",
|
"title": "Cluster Drive Health Counts",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"collapsed": false,
|
"collapsed": false,
|
||||||
@@ -8554,14 +8562,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "{__name__=~\"rustfs_system_drive_.*\",job=~\"$job\",drive=~\"$drive\"}",
|
"expr": "rustfs:storage:current{source_metric=~\"rustfs_system_drive_.*\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{__name__}} | {{drive}}",
|
"legendFormat": "{{server}} | {{source_metric}} | {{drive}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "System Drive (All)",
|
"title": "System Drive (All)",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -8942,14 +8951,15 @@
|
|||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"expr": "{__name__=~\"rustfs_cluster_erasure_set_.*\",job=~\"$job\"}",
|
"expr": "rustfs:storage:current{source_metric=~\"rustfs_cluster_erasure_set_.*\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\"}",
|
||||||
"legendFormat": "{{__name__}}",
|
"legendFormat": "{{source_metric}}",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A"
|
"refId": "A"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Cluster Erasure Set (All)",
|
"title": "Cluster Erasure Set (All)",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -11716,7 +11726,7 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A",
|
"refId": "A",
|
||||||
"expr": "max by (server, drive, pool_index, set_index, drive_index, state) (rustfs_system_drive_runtime_state{job=~\"$job\",server=~\"$server\",drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_drive_runtime_state\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{state}}"
|
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{state}}"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -11727,12 +11737,13 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "B",
|
"refId": "B",
|
||||||
"expr": "max by (server, drive, pool_index, set_index, drive_index) (rustfs_system_drive_offline_duration_seconds{job=~\"$job\",server=~\"$server\",drive=~\"$drive\"})",
|
"expr": "rustfs:storage:current{source_metric=\"rustfs_cluster_drive_offline_duration_seconds\",collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",observer=\"$storage_observer\",drive=~\"$drive\"}",
|
||||||
"legendFormat": "{{server}} | {{drive}} | offline seconds"
|
"legendFormat": "{{server}} | {{drive}} | offline seconds"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Drive Runtime State and Offline Duration",
|
"title": "Observed Cluster Drive State and Offline Duration",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -11825,12 +11836,13 @@
|
|||||||
"editorMode": "code",
|
"editorMode": "code",
|
||||||
"range": true,
|
"range": true,
|
||||||
"refId": "A",
|
"refId": "A",
|
||||||
"expr": "sum by (server, drive, pool_index, set_index, drive_index, api) (rate(rustfs_system_drive_api_calls_total{job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}[$__rate_interval]))",
|
"expr": "rate(rustfs_system_drive_api_calls_total{collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}[$__rate_interval]) and ignoring(source_metric) rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\",server=~\"$server\",drive=~\"$drive\",api=~\"$drive_api\"}",
|
||||||
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{api}}"
|
"legendFormat": "{{server}} | {{drive}} | p{{pool_index}}/s{{set_index}}/d{{drive_index}} | {{api}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Drive API Calls by Operation",
|
"title": "Drive API Calls by Operation",
|
||||||
"type": "timeseries"
|
"type": "timeseries",
|
||||||
|
"description": "Storage snapshot rules are required. Local details come from each drive owner. Global values use the selected fresh cluster observer. Missing or expired observations show no data; select another observer if needed."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"datasource": {
|
"datasource": {
|
||||||
@@ -12376,6 +12388,50 @@
|
|||||||
"sort": 1,
|
"sort": 1,
|
||||||
"type": "query"
|
"type": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "label_values(rustfs:storage_snapshot:fresh, rustfs_cluster_id)",
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "Storage cluster",
|
||||||
|
"multi": false,
|
||||||
|
"name": "storage_cluster",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 1,
|
||||||
|
"query": "label_values(rustfs:storage_snapshot:fresh, rustfs_cluster_id)",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-storage_cluster"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"current": {},
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "${datasource}"
|
||||||
|
},
|
||||||
|
"definition": "query_result(rustfs:storage_snapshot:fresh{collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\"})",
|
||||||
|
"includeAll": false,
|
||||||
|
"label": "Cluster observer",
|
||||||
|
"multi": false,
|
||||||
|
"name": "storage_observer",
|
||||||
|
"options": [],
|
||||||
|
"query": {
|
||||||
|
"qryType": 3,
|
||||||
|
"query": "query_result(rustfs:storage_snapshot:fresh{collection_scope=\"cluster\",rustfs_cluster_id=\"$storage_cluster\",job=~\"$job\"})",
|
||||||
|
"refId": "PrometheusVariableQueryEditor-storage_observer"
|
||||||
|
},
|
||||||
|
"refresh": 2,
|
||||||
|
"regex": "/observer=\"([^\"]+)\"/",
|
||||||
|
"sort": 1,
|
||||||
|
"type": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"allValue": ".*",
|
"allValue": ".*",
|
||||||
"current": {
|
"current": {
|
||||||
@@ -12386,7 +12442,7 @@
|
|||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "${datasource}"
|
"uid": "${datasource}"
|
||||||
},
|
},
|
||||||
"definition": "label_values(rustfs_system_drive_api_calls_total,api)",
|
"definition": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, api)",
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"label": "Drive API",
|
"label": "Drive API",
|
||||||
"multi": true,
|
"multi": true,
|
||||||
@@ -12394,7 +12450,7 @@
|
|||||||
"options": [],
|
"options": [],
|
||||||
"query": {
|
"query": {
|
||||||
"qryType": 1,
|
"qryType": 1,
|
||||||
"query": "label_values(rustfs_system_drive_api_calls_total,api)",
|
"query": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, api)",
|
||||||
"refId": "PrometheusVariableQueryEditor-drive_api"
|
"refId": "PrometheusVariableQueryEditor-drive_api"
|
||||||
},
|
},
|
||||||
"refresh": 2,
|
"refresh": 2,
|
||||||
@@ -12455,7 +12511,7 @@
|
|||||||
"text": "All",
|
"text": "All",
|
||||||
"value": "$__all"
|
"value": "$__all"
|
||||||
},
|
},
|
||||||
"definition": "label_values(rustfs_system_drive_used_bytes,drive)",
|
"definition": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, drive)",
|
||||||
"includeAll": true,
|
"includeAll": true,
|
||||||
"label": "Drive",
|
"label": "Drive",
|
||||||
"multi": true,
|
"multi": true,
|
||||||
@@ -12463,7 +12519,7 @@
|
|||||||
"options": [],
|
"options": [],
|
||||||
"query": {
|
"query": {
|
||||||
"qryType": 1,
|
"qryType": 1,
|
||||||
"query": "label_values(rustfs_system_drive_used_bytes,drive)",
|
"query": "label_values(rustfs:storage:current{source_metric=\"rustfs_system_drive_used_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"$storage_cluster\"}, drive)",
|
||||||
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
"refId": "PrometheusVariableQueryEditor-VariableQuery"
|
||||||
},
|
},
|
||||||
"refresh": 2,
|
"refresh": 2,
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
# Preserve OTLP timestamps in the Collector. Apply timestamp() directly to
|
||||||
|
# each raw selector, before label rewriting; functions such as label_replace()
|
||||||
|
# replace the evaluation timestamp and would make a cached value look new.
|
||||||
|
# Compare publication times at Prometheus millisecond precision so points
|
||||||
|
# published and exported within the same millisecond are not withheld.
|
||||||
|
groups:
|
||||||
|
- name: rustfs-storage-snapshots
|
||||||
|
interval: 15s
|
||||||
|
rules:
|
||||||
|
- record: rustfs:storage_snapshot:fresh
|
||||||
|
expr: |
|
||||||
|
rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"}
|
||||||
|
and ((time() - rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"}) <= rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"})
|
||||||
|
and ((time() - rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"}) >= 0)
|
||||||
|
and (rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id!="",collection_scope=~"local|cluster"} > 0)
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_buckets_total
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_buckets_total{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_buckets_total{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_free_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_missing_drives
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_missing_drives{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_missing_drives{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_raw_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_stale_drives
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_stale_drives{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_stale_drives{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_usable_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_usable_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_usable_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_capacity_used_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_capacity_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_capacity_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_capacity_observation_age_seconds
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_capacity_observation_state
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_free_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_free_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_offline_duration_seconds
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_present
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_present{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_present{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_runtime_state
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_runtime_state{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_runtime_state{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_total_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_drive_used_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_drive_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_drive_used_bytes{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_data_shards
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_data_shards{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_data_shards{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_healing_drives_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_healing_drives_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_healing_drives_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_health
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_health{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_health{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_online_drives_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_online_drives_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_online_drives_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_overall_health
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_overall_health{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_overall_health{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_overall_write_quorum
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_overall_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_overall_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_parity
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_parity{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_parity{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_read_health
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_read_health{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_read_health{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_read_quorum
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_read_quorum{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_read_quorum{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_read_tolerance
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_read_tolerance{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_read_tolerance{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_size
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_size{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_size{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_write_health
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_write_health{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_write_health{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_write_quorum
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_write_quorum{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_erasure_set_write_tolerance
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_erasure_set_write_tolerance{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_erasure_set_write_tolerance{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_health_drives_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_health_drives_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_health_drives_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_health_drives_offline_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_health_drives_offline_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_health_drives_offline_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_health_drives_online_count
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_health_drives_online_count{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_health_drives_online_count{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_cluster_objects_total
|
||||||
|
expr: |
|
||||||
|
rustfs_cluster_objects_total{rustfs_cluster_id!="",collection_scope="cluster"}
|
||||||
|
and (timestamp(rustfs_cluster_objects_total{rustfs_cluster_id!="",collection_scope="cluster"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_node_disk_free_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_node_disk_free_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_node_disk_free_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_node_disk_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_node_disk_total_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_node_disk_total_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_node_disk_used_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_node_disk_used_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_node_disk_used_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_api_calls_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_api_calls_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_api_calls_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_api_latency_by_api_micros
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_api_latency_by_api_micros{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_api_latency_by_api_micros{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_api_latency_micros
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_api_latency_micros{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_api_latency_micros{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_availability_errors_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_availability_errors_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_availability_errors_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_capacity_observation_age_seconds
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_capacity_observation_age_seconds{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_capacity_observation_state
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_capacity_observation_state{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_count
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_count{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_count{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_deletes_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_deletes_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_deletes_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_free_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_free_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_free_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_free_inodes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_free_inodes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_free_inodes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_healing
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_healing{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_healing{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_health
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_health{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_health{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_info
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_info{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_info{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_io_errors_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_io_errors_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_io_errors_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_offline_count
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_offline_count{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_offline_count{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_offline_duration_seconds
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_offline_duration_seconds{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_online_count
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_online_count{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_online_count{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_perc_util
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_perc_util{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_perc_util{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_present
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_present{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_present{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_reads_await
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_reads_await{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_reads_await{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_reads_kb_per_sec
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_reads_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_reads_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_reads_per_sec
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_reads_per_sec{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_reads_per_sec{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_runtime_state
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_runtime_state{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_runtime_state{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_scanning
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_scanning{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_scanning{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_timeout_errors_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_timeout_errors_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_timeout_errors_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_total_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_total_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_total_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_total_inodes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_total_inodes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_total_inodes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_used_bytes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_used_bytes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_used_bytes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_used_inodes
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_used_inodes{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_used_inodes{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_waiting_io
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_waiting_io{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_waiting_io{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_writes_await
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_writes_await{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_writes_await{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_writes_kb_per_sec
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_writes_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_writes_kb_per_sec{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_writes_per_sec
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_writes_per_sec{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_writes_per_sec{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
|
- record: rustfs:storage:current
|
||||||
|
labels:
|
||||||
|
source_metric: rustfs_system_drive_writes_total
|
||||||
|
expr: |
|
||||||
|
rustfs_system_drive_writes_total{rustfs_cluster_id!="",collection_scope="local"}
|
||||||
|
and (timestamp(rustfs_system_drive_writes_total{rustfs_cluster_id!="",collection_scope="local"})
|
||||||
|
>= on (rustfs_cluster_id, observer, collection_scope, job, instance) group_left()
|
||||||
|
(floor(rustfs:storage_snapshot:fresh * 1000) / 1000))
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
rule_files:
|
||||||
|
- ../prometheus-rules/rustfs-storage.yml
|
||||||
|
evaluation_interval: 15s
|
||||||
|
tests:
|
||||||
|
- name: owners, pools, clusters and observer views remain distinct in mixed versions
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",pool_index="0",set_index="0",drive_index="0"}
|
||||||
|
values: 100x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data",pool_index="0",set_index="0",drive_index="1"}
|
||||||
|
values: 100x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n2",collection_scope="local",job="rustfs",instance="collector",server="n2",drive="/data",pool_index="1",set_index="0",drive_index="0"}
|
||||||
|
values: 100x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n3",collection_scope="local",job="rustfs",instance="collector",server="n3",drive="/data",pool_index="1",set_index="0",drive_index="1"}
|
||||||
|
values: 100x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",pool_index="0",set_index="0",drive_index="0"}
|
||||||
|
values: 1000x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data",pool_index="0",set_index="0",drive_index="1"}
|
||||||
|
values: 1000x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n2",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n2",collection_scope="local",job="rustfs",instance="collector",server="n2",drive="/data",pool_index="1",set_index="0",drive_index="0"}
|
||||||
|
values: 1000x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="b",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="b",observer="n3",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="b",observer="n3",collection_scope="local",job="rustfs",instance="collector",server="n3",drive="/data",pool_index="1",set_index="0",drive_index="1"}
|
||||||
|
values: 1000x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id="a",observer="n0",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 400x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_cluster_capacity_raw_total_bytes{rustfs_cluster_id="a",observer="n1",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 300x8
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="old",server="n0",drive="/data",job="rustfs",instance="collector"}
|
||||||
|
values: 999x8
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: sum by (rustfs_cluster_id) (rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes",collection_scope="local"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{rustfs_cluster_id="a"}'
|
||||||
|
value: 400
|
||||||
|
- labels: '{rustfs_cluster_id="b"}'
|
||||||
|
value: 4000
|
||||||
|
- expr: count by (rustfs_cluster_id,pool_index) (rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{rustfs_cluster_id="a",pool_index="0"}'
|
||||||
|
value: 2
|
||||||
|
- labels: '{rustfs_cluster_id="a",pool_index="1"}'
|
||||||
|
value: 2
|
||||||
|
- labels: '{rustfs_cluster_id="b",pool_index="0"}'
|
||||||
|
value: 2
|
||||||
|
- labels: '{rustfs_cluster_id="b",pool_index="1"}'
|
||||||
|
value: 2
|
||||||
|
- expr: sum(rustfs:storage:current{source_metric="rustfs_cluster_capacity_raw_total_bytes",rustfs_cluster_id="a",observer="n0"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 400
|
||||||
|
- expr: sum(rustfs:storage:current{source_metric="rustfs_cluster_capacity_raw_total_bytes",rustfs_cluster_id="a",observer="n1"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 300
|
||||||
|
- name: removed values and identities cannot rejoin new snapshots; stalled sources expire
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0 60 _ _ _ _ _ _ _
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_info{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",disk_id="old"}
|
||||||
|
values: 1 1 _ _ _ _ _ _ _
|
||||||
|
- series: rustfs_system_drive_info{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",disk_id="new"}
|
||||||
|
values: _ _ 1 1 1 1 1 1 1
|
||||||
|
- series: rustfs_system_drive_waiting_io{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data"}
|
||||||
|
values: 7 7 _ _ _ _ _ _ _
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data"}
|
||||||
|
values: 100 100 _ _ _ _ _ _ _
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: rustfs:storage:current{source_metric="rustfs_system_drive_info",disk_id="old"}
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples: []
|
||||||
|
- expr: rustfs:storage:current{source_metric="rustfs_system_drive_waiting_io"}
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples: []
|
||||||
|
- expr: count(rustfs:storage:current{source_metric="rustfs_system_drive_info",disk_id="new"})
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 1
|
||||||
|
- expr: rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes"}
|
||||||
|
eval_time: 5m
|
||||||
|
exp_samples: []
|
||||||
|
- expr: rustfs:storage_snapshot:fresh{observer="n1"}
|
||||||
|
eval_time: 5m
|
||||||
|
exp_samples: []
|
||||||
|
- name: counters reset independently; filter versions before rate and sum rates across owners
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_api_calls_total{rustfs_cluster_id="a",observer="n0",collection_scope="local",job="rustfs",instance="collector",server="n0",drive="/data",disk_id="n0-disk",api="read_all"}
|
||||||
|
values: 0 60 120 30 90 150 210
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x8
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x8
|
||||||
|
- series: rustfs_system_drive_api_calls_total{rustfs_cluster_id="a",observer="n1",collection_scope="local",job="rustfs",instance="collector",server="n1",drive="/data",disk_id="n1-disk",api="read_all"}
|
||||||
|
values: 0+120x6
|
||||||
|
- series: rustfs_system_drive_api_calls_total{rustfs_cluster_id="a",server="n0",drive="/data",observer="old"}
|
||||||
|
values: 0+999x6
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: sum(resets(rustfs_system_drive_api_calls_total{collection_scope="local"}[5m]))
|
||||||
|
eval_time: 5m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 1
|
||||||
|
- expr: sum(rate(rustfs_system_drive_api_calls_total{collection_scope="local"}[2m]) and ignoring(source_metric) rustfs:storage:current{source_metric="rustfs_system_drive_api_calls_total"})
|
||||||
|
eval_time: 5m
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 3
|
||||||
|
- name: completed slow collection has no validity budget
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="slow",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: 0+60x3
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="slow",collection_scope="cluster",job="rustfs",instance="collector"}
|
||||||
|
values: '0x3'
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: rustfs:storage_snapshot:fresh{observer="slow"}
|
||||||
|
eval_time: 2m
|
||||||
|
exp_samples: []
|
||||||
|
- name: submillisecond publication and millisecond OTLP samples share a cutoff
|
||||||
|
interval: 1m
|
||||||
|
input_series:
|
||||||
|
- series: rustfs_storage_snapshot_last_success_timestamp_seconds{rustfs_cluster_id="a",observer="submillisecond",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 0.0009+60x3
|
||||||
|
- series: rustfs_storage_snapshot_max_age_seconds{rustfs_cluster_id="a",observer="submillisecond",collection_scope="local",job="rustfs",instance="collector"}
|
||||||
|
values: 180x3
|
||||||
|
- series: rustfs_system_drive_total_bytes{rustfs_cluster_id="a",observer="submillisecond",collection_scope="local",job="rustfs",instance="collector",server="submillisecond",drive="/data"}
|
||||||
|
values: 100x3
|
||||||
|
promql_expr_test:
|
||||||
|
- expr: count(rustfs:storage:current{source_metric="rustfs_system_drive_total_bytes",observer="submillisecond"})
|
||||||
|
eval_time: 2m15s
|
||||||
|
exp_samples:
|
||||||
|
- labels: '{}'
|
||||||
|
value: 1
|
||||||
@@ -111,10 +111,6 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
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
|
- name: Check io_uring lane --lib precondition
|
||||||
shell: bash
|
shell: bash
|
||||||
run: ./scripts/check_uring_lane_lib_only.sh
|
run: ./scripts/check_uring_lane_lib_only.sh
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ secret_key = ${S3_SECRET_KEY}
|
|||||||
|
|
||||||
## replace with key id obtained when secret is created, or delete if KMS not tested
|
## replace with key id obtained when secret is created, or delete if KMS not tested
|
||||||
#kms_keyid = 01234567-89ab-cdef-0123-456789abcdef
|
#kms_keyid = 01234567-89ab-cdef-0123-456789abcdef
|
||||||
|
#kms_keyid2 = fedcba98-7654-3210-fedc-ba9876543210
|
||||||
|
|
||||||
|
## Expected service default for SSE-KMS requests without a key id; empty means none
|
||||||
|
#kms_default_keyid =
|
||||||
|
|
||||||
## Storage classes
|
## Storage classes
|
||||||
#storage_classes = "LUKEWARM, FROZEN"
|
#storage_classes = "LUKEWARM, FROZEN"
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
types: [ opened, synchronize, reopened ]
|
|
||||||
branches: [ main ]
|
|
||||||
paths:
|
|
||||||
- "**.md"
|
|
||||||
- "docs/**"
|
|
||||||
- "deploy/**"
|
|
||||||
- "scripts/dev_*.sh"
|
|
||||||
- "scripts/probe.sh"
|
|
||||||
- "LICENSE*"
|
|
||||||
- ".gitignore"
|
|
||||||
- ".dockerignore"
|
|
||||||
- "README*"
|
|
||||||
- "**/*.png"
|
|
||||||
- "**/*.jpg"
|
|
||||||
- "**/*.svg"
|
|
||||||
- ".github/workflows/build.yml"
|
|
||||||
- ".github/workflows/docker.yml"
|
|
||||||
- ".github/workflows/audit.yml"
|
|
||||||
- "flake.lock"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quick-checks:
|
|
||||||
name: Quick Checks
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
- name: Run shared quick checks
|
|
||||||
uses: ./.github/actions/quick-checks
|
|
||||||
|
|
||||||
test-and-lint:
|
|
||||||
name: Test and Lint
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 10
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
|
||||||
with:
|
|
||||||
persist-credentials: false
|
|
||||||
|
|
||||||
# Docs-only PRs skip the full code CI, but they are exactly where a
|
|
||||||
# planning-type document could be slipped in (git add -f bypasses
|
|
||||||
# .gitignore). Run the guard here so the required "Test and Lint" check
|
|
||||||
# stays meaningful for docs-only changes.
|
|
||||||
- name: Check no planning docs committed
|
|
||||||
run: ./scripts/check_no_planning_docs.sh
|
|
||||||
|
|
||||||
- name: Satisfy required check for docs-only changes
|
|
||||||
run: echo "Docs-only change — code CI is skipped by paths-ignore; planning-docs guard passed, reporting success for the required 'Test and Lint' check."
|
|
||||||
+175
-80
@@ -16,7 +16,7 @@ name: Continuous Integration
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, release ]
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "**.md"
|
- "**.md"
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
@@ -36,26 +36,7 @@ on:
|
|||||||
- "flake.lock"
|
- "flake.lock"
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
branches: [ main ]
|
branches: [ main, release ]
|
||||||
# Keep this list in sync with the `paths` list in ci-docs-only.yml, which
|
|
||||||
# reports the required "Test and Lint" check for PRs skipped here.
|
|
||||||
paths-ignore:
|
|
||||||
- "**.md"
|
|
||||||
- "docs/**"
|
|
||||||
- "deploy/**"
|
|
||||||
- "scripts/dev_*.sh"
|
|
||||||
- "scripts/probe.sh"
|
|
||||||
- "LICENSE*"
|
|
||||||
- ".gitignore"
|
|
||||||
- ".dockerignore"
|
|
||||||
- "README*"
|
|
||||||
- "**/*.png"
|
|
||||||
- "**/*.jpg"
|
|
||||||
- "**/*.svg"
|
|
||||||
- ".github/workflows/build.yml"
|
|
||||||
- ".github/workflows/docker.yml"
|
|
||||||
- ".github/workflows/audit.yml"
|
|
||||||
- "flake.lock"
|
|
||||||
merge_group:
|
merge_group:
|
||||||
types: [ checks_requested ]
|
types: [ checks_requested ]
|
||||||
schedule:
|
schedule:
|
||||||
@@ -88,6 +69,32 @@ jobs:
|
|||||||
- name: Explain cancellation run
|
- name: Explain cancellation run
|
||||||
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
run: echo "PR closed; this run only cancels older runs in the same concurrency group."
|
||||||
|
|
||||||
|
classify-changes:
|
||||||
|
name: Select CI scope
|
||||||
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
mode: ${{ steps.scope.outputs.mode }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Select scope using the base revision's policy
|
||||||
|
id: scope
|
||||||
|
env:
|
||||||
|
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then
|
||||||
|
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||||
|
elif [[ "$CI_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] && git show "$CI_BASE_SHA:scripts/ci_gate.py" > "$RUNNER_TEMP/ci-gate-base.py"; then
|
||||||
|
python3 -I "$RUNNER_TEMP/ci-gate-base.py" select
|
||||||
|
else
|
||||||
|
printf '%s\n' 'mode=full' >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Base CI policy unavailable; running the full matrix."
|
||||||
|
fi
|
||||||
|
|
||||||
typos:
|
typos:
|
||||||
name: Typos
|
name: Typos
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -100,7 +107,7 @@ jobs:
|
|||||||
- name: Typos check with custom config file
|
- name: Typos check with custom config file
|
||||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # master
|
||||||
|
|
||||||
# Fail early with compile-free checks shared with docs-only CI.
|
# Fail early with compile-free checks for every pull request.
|
||||||
quick-checks:
|
quick-checks:
|
||||||
name: Quick Checks
|
name: Quick Checks
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||||
@@ -116,9 +123,9 @@ jobs:
|
|||||||
uses: ./.github/actions/quick-checks
|
uses: ./.github/actions/quick-checks
|
||||||
|
|
||||||
test-and-lint:
|
test-and-lint:
|
||||||
name: Test and Lint
|
name: Workspace Test and Lint
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -289,45 +296,6 @@ jobs:
|
|||||||
- name: Run rebalance/decommission migration proofs
|
- name: Run rebalance/decommission migration proofs
|
||||||
run: ./scripts/check_migration_gate_count.sh
|
run: ./scripts/check_migration_gate_count.sh
|
||||||
|
|
||||||
# Record the reason before this job completes as FAILURE. A separate
|
|
||||||
# dependent job cancels sibling lanes only after GitHub has preserved this
|
|
||||||
# required check's failure verdict.
|
|
||||||
- name: Annotate early-stop reason
|
|
||||||
if: >-
|
|
||||||
failure() && github.event_name == 'pull_request'
|
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
run: |
|
|
||||||
{
|
|
||||||
echo "## CI early-stop"
|
|
||||||
echo "Job \`${GITHUB_JOB}\` (Test and Lint) failed; a follow-up job will cancel sibling lanes to free runners."
|
|
||||||
echo "Sibling jobs showing **cancelled** were stopped by the early-stop follow-up, not by their own failure."
|
|
||||||
} >> "$GITHUB_STEP_SUMMARY"
|
|
||||||
|
|
||||||
# Preserve the required Test and Lint FAILURE verdict before stopping sibling
|
|
||||||
# lanes. Cancelling from inside test-and-lint changed its own conclusion to
|
|
||||||
# CANCELLED and hid the actionable failure in the PR checks UI.
|
|
||||||
cancel-after-test-and-lint-failure:
|
|
||||||
name: Cancel siblings after Test and Lint failure
|
|
||||||
if: >-
|
|
||||||
failure() && needs.test-and-lint.result == 'failure'
|
|
||||||
&& github.event_name == 'pull_request'
|
|
||||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
|
||||||
needs: [ test-and-lint ]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 5
|
|
||||||
permissions:
|
|
||||||
actions: write
|
|
||||||
steps:
|
|
||||||
- name: Cancel remaining jobs
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
run: |
|
|
||||||
curl -fsS -X POST \
|
|
||||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
|
||||||
-H "Accept: application/vnd.github+json" \
|
|
||||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/cancel"
|
|
||||||
|
|
||||||
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
# Dedicated serial lane for the ILM / lifecycle integration tests. These tests
|
||||||
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
# drive the object layer through process-global singletons (the GLOBAL_ENV
|
||||||
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
# ECStore, the global tier-config manager, background-expiry workers) and bind
|
||||||
@@ -340,8 +308,8 @@ jobs:
|
|||||||
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
# See rustfs/backlog#1148 (ilm-1) and #1155.
|
||||||
test-ilm-integration-serial:
|
test-ilm-integration-serial:
|
||||||
name: ILM Integration (serial)
|
name: ILM Integration (serial)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -408,8 +376,8 @@ jobs:
|
|||||||
|
|
||||||
test-and-lint-rio-v2:
|
test-and-lint-rio-v2:
|
||||||
name: Test and Lint (rio-v2)
|
name: Test and Lint (rio-v2)
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
env:
|
env:
|
||||||
@@ -449,8 +417,8 @@ jobs:
|
|||||||
|
|
||||||
connect-short-credential-boundary:
|
connect-short-credential-boundary:
|
||||||
name: Connect Short Credential Boundary
|
name: Connect Short Credential Boundary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
@@ -507,8 +475,8 @@ jobs:
|
|||||||
|
|
||||||
test-and-lint-protocols:
|
test-and-lint-protocols:
|
||||||
name: "Test and Lint (${{ matrix.features.name }})"
|
name: "Test and Lint (${{ matrix.features.name }})"
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 90
|
timeout-minutes: 90
|
||||||
strategy:
|
strategy:
|
||||||
@@ -561,8 +529,8 @@ jobs:
|
|||||||
|
|
||||||
build-rustfs-debug-binary:
|
build-rustfs-debug-binary:
|
||||||
name: Build RustFS Debug Binary
|
name: Build RustFS Debug Binary
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
runs-on: sm-standard-4
|
runs-on: sm-standard-4
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
env:
|
env:
|
||||||
@@ -582,13 +550,59 @@ jobs:
|
|||||||
install-build-packaging-tools: 'false'
|
install-build-packaging-tools: 'false'
|
||||||
|
|
||||||
- name: Build debug binary
|
- name: Build debug binary
|
||||||
run: cargo build -p rustfs --bins --features e2e-test-hooks
|
run: |
|
||||||
|
python3 - <<'PYBUILD'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
def git(*args):
|
||||||
|
return subprocess.check_output(["git", *args], text=True).strip()
|
||||||
|
|
||||||
|
def sha256(path):
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with pathlib.Path(path).open("rb") as source:
|
||||||
|
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
argv = ["cargo", "build", "-p", "rustfs", "--bins", "--features", "e2e-test-hooks"]
|
||||||
|
commit, tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
|
||||||
|
clean_before = not git("status", "--porcelain", "--untracked-files=normal")
|
||||||
|
if not clean_before:
|
||||||
|
raise SystemExit("hooks binary requires a clean build checkout")
|
||||||
|
lock_sha256 = sha256("Cargo.lock")
|
||||||
|
lock_git_blob = git("hash-object", "Cargo.lock")
|
||||||
|
rustc = subprocess.check_output(["rustc", "-vV"], text=True)
|
||||||
|
host = next(line.removeprefix("host: ") for line in rustc.splitlines() if line.startswith("host: "))
|
||||||
|
if os.environ.get("CARGO_BUILD_TARGET") or pathlib.Path(os.environ.get("CARGO_TARGET_DIR", "target")).resolve() != pathlib.Path("target").resolve():
|
||||||
|
raise SystemExit("this artifact requires the native target/debug output")
|
||||||
|
subprocess.run(argv, check=True)
|
||||||
|
clean_after = not git("status", "--porcelain", "--untracked-files=normal")
|
||||||
|
if not clean_after or commit != git("rev-parse", "HEAD") or tree != git("rev-parse", "HEAD^{tree}") or lock_sha256 != sha256("Cargo.lock"):
|
||||||
|
raise SystemExit("hooks binary source changed while building")
|
||||||
|
manifest = {
|
||||||
|
"schema": 1, "commit": commit, "tree": tree,
|
||||||
|
"clean_before": clean_before, "clean_after": clean_after,
|
||||||
|
"lock_sha256": lock_sha256, "lock_git_blob": lock_git_blob,
|
||||||
|
"argv": argv, "profile": "debug", "target": host,
|
||||||
|
"features": ["e2e-test-hooks"],
|
||||||
|
"rustc_verbose": rustc,
|
||||||
|
"build_flags": {key: os.environ[key] for key in ("RUSTFLAGS", "CARGO_ENCODED_RUSTFLAGS", "CARGO_BUILD_TARGET", "CARGO_TARGET_DIR", "RUSTUP_TOOLCHAIN") if key in os.environ},
|
||||||
|
"binary_sha256": sha256("target/debug/rustfs"),
|
||||||
|
}
|
||||||
|
pathlib.Path("target/debug/rustfs.e2e-startup-cas-build.json").write_text(json.dumps(manifest, indent=2) + "\n")
|
||||||
|
PYBUILD
|
||||||
|
|
||||||
- name: Upload debug binary
|
- name: Upload debug binary
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: rustfs-debug-binary
|
name: rustfs-debug-binary
|
||||||
path: target/debug/rustfs
|
path: |
|
||||||
|
target/debug/rustfs
|
||||||
|
target/debug/rustfs.e2e-startup-cas-build.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
@@ -638,8 +652,8 @@ jobs:
|
|||||||
# job had neither, so each closed/merged PR really ran the whole io_uring
|
# job had neither, so each closed/merged PR really ran the whole io_uring
|
||||||
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
||||||
# 30662728539) and kept the cancellation run in progress for minutes.
|
# 30662728539) and kept the cancellation run in progress for minutes.
|
||||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
if: needs.classify-changes.outputs.mode == 'full' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
needs: [ quick-checks ]
|
needs: [ quick-checks, classify-changes ]
|
||||||
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
||||||
# a container, applies no seccomp filter that would block io_uring_setup — so
|
# a container, applies no seccomp filter that would block io_uring_setup — so
|
||||||
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
||||||
@@ -826,13 +840,14 @@ jobs:
|
|||||||
# Merge gate only (backlog#1149 ci-5): the never-automated user-visible
|
# Merge gate only (backlog#1149 ci-5): the never-automated user-visible
|
||||||
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
|
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
|
||||||
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for
|
# security-boundary, ... — via the e2e-full nextest profile. Too heavy for
|
||||||
# every PR, so it is gated to main pushes, the merge queue, and manual
|
# every PR, so it is gated to main/release pushes, the merge queue, and manual
|
||||||
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
|
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
|
||||||
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
||||||
if: >-
|
if: >-
|
||||||
github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
github.event_name == 'merge_group' ||
|
github.event_name == 'merge_group' ||
|
||||||
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|
(github.event_name == 'push' &&
|
||||||
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/release'))
|
||||||
needs: [ build-rustfs-debug-binary ]
|
needs: [ build-rustfs-debug-binary ]
|
||||||
runs-on: sm-standard-2
|
runs-on: sm-standard-2
|
||||||
timeout-minutes: 55
|
timeout-minutes: 55
|
||||||
@@ -912,6 +927,36 @@ jobs:
|
|||||||
- name: Make binary executable
|
- name: Make binary executable
|
||||||
run: chmod +x ./target/debug/rustfs
|
run: chmod +x ./target/debug/rustfs
|
||||||
|
|
||||||
|
- name: Preserve startup CAS binary input
|
||||||
|
env:
|
||||||
|
STARTUP_CAS_INPUT: ${{ runner.temp }}/rustfs-startup-cas-input
|
||||||
|
run: |
|
||||||
|
python3 - <<'PYINPUT'
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
source = pathlib.Path("target/debug/rustfs")
|
||||||
|
manifest_path = source.with_name("rustfs.e2e-startup-cas-build.json")
|
||||||
|
manifest = json.loads(manifest_path.read_text())
|
||||||
|
target = pathlib.Path(os.environ["STARTUP_CAS_INPUT"])
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
binary = target / "rustfs"
|
||||||
|
shutil.copy2(source, binary)
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with binary.open("rb") as stream:
|
||||||
|
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
commit = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
if manifest["binary_sha256"] != digest.hexdigest() or manifest["commit"] != commit:
|
||||||
|
raise SystemExit("downloaded hooks binary identity mismatch")
|
||||||
|
shutil.copy2(manifest_path, target / manifest_path.name)
|
||||||
|
binary.chmod(0o755)
|
||||||
|
PYINPUT
|
||||||
|
|
||||||
- name: Verify e2e full membership
|
- name: Verify e2e full membership
|
||||||
env:
|
env:
|
||||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-full-list.json
|
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-full-list.json
|
||||||
@@ -924,6 +969,10 @@ jobs:
|
|||||||
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
|
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
|
||||||
# debug binary; each test spawns its own rustfs server on a random port.
|
# debug binary; each test spawns its own rustfs server on a random port.
|
||||||
- name: Run e2e full suite
|
- name: Run e2e full suite
|
||||||
|
env:
|
||||||
|
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
|
||||||
|
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
||||||
|
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
|
||||||
run: cargo nextest run --profile e2e-full -p e2e_test
|
run: cargo nextest run --profile e2e-full -p e2e_test
|
||||||
|
|
||||||
- name: Upload junit
|
- name: Upload junit
|
||||||
@@ -936,6 +985,17 @@ jobs:
|
|||||||
${{ runner.temp }}/rustfs-e2e-full-list.json
|
${{ runner.temp }}/rustfs-e2e-full-list.json
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload startup CAS evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: fresh-startup-cas-evidence-${{ github.run_number }}
|
||||||
|
path: |
|
||||||
|
${{ runner.temp }}/rustfs-startup-cas-evidence
|
||||||
|
${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
|
||||||
|
if-no-files-found: warn
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
e2e-tests-rio-v2:
|
e2e-tests-rio-v2:
|
||||||
name: End-to-End Tests (rio-v2)
|
name: End-to-End Tests (rio-v2)
|
||||||
# Inherits the schedule/dispatch-only gate through needs: on every other
|
# Inherits the schedule/dispatch-only gate through needs: on every other
|
||||||
@@ -1121,9 +1181,44 @@ jobs:
|
|||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
retention-days: 3
|
retention-days: 3
|
||||||
|
|
||||||
|
required-checks:
|
||||||
|
name: Test and Lint
|
||||||
|
if: always() && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||||
|
needs:
|
||||||
|
- classify-changes
|
||||||
|
- typos
|
||||||
|
- quick-checks
|
||||||
|
- test-and-lint
|
||||||
|
- test-ilm-integration-serial
|
||||||
|
- test-and-lint-rio-v2
|
||||||
|
- connect-short-credential-boundary
|
||||||
|
- test-and-lint-protocols
|
||||||
|
- build-rustfs-debug-binary
|
||||||
|
- uring-integration
|
||||||
|
- e2e-tests
|
||||||
|
- s3-implemented-tests
|
||||||
|
- s3-lifecycle-behavior-tests
|
||||||
|
- build-rustfs-debug-binary-rio-v2
|
||||||
|
- e2e-tests-rio-v2
|
||||||
|
- e2e-full
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Require the expected result of every CI lane
|
||||||
|
env:
|
||||||
|
CI_NEEDS: ${{ toJSON(needs) }}
|
||||||
|
shell: bash
|
||||||
|
run: python3 scripts/ci_gate.py verify
|
||||||
|
|
||||||
alert-on-failure:
|
alert-on-failure:
|
||||||
name: Alert on scheduled failure
|
name: Alert on scheduled failure
|
||||||
needs:
|
needs:
|
||||||
|
- classify-changes
|
||||||
|
- connect-short-credential-boundary
|
||||||
|
- required-checks
|
||||||
- typos
|
- typos
|
||||||
- quick-checks
|
- quick-checks
|
||||||
- test-and-lint
|
- test-and-lint
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ jobs:
|
|||||||
cache_key: e2e-odm-config-rollback
|
cache_key: e2e-odm-config-rollback
|
||||||
test: rc5_rollback_requires_restoring_odm_configuration
|
test: rc5_rollback_requires_restoring_odm_configuration
|
||||||
artifact: odm-config-rollback
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
env:
|
env:
|
||||||
@@ -121,11 +129,22 @@ jobs:
|
|||||||
: > target/debug/rustfs.features
|
: > target/debug/rustfs.features
|
||||||
|
|
||||||
- name: Run upgrade compatibility test
|
- name: Run upgrade compatibility test
|
||||||
|
env:
|
||||||
|
RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||||
run: |
|
run: |
|
||||||
cargo test --locked -p e2e_test \
|
cargo test --locked -p e2e_test \
|
||||||
"upgrade_compatibility_test::${{ matrix.test }}" \
|
"upgrade_compatibility_test::${{ matrix.test }}" \
|
||||||
-- --ignored --exact --nocapture
|
-- --ignored --exact --nocapture
|
||||||
|
|
||||||
|
- name: Upload scanner/heal G09 evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: ${{ matrix.artifact }}-g09-evidence-${{ github.run_number }}
|
||||||
|
path: ${{ runner.temp }}/rustfs-upgrade-g09-evidence/${{ matrix.artifact }}
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
- name: Upload server logs
|
- name: Upload server logs
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
|||||||
@@ -19,17 +19,27 @@ on:
|
|||||||
- cron: "7 0 * * *"
|
- cron: "7 0 * * *"
|
||||||
timezone: "Asia/Shanghai"
|
timezone: "Asia/Shanghai"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
branch:
|
||||||
|
description: 'Branch/ref to build and publish as the nightly (empty = scheduled source, see NIGHTLY_BUILD_REF)'
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
# Scheduled builds follow the NIGHTLY_BRANCH repo variable so the channel can
|
||||||
|
# be pointed at e.g. `release` for the GA cycle and back to `main` afterwards
|
||||||
|
# without touching this file. Manual runs take the `branch` input, falling
|
||||||
|
# back to the branch the run was dispatched from.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: nightly-gnu-build-main-${{ github.event_name }}
|
group: nightly-gnu-build-${{ github.event_name }}-${{ github.event_name == 'schedule' && (vars.NIGHTLY_BRANCH || 'main') || (inputs.branch || github.ref_name) }}
|
||||||
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}
|
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
RUST_BACKTRACE: 1
|
RUST_BACKTRACE: 1
|
||||||
|
NIGHTLY_BUILD_REF: ${{ github.event_name == 'schedule' && (vars.NIGHTLY_BRANCH || 'main') || (inputs.branch || github.ref_name) }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -43,6 +53,7 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
@@ -152,13 +163,104 @@ jobs:
|
|||||||
|
|
||||||
fakeroot dpkg-deb --build "${PKG_DIR}"
|
fakeroot dpkg-deb --build "${PKG_DIR}"
|
||||||
ls -lh "${DEB_FILE}"
|
ls -lh "${DEB_FILE}"
|
||||||
|
echo "deb_date=${DEB_DATE}" >> "${GITHUB_OUTPUT}"
|
||||||
echo "deb_file=${DEB_FILE}" >> "${GITHUB_OUTPUT}"
|
echo "deb_file=${DEB_FILE}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
# Same packaging scheme as .github/workflows/package.yml (fpm), but from
|
||||||
|
# the locally built nightly binary instead of a release artifact, with a
|
||||||
|
# date-based version that mirrors the DEB.
|
||||||
|
- name: Build RPM package
|
||||||
|
id: rpm
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DEB_DATE: ${{ steps.deb.outputs.deb_date }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v fpm >/dev/null 2>&1; then
|
||||||
|
SUDO=""; [ "$(id -u)" -ne 0 ] && SUDO="sudo -n"
|
||||||
|
${SUDO} apt-get update -qq && ${SUDO} apt-get install -y -qq ruby ruby-dev build-essential rpm >/dev/null
|
||||||
|
${SUDO} gem install fpm --no-document >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
RPM_FILE="rustfs-nightly-${DEB_DATE}.rpm"
|
||||||
|
RPM_VERSION="0"
|
||||||
|
RPM_RELEASE="0.nightly.${DEB_DATE//-/.}"
|
||||||
|
|
||||||
|
echo "Building RPM: ${RPM_FILE} (version ${RPM_VERSION}-${RPM_RELEASE})"
|
||||||
|
|
||||||
|
# fpm wants the config file to exist before packaging.
|
||||||
|
mkdir -p ./tmp-pkg/etc/default
|
||||||
|
cat > ./tmp-pkg/etc/default/rustfs << 'ENVEOF'
|
||||||
|
# RustFS Environment Configuration
|
||||||
|
# See https://rustfs.com/docs/ for more information
|
||||||
|
# RUSTFS_VOLUMES=""
|
||||||
|
# RUSTFS_ROOT_USER=""
|
||||||
|
# RUSTFS_ROOT_PASSWORD=""
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
fpm -s dir -t rpm \
|
||||||
|
--name rustfs \
|
||||||
|
--version "$RPM_VERSION" \
|
||||||
|
--iteration "$RPM_RELEASE" \
|
||||||
|
--architecture x86_64 \
|
||||||
|
--package "$RPM_FILE" \
|
||||||
|
--depends "glibc >= 2.31" \
|
||||||
|
--maintainer "RustFS Team <support@rustfs.com>" \
|
||||||
|
--description "High-performance distributed object storage" \
|
||||||
|
--url "https://rustfs.com" \
|
||||||
|
--license "Apache-2.0" \
|
||||||
|
--after-install <(cat << 'POSTINST'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if ! getent passwd rustfs > /dev/null 2>&1; then
|
||||||
|
useradd -r -s /bin/false -d /opt/rustfs rustfs
|
||||||
|
fi
|
||||||
|
mkdir -p /opt/rustfs /data/rustfs /var/log/rustfs
|
||||||
|
chown rustfs:rustfs /opt/rustfs /data/rustfs /var/log/rustfs
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
POSTINST
|
||||||
|
) \
|
||||||
|
--before-remove <(cat << 'PRERM'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if [ -d /run/systemd/system ] && systemctl is-active --quiet rustfs; then
|
||||||
|
systemctl stop rustfs
|
||||||
|
fi
|
||||||
|
PRERM
|
||||||
|
) \
|
||||||
|
--after-remove <(cat << 'POSTRM'
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
if [ -d /run/systemd/system ]; then
|
||||||
|
systemctl daemon-reload
|
||||||
|
fi
|
||||||
|
POSTRM
|
||||||
|
) \
|
||||||
|
--config-files /etc/default/rustfs \
|
||||||
|
"rustfs-nightly-${DEB_DATE}/usr/bin/rustfs=/usr/bin/rustfs" \
|
||||||
|
./tmp-pkg/etc/default/rustfs=/etc/default/rustfs \
|
||||||
|
deploy/build/rustfs.service=/lib/systemd/system/rustfs.service \
|
||||||
|
LICENSE=/usr/share/doc/rustfs/LICENSE \
|
||||||
|
README.md=/usr/share/doc/rustfs/README.md
|
||||||
|
|
||||||
|
[[ -f "$RPM_FILE" ]] || { echo "RPM build failed"; exit 1; }
|
||||||
|
rpm -qpl "$RPM_FILE" | grep -Fx '/usr/bin/rustfs' >/dev/null
|
||||||
|
stat --printf='%n %s bytes\n' "$RPM_FILE"
|
||||||
|
echo "rpm_file=$RPM_FILE" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Upload DEB artifact
|
- name: Upload DEB artifact
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
with:
|
with:
|
||||||
name: ${{ steps.deb.outputs.deb_file }}
|
name: ${{ steps.deb.outputs.deb_file }}
|
||||||
path: ${{ steps.deb.outputs.deb_file }}
|
path: ${{ steps.deb.outputs.deb_file }}
|
||||||
|
- name: Upload RPM artifact
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||||
|
with:
|
||||||
|
name: ${{ steps.rpm.outputs.rpm_file }}
|
||||||
|
path: ${{ steps.rpm.outputs.rpm_file }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
# Persist the nightly deb on Cloudflare R2 (same channel as package.yml)
|
# Persist the nightly deb on Cloudflare R2 (same channel as package.yml)
|
||||||
@@ -187,11 +289,10 @@ jobs:
|
|||||||
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
export AWS_SECRET_ACCESS_KEY="$R2_SECRET_ACCESS_KEY"
|
||||||
export AWS_DEFAULT_REGION="auto"
|
export AWS_DEFAULT_REGION="auto"
|
||||||
|
|
||||||
|
# The candidate manifest must describe the tree that was actually
|
||||||
|
# built. With a ref override (NIGHTLY_BRANCH / dispatch input) that
|
||||||
|
# is not necessarily GITHUB_SHA, so always advertise HEAD.
|
||||||
SOURCE_SHA="$(git rev-parse HEAD)"
|
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)"
|
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_KEY="artifacts/rustfs/packages/nightly/runs/${GITHUB_RUN_ID}/${GITHUB_RUN_ATTEMPT}/${DEB_SHA256}/rustfs.deb"
|
||||||
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
|
CANDIDATE_URL="https://dl.rustfs.com/${CANDIDATE_KEY}"
|
||||||
@@ -247,6 +348,20 @@ jobs:
|
|||||||
path: ${{ steps.publish.outputs.candidate_file }}
|
path: ${{ steps.publish.outputs.candidate_file }}
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Publish the deb/rpm pair to the auto-testing repo's `assets` branch so
|
||||||
|
# engineers can download and install the nightly directly. The branch is
|
||||||
|
# a single-commit orphan rewritten on every build, which keeps the repo
|
||||||
|
# small while the latest files stay reachable at stable raw URLs.
|
||||||
|
# Publish the deb/rpm pair as assets of the rolling `nightly` release on
|
||||||
|
# rustfs/auto-testing (see scripts/release/publish_nightly_assets.sh).
|
||||||
|
- name: Publish packages to auto-testing release assets
|
||||||
|
env:
|
||||||
|
ASSETS_TOKEN: ${{ secrets.PF_TESTING_GH_TOKEN }}
|
||||||
|
DEB_FILE: ${{ steps.deb.outputs.deb_file }}
|
||||||
|
RPM_FILE: ${{ steps.rpm.outputs.rpm_file }}
|
||||||
|
DEB_DATE: ${{ steps.deb.outputs.deb_date }}
|
||||||
|
BUILD_REF: ${{ env.NIGHTLY_BUILD_REF }}
|
||||||
|
run: bash scripts/release/publish_nightly_assets.sh
|
||||||
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
# Live-Vault lane for the rustfs-kms suite (rustfs/backlog#1774).
|
||||||
#
|
#
|
||||||
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
# RUSTFS_KMS_VAULT_TOKEN is the single switch that adds the Vault KV2 and
|
||||||
@@ -284,6 +399,7 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
@@ -372,6 +488,7 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
ref: ${{ env.NIGHTLY_BUILD_REF }}
|
||||||
|
|
||||||
- name: Setup Rust environment
|
- name: Setup Rust environment
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
|
|||||||
@@ -82,6 +82,12 @@ jobs:
|
|||||||
performance-test:
|
performance-test:
|
||||||
runs-on: pf-testing
|
runs-on: pf-testing
|
||||||
timeout-minutes: 900
|
timeout-minutes: 900
|
||||||
|
env:
|
||||||
|
RUSTFS_BENCH_SCRIPT: ${{ github.workspace }}/auto-testing/rustfs_performance_testing.sh
|
||||||
|
RUSTFS_WARP_METHODS: ${{ inputs.test_method }}
|
||||||
|
RUSTFS_WARP_SIZES: ${{ inputs.object_size }}
|
||||||
|
RUSTFS_WARP_DURATION: ${{ inputs.warp_duration || '5m' }}
|
||||||
|
RUSTFS_WARP_CONCURRENCY: ${{ inputs.warp_concurrency || '64' }}
|
||||||
# Run on manual dispatch, or when the nightly build completed successfully.
|
# Run on manual dispatch, or when the nightly build completed successfully.
|
||||||
# Skipped when nightly failed.
|
# Skipped when nightly failed.
|
||||||
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch' }}
|
||||||
@@ -158,19 +164,15 @@ jobs:
|
|||||||
- name: Run benchmark (GET/PUT/MIXED)
|
- name: Run benchmark (GET/PUT/MIXED)
|
||||||
id: benchmark
|
id: benchmark
|
||||||
run: |
|
run: |
|
||||||
# Empty on automatic (workflow_run) runs -> full 30 rounds.
|
|
||||||
# Manual dispatch can restrict method(s)/size(s).
|
|
||||||
export WARP_METHODS="${{ inputs.test_method }}"
|
|
||||||
export WARP_SIZES="${{ inputs.object_size }}"
|
|
||||||
./auto-testing/rustfs_performance_test.sh \
|
./auto-testing/rustfs_performance_test.sh \
|
||||||
--step 5 -y \
|
--step 5 -y \
|
||||||
--warp-duration "${{ inputs.warp_duration || '5m' }}" \
|
|
||||||
--warp-concurrency "${{ inputs.warp_concurrency || '64' }}" \
|
|
||||||
--log-file "${LOG_FILE}"
|
--log-file "${LOG_FILE}"
|
||||||
|
|
||||||
- name: Analyze results
|
- name: Analyze results
|
||||||
if: ${{ steps.benchmark.conclusion == 'success' }}
|
if: ${{ steps.benchmark.conclusion == 'success' }}
|
||||||
run: |
|
run: |
|
||||||
|
export WARP_METHODS="${RUSTFS_WARP_METHODS}" WARP_SIZES="${RUSTFS_WARP_SIZES}"
|
||||||
|
export WARP_DURATION="${RUSTFS_WARP_DURATION}" WARP_CONCURRENCY="${RUSTFS_WARP_CONCURRENCY}"
|
||||||
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
./auto-testing/rustfs_performance_test.sh --step 6 -y --log-file "${LOG_FILE:-/dev/null}"
|
||||||
|
|
||||||
- name: Collect RustFS version info
|
- name: Collect RustFS version info
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: overtrue/repo-visuals-action@fd79cba437ecfac933d00a69add17eb95d3939c3 # v1.3.1
|
- uses: overtrue/repo-visuals-action@ee2c632f6ce617e851fb46ea935ee8af762ebb93 # v1.4.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ github.token }}
|
github-token: ${{ github.token }}
|
||||||
output-branch: star-history
|
output-branch: star-history
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ name: Windows Filesystem Tests
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main, release ]
|
||||||
paths:
|
paths:
|
||||||
- "crates/ecstore/src/disk/**"
|
- "crates/ecstore/src/disk/**"
|
||||||
- "crates/ecstore/src/store/init_format.rs"
|
- "crates/ecstore/src/store/init_format.rs"
|
||||||
@@ -26,7 +26,7 @@ on:
|
|||||||
- ".github/actions/setup/**"
|
- ".github/actions/setup/**"
|
||||||
- ".github/workflows/windows-filesystem.yml"
|
- ".github/workflows/windows-filesystem.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main, release ]
|
||||||
paths:
|
paths:
|
||||||
- "crates/ecstore/src/disk/**"
|
- "crates/ecstore/src/disk/**"
|
||||||
- "crates/ecstore/src/store/init_format.rs"
|
- "crates/ecstore/src/store/init_format.rs"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ docs
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
!docs/
|
!docs/
|
||||||
docs/*
|
docs/*
|
||||||
|
!docs/README.md
|
||||||
!docs/architecture/
|
!docs/architecture/
|
||||||
!docs/architecture/**
|
!docs/architecture/**
|
||||||
!docs/operations/
|
!docs/operations/
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ This file contains repository-wide rules. Use the nearest subdirectory
|
|||||||
|
|
||||||
1. System/developer instructions.
|
1. System/developer instructions.
|
||||||
2. The current user request.
|
2. The current user request.
|
||||||
3. The nearest `AGENTS.md`.
|
3. Applicable `AGENTS.md` files, with the nearest file winning conflicts.
|
||||||
4. This file.
|
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
|
## 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
|
- Do not load every skill or inspect unrelated modules preemptively. Select a
|
||||||
skill only when its description directly matches the request or changed
|
skill only when its description directly matches the request or changed
|
||||||
surface.
|
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
|
- Avoid repeated reads and equivalent verification commands once enough
|
||||||
evidence exists.
|
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
|
Read only the reference needed for the current task, once per unchanged context:
|
||||||
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
|
- 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.
|
||||||
- Preserve existing control flow unless changing it is required for correctness.
|
- Before commits, pushes, PR creation/updates, or posting to PRs/issues/discussions,
|
||||||
- Prefer a direct local edit over new files, wrappers, managers, or speculative
|
read [Git and PR rules](.agents/references/pull-requests.md).
|
||||||
abstractions.
|
Reuse existing authorization; a reference does not authorize posting, merging, or publishing.
|
||||||
- Add a helper only when it removes current duplication, names a real domain
|
- Preserve unrelated work. Never commit from a shared checkout or delete another task's artifacts.
|
||||||
boundary, or isolates a non-trivial invariant.
|
- Source comments, commits, PR titles, and PR bodies are in English.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Sources of Truth
|
## Sources of Truth
|
||||||
|
|
||||||
@@ -147,73 +116,25 @@ requested adversarial/design reviews, and agent-instruction changes that alter
|
|||||||
execution. Ordinary questions, diagnoses, status reports, non-adversarial code
|
execution. Ordinary questions, diagnoses, status reports, non-adversarial code
|
||||||
reviews, and low-risk planning do not trigger it.
|
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,
|
A review has no finding quota; `No findings` is a complete outcome. A request to
|
||||||
build, test, or agent-execution effect.
|
find problems is not evidence that a defect exists. Before reporting a candidate,
|
||||||
- **Mechanical:** renames, moves, test/tooling-only changes, and agent-rule
|
check callers, invariants, and existing tests for evidence that disproves it.
|
||||||
changes. Run correctness and simplicity lenses.
|
Findings need `file:line` and a concrete failure or violation of an explicit
|
||||||
- **Standard:** localized behavior changes. Run one integrated final-diff pass
|
requirement. Missing required tests/checks are verification gaps, not proof of a
|
||||||
covering correctness, simplicity, and test coverage; add only domain lenses
|
runtime bug; name the unprotected behavior or unmet gate. Keep optional style or
|
||||||
matched by the diff.
|
refactoring preferences out of defect findings unless that review was requested.
|
||||||
- **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,
|
Fix or rebut supported findings within the authorized scope. Once the required
|
||||||
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
|
passes are complete, stop. Reopen only for changed code, new evidence, an
|
||||||
explicit adversarial request, a high-risk change, or a substantial PR review;
|
unresolved finding, or an explicit re-review request; an unchanged diff does not
|
||||||
then read only its matching role references. A routine standard pass does not
|
need another pass at every conversation turn or workflow handoff.
|
||||||
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.
|
|
||||||
|
|
||||||
For high-risk PRs, record one concise verdict per covered lens in the PR body.
|
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
|
## Security Baseline
|
||||||
|
|
||||||
- Never commit secrets, credentials, or key material.
|
- Never commit secrets, credentials, or key material.
|
||||||
@@ -250,12 +171,6 @@ Use `.agents/skills/rustfs-logging-governance/SKILL.md` for logging changes.
|
|||||||
- `DataUsageCacheInfo` and `DataUsageEntry` keep their hand-written map
|
- `DataUsageCacheInfo` and `DataUsageEntry` keep their hand-written map
|
||||||
serialization and new fields remain `#[serde(default)]` for older readers.
|
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
|
## Scoped Guidance
|
||||||
|
|
||||||
Before editing, locate the nearest instructions with:
|
Before editing, locate the nearest instructions with:
|
||||||
@@ -265,4 +180,4 @@ git ls-files '*AGENTS.md'
|
|||||||
```
|
```
|
||||||
|
|
||||||
The nearest file wins for domain invariants. Keep generic workflow and
|
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.
|
||||||
|
|||||||
@@ -7,7 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Replication
|
||||||
|
|
||||||
|
- Object Lock replication PUTs now carry a required integrity header, fixing target rejection introduced by the plain-payload default ([#7097](https://github.com/rustfs/rustfs/pull/7097)). This changes the default outbound request for locked objects but adds no persisted format.
|
||||||
|
- Multipart source objects stay on the multipart transport even when their checksum record is a whole-object checksum, so objects above the single-PUT limit remain replicable ([#7047](https://github.com/rustfs/rustfs/pull/7047)).
|
||||||
|
- Targets that mint their own version IDs now use a per-target version ledger for tag, retention, legal-hold, and permanent-delete mutations; ambiguous pre-ledger matches fail with backoff instead of guessing ([#7368](https://github.com/rustfs/rustfs/pull/7368)). This adds dual-prefixed internal metadata keys that older readers ignore.
|
||||||
|
- Single-part source checksums are forwarded as `x-amz-checksum-*` headers instead of user metadata, so the replica preserves checksum responses ([#7313](https://github.com/rustfs/rustfs/pull/7313)). This changes the default outbound headers for checksummed objects.
|
||||||
|
- Site-replication outage recovery now uses a bounded 30-second retry drain plus the 600-second full reconciliation pass, persists destructive liabilities before local deletion, and fences replay settlement and peer edits ([#7148](https://github.com/rustfs/rustfs/pull/7148)). Persisted additions are optional and ignored by older readers.
|
||||||
|
- IAM snapshot/deletion replay, target-assigned delete-marker purges, timestamp ordering, and best-effort peer broadcast now close the control-plane gaps found by the R6 review ([#7195](https://github.com/rustfs/rustfs/pull/7195)).
|
||||||
|
- Upgrade and rollback: upgrade every node in one site consecutively and verify reconciliation before moving to the next site; do not intentionally run a site mixed-version. Target-version ledger keys are harmless on rollback, although old code cannot use their routing. Before rolling back past [#7307](https://github.com/rustfs/rustfs/pull/7307), drain or repair every pending version purge: older code can free a retained version's data directory before its remote purge is acknowledged. See `docs/operations/site-replication-operations.md`.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Presigned URLs honour only signed headers** (GHSA-g8w9-qw9q-fghr): a SigV4 presigned request that carries an `x-amz-*` request header not listed in `X-Amz-SignedHeaders` is now rejected with `403 AccessDenied` ("There were headers present in the request which were not signed"), matching AWS S3. Previously the holder of a presigned `PutObject` URL could add unsigned `x-amz-tagging`, `x-amz-storage-class`, `x-amz-website-redirect-location`, ACL, metadata, Object Lock or SSE headers and have them applied. Presigners that intend a property must set it before signing so the SDK lists the header in `SignedHeaders`; `x-amz-cf-id` (CloudFront) remains tolerated unsigned. Header-signed SigV4 and SigV2 requests are unchanged.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Fresh multi-pool bootstrap with distinct format creators**: a new deployment whose pools have their first endpoint on different nodes (for example two single-node pools) could never publish its initial `pool.bin`: each node held fresh-bootstrap proof only for the pool it formatted, the deployment-wide proof collapsed to none, and every node died with `pool metadata recovery required: no durable bootstrap identity or pool.bin replica is available` after the startup retry budget. The first pool's creator now mints the pending cluster identity on its own pool, every other creator copies that nonce-bound identity onto the pool it formatted first-hand, and the elected writer publishes `pool.bin` once every pool replica carries the same pending identity. Corrupt or disagreeing replicas, pools that merely have a format, expansion pools joining an initialized deployment, and restarts without first-hand proof still fail closed. Non-elected nodes that start before `pool.bin` exists, and the elected writer while it waits for the other creators, no longer latch their pool-metadata write gate for the life of the process. Refs rustfs/backlog#2338, rustfs/backlog#2375.
|
||||||
|
- **Lock RPC timeout storms** (#7363): the remote lock client no longer evicts and re-dials the shared internode HTTP/2 channel on every request deadline. A timeout evicts only when the peer has not completed any lock RPC for two deadlines, evictions and transport-failure re-dials are rate limited per peer (`RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS`, default 5 s), and a timed-out request is left running instead of being reset (bounded per peer by `RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT`, default 256), so a slow lock endpoint can no longer drive the `RST_STREAM`/`GOAWAY too_many_resets`/reconnect loop. A lock granted after its caller timed out is released immediately, and unlocks that fail the quick retries continue on a deferred 1/2/4/8/16 s schedule before the server lease reclaims them. New `rustfs_remote_lock_*` metrics cover timeouts, evictions, suppressed evictions, detached streams, late completions and late releases per peer. Operator guide at `docs/operations/lock-rpc-storm-protection.md`.
|
||||||
|
- **KMS failures on the S3 data path carry an actionable status**: only "key not found" and a backend outage were classified; every other KMS failure — a disabled or pending-deletion key, a denied KMS grant, an encryption-context mismatch, an unsupported algorithm, a credential or timeout failure, a capability the backend does not have — collapsed onto `500 InternalError`. SDKs therefore applied exponential backoff to configuration errors that no retry can fix, and monitoring filed every one of them as a server fault. Unusable-key and request-side failures now return `400`, a denied grant `403`, transient backend failures `503` — including a key store the backend could not read, so an outage stays distinguishable from a missing key all the way to the client — and a missing backend capability `501`. Damaged or unreadable key material still returns `500`, which is what it is.
|
||||||
|
- **SSE-C on buckets with default encryption**: a `PutObject` carrying a valid SSE-C header triple on a bucket that has default encryption configured no longer fails with `400 InvalidArgument` ("The SSE-C and managed server-side encryption headers cannot be used together"). PUT and the POST-object/extract path resolved the bucket default with a hard-coded "no explicit SSE-C" flag, so the default was layered onto the request and then tripped the request's own mutual-exclusion check; an SSE-C request now suppresses the bucket default on all three write paths, matching COPY and AWS S3. Every bucket with default encryption previously refused SSE-C single PUTs outright, while `CreateMultipartUpload` on the same bucket succeeded.
|
||||||
|
- **Explicit SSE-S3 on SSE-KMS-default buckets**: `x-amz-server-side-encryption: AES256` against a bucket whose default is `aws:kms` no longer fails with `400 InvalidArgument`. The bucket default's KMS key id was inherited independently of the effective algorithm, producing a self-contradictory `AES256` + key-id pair; the key id is now inherited only when the effective algorithm is `aws:kms`. `PutBucketEncryption` fills in a default key id automatically, so this affected nearly every SSE-KMS-default bucket.
|
||||||
|
- **Restore of encrypted or compressed multipart objects (silent data corruption)**: restoring a multipart object from a remote tier addressed the tier in *plaintext* coordinates while the copy-back reads the *stored* representation. Every part received a misaligned slice of the remote object whose length still satisfied the range, the hash reader and the completion size check, so the restore reported success and replaced the object's bytes. Restore now accumulates stored part sizes, passes the stored length to the hash reader alongside the plaintext length, and validates against the stored size. Objects restored by an affected release must be re-restored from the tier or recovered from a backup — this release does not detect or repair them retroactively.
|
||||||
|
- **Restore no longer drifts the object ETag**: the copy-back digests stored (encrypted or compressed) bytes, so the recomputed MD5 is not the object's public ETag. Single-part and multipart restores now preserve the original object ETag, and each restored part keeps its own recorded part ETag.
|
||||||
|
- **ILM archive no longer forwards encryption metadata to the tier**: transition requests carried the object's SSE headers and the RustFS-wrapped data key as request headers. Any S3 target rejected an SSE-C archive outright (`400`, no key supplied), an SSE-KMS archive asked the target to encrypt a second time under a key id it does not own, and the wrapped DEK left the cluster. The archive request now strips every SSE header and encryption marker using the same predicate the replication path uses; the local `xl.meta` keeps all of it, so read-through and restore are unaffected.
|
||||||
|
- **KMS reload is no longer a no-op on a node whose KMS failed to start**: `POST /rustfs/admin/v3/kms/reload` short-circuited whenever the persisted configuration matched the in-memory one byte for byte. A node whose KMS failed to start (for example Vault briefly unreachable during a rolling restart) keeps that configuration and sits in `Error`, so the documented recovery call returned "reloaded successfully" while leaving the node down — and did the same on every peer through the reload broadcast. Reload now short-circuits only for a service that is actually running, and otherwise reconfigures, which starts the service.
|
||||||
|
- **AWS KMS capability reporting**: the AWS backend no longer advertises `versioning` support through `GET /rustfs/admin/v3/kms/status`. AWS KMS key versions are not enumerable through this backend, as the backend documentation already stated.
|
||||||
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
@@ -48,6 +72,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
|
- Four-layer regression-prevention tests guard against silent feature deletion: compile-time module assertion, module-presence unit test, cross-module `Protocol` enum assertion, end-to-end SSH banner test against the running binary
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Encryption and KMS work merged since `1.0.0-rc.5`** (entries were missing from this section):
|
||||||
|
- **Persisted KMS configuration secrets** are sealed field-by-field with `RUSTFS_KMS_CONFIG_SECRET`. **When the variable is unset the secrets are persisted in cleartext and the server only warns** (`persisted KMS configuration carries cleartext secrets`); it never refuses the write. Set it, identically, on every node, and re-save the configuration to seal an existing one.
|
||||||
|
- **New v2 ciphertext frame format** with per-frame index binding and final-frame authentication. Its write switch `RUSTFS_ENCRYPTION_FRAME_V2` is **off by default**: v2 frames are unreadable by nodes without v2 read support, and encrypted ciphertext travels verbatim through transition, decommission and SSE-C replication passthrough, so turn it on only after every node — and every RustFS warm/replication target that receives raw ciphertext — runs a release with v2 read support. Reading v2 objects needs no switch.
|
||||||
|
- **Per-key SSE-KMS authorization** (`RUSTFS_KMS_ENFORCE_SSE_KEY_POLICY`, default `false`). With it on, anonymous callers hold no KMS grants, so **a public bucket serving SSE-KMS objects is an incompatible combination** and those reads return `AccessDenied`.
|
||||||
|
- Envelope context binding as KMS AAD (`ENV_KMS_ENVELOPE_AAD`, off by default; a node that predates the field cannot open bound envelopes).
|
||||||
|
- Vault custom CA and mutual TLS; object-level DEK rewrap plus a batch rekey admin API; a backend-locality runtime signal on `kms/status`.
|
||||||
|
- Single-pass decryption for encrypted GET, and encrypted single-part closed-range seek — the latter is now **on by default** (`RUSTFS_ENCRYPTED_RANGE_SEEK`, default `true`; the switch remains as a kill switch).
|
||||||
|
- **Vault static tokens are now tracked and renewed**: with `Token` authentication RustFS hard-coded "this token has no lease", so the renewal task never started and no remaining-TTL gauge was published. `vault token create` grants a 768-hour TTL by default, which turned a healthy-looking cluster into one where every KMS call returned 403 about a month later, with no self-healing short of a restart or reconfigure. RustFS now calls `auth/token/lookup-self` at login and adopts what Vault reports: a non-expiring token behaves exactly as before, an expiring renewable one is renewed at half TTL like the other auth methods, and an expiring non-renewable one logs `vault_static_token_not_renewable` and publishes its remaining TTL. The probe never fails the login: a token whose policy omits `lookup-self` (Vault's `default` policy grants it), or a Vault that is unreachable at that moment, logs `vault_static_token_lookup_failed` and falls back to the previous no-lease behaviour, so no deployment that works today stops working.
|
||||||
|
- **SSE-C over a plaintext transport is reported**: AWS S3 and MinIO refuse an SSE-C request that did not arrive over TLS, because the customer key travels in a request header. RustFS accepted them on any transport and still does by default — flipping to a rejection inside a release window would break plaintext staging and test deployments. Each such request now increments `rustfs_ssec_plaintext_requests_total` and logs one `ssec_request_without_tls` warning per process, and `RUSTFS_SSE_C_REQUIRE_TLS=true` opts into the AWS `400` now. The default is expected to flip in a later release; confirm the counter reads zero first. The verdict is per connection: a TLS listener satisfies it, and so does an `https` protocol forwarded by a proxy the trusted-proxy configuration accepts.
|
||||||
|
- **Local KMS backend on a distributed deployment says what actually breaks**: the backend keeps key material and its Argon2id salt on each node's own disk, so two nodes derive different keys from the same `master_key` and an object encrypted on one node cannot be decrypted on another — intermittent 500s behind a load balancer. Configuring it while the deployment is distributed now logs `kms_node_local_backend_in_distributed_deployment` and appends that consequence to the `kms/configure` response, instead of only the generic "development only" positioning warning. It remains a warning, not a gate.
|
||||||
|
- **SSE-KMS is refused when no KMS is running (breaking)**: a write requesting `x-amz-server-side-encryption: aws:kms` on a node with no KMS service no longer succeeds. Earlier releases wrapped the data key with the node-local `RUSTFS_SSE_S3_MASTER_KEY` while still writing `aws:kms` and the requested key id into the object metadata — metadata that claimed a KMS protection the object never had, under a key that was never consulted. Such a request now returns `400 InvalidRequest` when KMS was never configured and `503` when a configured service is not running; the refusal is evaluated after the per-key authorization gate, so an unauthorized caller still receives `403 AccessDenied`. **Upgrade note:** a deployment that relied on this write succeeding will start receiving 4xx/503. Either configure a KMS, or request `AES256` and keep the documented SSE-S3 local-master-key fallback, which is unchanged. Objects already written this way remain readable.
|
||||||
|
- **Legacy ciphertext nonce layouts are now locked per segment**: while decrypting a v1 segment, the reader locks onto whichever of the three historical nonce layouts decoded the segment's first non-zero-index frame and rejects any later frame that needs a different one. Because a frame encrypted at block index zero authenticates under the pre-`1.0.0-alpha.91` reused-part-nonce layout at any position, an attacker able to rewrite the underlying shards could previously replay it and have the forged plaintext returned with `200`. New `RUSTFS_ENCRYPTION_LEGACY_NONCE_FALLBACK` (default `true`) drops that third layout entirely when set to `false`, which closes the residual case of a stream built purely from repeats of frame zero. Turn it off only after migrating pre-alpha.91 encrypted objects (rewrite in place with CopyObject); see [KMS backend security properties](docs/operations/kms-backend-security.md) for what the v1 frame layout does and does not authenticate.
|
||||||
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
|
- **HTTP Server Stack**: Integrated `KeystoneAuthLayer` middleware from `rustfs-keystone` crate into service stack (positioned after ReadinessGateLayer)
|
||||||
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
|
- **Storage-class validation on startup (upgrade note)**: A persisted explicit storage class (`RUSTFS_STORAGE_CLASS_STANDARD` / `RUSTFS_STORAGE_CLASS_RRS`, for example `EC:2`) is now validated against the actual per-pool drive counts at startup and rejected when a pool cannot satisfy it. This is fail-closed and correct, but a cluster that persisted a storage class larger than a small or heterogeneous pool can hold (for example `EC:2` alongside a 2-drive pool), which earlier releases accepted and silently resolved to an invalid layout, will now refuse to start after upgrade. To recover, unset `RUSTFS_STORAGE_CLASS_STANDARD` so the server derives a valid per-pool default automatically, or set it to a value every pool can satisfy.
|
||||||
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
|
- **IAMAuth**: Enhanced `get_secret_key()` to return empty secret for Keystone credentials (bypasses signature validation)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ what Claude Code needs on top: commands and pointers.
|
|||||||
cargo build --release --bin rustfs # production binary
|
cargo build --release --bin rustfs # production binary
|
||||||
cargo check -p <crate> # fast type-check one crate
|
cargo check -p <crate> # fast type-check one crate
|
||||||
cargo test -p <crate> # test one crate
|
cargo test -p <crate> # test one crate
|
||||||
cargo fmt --all # format (required before PR)
|
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
|
||||||
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
|
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
|
||||||
make pre-pr # optional full gate for broad cross-module changes
|
make pre-pr # optional full gate for broad cross-module changes
|
||||||
make build-docker BUILD_OS=ubuntu22.04
|
make build-docker BUILD_OS=ubuntu22.04
|
||||||
@@ -42,5 +42,5 @@ make build-docker BUILD_OS=ubuntu22.04
|
|||||||
|
|
||||||
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
|
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
|
||||||
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
|
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.
|
tiering code.
|
||||||
|
|||||||
Generated
+126
-145
@@ -701,9 +701,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-compression"
|
name = "async-compression"
|
||||||
version = "0.4.44"
|
version = "0.4.46"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844"
|
checksum = "4f10dafd0c8d2e51ae9a748805777613ed0bbe17bf586b76c8311f45c020a32f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"compression-codecs",
|
"compression-codecs",
|
||||||
"compression-core",
|
"compression-core",
|
||||||
@@ -1031,9 +1031,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aws-sdk-s3"
|
name = "aws-sdk-s3"
|
||||||
version = "1.145.0"
|
version = "1.146.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0e6320417a37c8a62f78b443d0b4cf628b57cd340a09b0eb56173d47cc94e93"
|
checksum = "37b5ffaae346b9bd486ebdc3eb7053ec8ab8a1ad0f19a332eefeff036ed559e6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"aws-credential-types",
|
"aws-credential-types",
|
||||||
@@ -1735,18 +1735,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bon"
|
name = "bon"
|
||||||
version = "3.10.0"
|
version = "3.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e3fac94a66da67200398458a25412bcc3f9b6443b5119a6cad9cf3ccfcd8cc6"
|
checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bon-macros",
|
"bon-macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bon-macros"
|
name = "bon-macros"
|
||||||
version = "3.10.0"
|
version = "3.10.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d4654961ad0494e4774c5c60b4cb4cd0ae9b9d92d039d901638b1dba97ebebf5"
|
checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.24.1",
|
"darling 0.24.1",
|
||||||
"ident_case",
|
"ident_case",
|
||||||
@@ -2220,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width 0.2.2",
|
"unicode-width",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2238,9 +2238,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.39"
|
version = "0.4.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df"
|
checksum = "58a6d0db8759036a783bc7c3f7a07f8cef3bf9470eb1db3bc86e8bcd1c5d0fe8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"brotli 8.0.4",
|
"brotli 8.0.4",
|
||||||
"bzip2",
|
"bzip2",
|
||||||
@@ -2249,8 +2249,8 @@ dependencies = [
|
|||||||
"liblzma",
|
"liblzma",
|
||||||
"lz4",
|
"lz4",
|
||||||
"memchr",
|
"memchr",
|
||||||
"zstd 0.13.3",
|
"zstd 0.14.0",
|
||||||
"zstd-safe 7.3.0",
|
"zstd-safe 8.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2408,6 +2408,12 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_detect"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpp_demangle"
|
name = "cpp_demangle"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3927,16 +3933,6 @@ dependencies = [
|
|||||||
"dirs-sys",
|
"dirs-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dirs-next"
|
|
||||||
version = "2.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"dirs-sys-next",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs-sys"
|
name = "dirs-sys"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -3945,21 +3941,10 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.2",
|
"redox_users",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "dirs-sys-next"
|
|
||||||
version = "0.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"redox_users 0.4.6",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dispatch2"
|
name = "dispatch2"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -4057,6 +4042,7 @@ dependencies = [
|
|||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
"suppaftp",
|
"suppaftp",
|
||||||
|
"tempfile",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
@@ -4207,11 +4193,17 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.41"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
checksum = "7b5ef0006ac9ab233c38522f5ae99cae3625151de8f706cacee1cba4b8e2832a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"core_detect",
|
||||||
|
"multiversion",
|
||||||
|
"multiversion_no_op",
|
||||||
|
"rustversion",
|
||||||
|
"scopeguard",
|
||||||
|
"simdutf8",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5070,9 +5062,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.12.1"
|
version = "0.12.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
|
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.17.1",
|
"hashbrown 0.17.1",
|
||||||
]
|
]
|
||||||
@@ -5286,9 +5278,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hotpath"
|
name = "hotpath"
|
||||||
version = "0.25.0"
|
version = "0.25.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2ec7782e005cabd5eaf350febde384cd799faa3a0e624587aa8759c240e0b592"
|
checksum = "366c5db5a8e4643bdde121a1d6f35cfa035e7850b8b8ea94c6960a139d44e93a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
@@ -5297,6 +5289,7 @@ dependencies = [
|
|||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"flate2",
|
"flate2",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hdrhistogram",
|
"hdrhistogram",
|
||||||
"hotpath-macros",
|
"hotpath-macros",
|
||||||
@@ -5305,9 +5298,8 @@ dependencies = [
|
|||||||
"object 0.36.7",
|
"object 0.36.7",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"prettytable-rs",
|
|
||||||
"quanta",
|
"quanta",
|
||||||
"regex",
|
"regex-lite",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"reqwest-middleware",
|
"reqwest-middleware",
|
||||||
"rustc-demangle",
|
"rustc-demangle",
|
||||||
@@ -5409,9 +5401,9 @@ checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hybrid-array"
|
name = "hybrid-array"
|
||||||
version = "0.4.14"
|
version = "0.4.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ctutils",
|
"ctutils",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -5710,9 +5702,9 @@ checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "io-uring"
|
name = "io-uring"
|
||||||
version = "0.7.14"
|
version = "0.7.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d64d8ca234d152948ceaede1f419b6a83983a5ecccaac05fb337a809c96d3aa6"
|
checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
@@ -5750,17 +5742,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "is-terminal"
|
|
||||||
version = "0.4.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
|
||||||
dependencies = [
|
|
||||||
"hermit-abi",
|
|
||||||
"libc",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_debug"
|
name = "is_debug"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
@@ -6020,9 +6001,9 @@ checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lapin"
|
name = "lapin"
|
||||||
version = "4.10.0"
|
version = "4.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fd20e01fd92597ca352ca7ceed3c589851ebad279dfcada48aa4d24fd3a7caa"
|
checksum = "24b99d8cdfe3f6223f9f75e35e7510a8662767bee27c1cf870d503fc3bb2428a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"amq-protocol",
|
"amq-protocol",
|
||||||
"async-rs",
|
"async-rs",
|
||||||
@@ -6723,6 +6704,33 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b4ca4bea16ffc3f443cf7d866912118196bfef4c6a1556ca00f9f9b00bb43f7c"
|
||||||
|
dependencies = [
|
||||||
|
"multiversion-macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion-macros"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d416831a7317ef4b08bee00b69cbbb9c8763da7959a7026244d6266869f9c83"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"rustversion",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "multiversion_no_op"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "murmur3"
|
name = "murmur3"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -7708,9 +7716,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pe-unwind-info"
|
name = "pe-unwind-info"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "97f6fccfd2d9d2df765ca23ff85fe5cc437fb0e6d3e164e4d3cbe09d14780c93"
|
checksum = "e33c6dbf1a8fb7f71742cd70f5e9f0986e60b2d19dc0b28d9ca0d1323259274a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arrayvec",
|
"arrayvec",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
@@ -8186,19 +8194,6 @@ dependencies = [
|
|||||||
"syn 3.0.5",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "prettytable-rs"
|
|
||||||
version = "0.10.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a"
|
|
||||||
dependencies = [
|
|
||||||
"encode_unicode",
|
|
||||||
"is-terminal",
|
|
||||||
"lazy_static",
|
|
||||||
"term",
|
|
||||||
"unicode-width 0.1.14",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "primefield"
|
name = "primefield"
|
||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
@@ -8518,24 +8513,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-core"
|
name = "qrcode-core"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "79de8ed9b3c65c866e0ca79ba3aa8b05264e5183b0f894f1ea43b3a53146d657"
|
checksum = "5a5c0d85fe467f79319e9f0d0ffe43f8c32defae67263f78d57e33d8b4a638bf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-decode"
|
name = "qrcode-decode"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41c307d82a6b418dc99004a68cdd621c0606874faae8645036eef9168ec5aef4"
|
checksum = "04cf729caefa8fd0919f8d111a4e6531942ac48742e7649ed4c9c159b41a6fda"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-eps"
|
name = "qrcode-eps"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1cf7d12b9e539644309c628db9436be7024e0fbe16f440109ce92d7c9787688"
|
checksum = "d289a6212464685d1e598c3692e02189a096b2f4abe1acdb1f4fba6a932809d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8543,9 +8538,19 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-html"
|
name = "qrcode-html"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7fb7d790f2b34ce1edbafdf7a6802e73694aa94798be275c63acb780a39c1e6c"
|
checksum = "5a14b98d4aa6278f7e7218ad901837a85cef040a262127d0c4b6f8d3bd51d46e"
|
||||||
|
dependencies = [
|
||||||
|
"qrcode-core",
|
||||||
|
"qrcode-render",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode-image"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c7923bee1dc13ef96b9778e9299d803ae9f5de38971cb8e55c141022cc296618"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8553,15 +8558,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-parse"
|
name = "qrcode-parse"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "84f6484a2329e87b5a4b21c983d68bee32a442087287c0679efe782689ee469a"
|
checksum = "0726e7e2acd6335b63936ba1d784361765313f53274942efb0636407e882fdc9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-pdf"
|
name = "qrcode-pdf"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "deda8c86193091e31856024288e986869559ace2a71b7b477f59b4caea4dabf4"
|
checksum = "41ed9a51cab153e441963f8ec64ffc980d7a40bfd7fa8f6e0edd290424cda206"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8569,9 +8574,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-pic"
|
name = "qrcode-pic"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "03bda277eb6e11b06e0ebe0cb1b519d71467a3f1d7f6408336af9a979edb1091"
|
checksum = "038be675ee01aeae82f20eea4b043054f8232e14d3ccc7836803510ad3ff6524"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8579,23 +8584,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-render"
|
name = "qrcode-render"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a40771febec53bdf7de3b3de6549950a7c9f54eef58477d99f4de2fd9f114f9"
|
checksum = "7af1ddfd0f2496c28276913e26289e8fb245cab16ece7efcf9df46e2027dd3c8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-rs"
|
name = "qrcode-rs"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a62237103e62d01195453a4b34d56a35f59cf8c8ca3fa8ddcfca84336d5c4a2a"
|
checksum = "246e31e4a82d59d4714c589e4912f27bc67f5fa205cc4e3bdf916ccc640af010"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-decode",
|
"qrcode-decode",
|
||||||
"qrcode-eps",
|
"qrcode-eps",
|
||||||
"qrcode-html",
|
"qrcode-html",
|
||||||
|
"qrcode-image",
|
||||||
"qrcode-parse",
|
"qrcode-parse",
|
||||||
"qrcode-pdf",
|
"qrcode-pdf",
|
||||||
"qrcode-pic",
|
"qrcode-pic",
|
||||||
@@ -8605,9 +8611,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "qrcode-svg"
|
name = "qrcode-svg"
|
||||||
version = "2.0.0"
|
version = "2.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "513f1dd2a0e11d1bcc4dd3268887cdad059ed8ab5269d45ce57aada1256c5405"
|
checksum = "19f641cac9a021d31948437c740baec47243c03586c3230c839b76df9e4df084"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"qrcode-core",
|
"qrcode-core",
|
||||||
"qrcode-render",
|
"qrcode-render",
|
||||||
@@ -8980,17 +8986,6 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "redox_users"
|
|
||||||
version = "0.4.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
|
||||||
dependencies = [
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"libredox",
|
|
||||||
"thiserror 1.0.69",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -9083,11 +9078,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
|
checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
@@ -9309,9 +9304,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "russh"
|
name = "russh"
|
||||||
version = "0.63.2"
|
version = "0.63.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8e134e2480f4e86f83e4aa45b4c0a9723f84beaffa694c54bdf056e74efdd7dd"
|
checksum = "036204edbd199552a5b3832f63c60dcdf395dc44c7f06b4af1c0e8139cc11bce"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.3",
|
"aes 0.9.3",
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
@@ -9392,9 +9387,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "russh-sftp"
|
name = "russh-sftp"
|
||||||
version = "2.4.0"
|
version = "3.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b"
|
checksum = "093197e526668d92bba562e2bbbe98d1af9831bf080b619c736316ca1fa35101"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -9906,6 +9901,7 @@ dependencies = [
|
|||||||
"regex",
|
"regex",
|
||||||
"rmp",
|
"rmp",
|
||||||
"rmp-serde",
|
"rmp-serde",
|
||||||
|
"rustfs-config",
|
||||||
"rustfs-utils",
|
"rustfs-utils",
|
||||||
"s3s",
|
"s3s",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -9926,6 +9922,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"base64-simd",
|
"base64-simd",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"chrono",
|
||||||
"crc-fast",
|
"crc-fast",
|
||||||
"futures",
|
"futures",
|
||||||
"hotpath",
|
"hotpath",
|
||||||
@@ -10576,6 +10573,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"sha1 0.11.0",
|
"sha1 0.11.0",
|
||||||
"sha2 0.11.0",
|
"sha2 0.11.0",
|
||||||
|
"temp-env",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-test",
|
"tokio-test",
|
||||||
@@ -11082,9 +11080,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.43"
|
version = "0.23.44"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
@@ -11600,11 +11598,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.22.0"
|
version = "3.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
|
checksum = "935177bb8c0cd8ca1a4e6d1a2ac8988bea69cab4f9d3a31311e012ad27868ea4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.23.1",
|
||||||
"bs58",
|
"bs58",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
@@ -11621,14 +11619,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_with_macros"
|
name = "serde_with_macros"
|
||||||
version = "3.22.0"
|
version = "3.23.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
|
checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"darling 0.23.0",
|
"darling 0.24.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -12192,9 +12190,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "suppaftp"
|
name = "suppaftp"
|
||||||
version = "11.0.0"
|
version = "12.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "46c5095831abc0d7944a2d50d6ec6abcd75b9d165d9377deb3e45798cae2343a"
|
checksum = "9c6978f1b04fc86f122f767b88a9d1d89e80cfe3dc97d670598507d713c4ce48"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -12413,17 +12411,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "term"
|
|
||||||
version = "0.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f"
|
|
||||||
dependencies = [
|
|
||||||
"dirs-next",
|
|
||||||
"rustversion",
|
|
||||||
"winapi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termcolor"
|
name = "termcolor"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
@@ -13190,12 +13177,6 @@ version = "1.13.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-width"
|
|
||||||
version = "0.1.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-width"
|
name = "unicode-width"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -13996,18 +13977,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
|
checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.56"
|
version = "0.8.57"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
|
checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
+10
-10
@@ -145,7 +145,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.0-rc.5" }
|
|||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
async_zip = { default-features = false, version = "0.0.19" }
|
async_zip = { default-features = false, version = "0.0.19" }
|
||||||
mysql_async = { default-features = false, version = "0.37.1" }
|
mysql_async = { default-features = false, version = "0.37.1" }
|
||||||
async-compression = { version = "0.4.44" }
|
async-compression = { version = "0.4.46" }
|
||||||
async-recursion = "1.1.1"
|
async-recursion = "1.1.1"
|
||||||
async-trait = "0.1.92"
|
async-trait = "0.1.92"
|
||||||
async-nats = { version = "0.50.0", default-features = false }
|
async-nats = { version = "0.50.0", default-features = false }
|
||||||
@@ -156,7 +156,7 @@ futures-lite = "2.6.1"
|
|||||||
futures-util = "0.3.34"
|
futures-util = "0.3.34"
|
||||||
pollster = "1.0.1"
|
pollster = "1.0.1"
|
||||||
pulsar = { default-features = false, version = "6.9.0" }
|
pulsar = { default-features = false, version = "6.9.0" }
|
||||||
lapin = { default-features = false, version = "4.10.0" }
|
lapin = { default-features = false, version = "4.11.0" }
|
||||||
hyper = { version = "1.11.1" }
|
hyper = { version = "1.11.1" }
|
||||||
hyper-rustls = { default-features = false, version = "0.27.9" }
|
hyper-rustls = { default-features = false, version = "0.27.9" }
|
||||||
hyper-util = { version = "0.1.20" }
|
hyper-util = { version = "0.1.20" }
|
||||||
@@ -164,7 +164,7 @@ http = "1.5.0"
|
|||||||
http-body = "1.1.0"
|
http-body = "1.1.0"
|
||||||
http-body-util = "0.1.5"
|
http-body-util = "0.1.5"
|
||||||
minlz = "1.2.3"
|
minlz = "1.2.3"
|
||||||
reqwest = "0.13.4"
|
reqwest = "0.13.5"
|
||||||
rustfs-kafka-async = { version = "1.3.1" }
|
rustfs-kafka-async = { version = "1.3.1" }
|
||||||
socket2 = { version = "0.6.5" }
|
socket2 = { version = "0.6.5" }
|
||||||
tokio = { version = "1.53.1" }
|
tokio = { version = "1.53.1" }
|
||||||
@@ -211,7 +211,7 @@ openidconnect = { default-features = false, version = "4.0" }
|
|||||||
pbkdf2 = "0.13.0"
|
pbkdf2 = "0.13.0"
|
||||||
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
|
p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] }
|
||||||
rsa = { version = "=0.10.0-rc.18" }
|
rsa = { version = "=0.10.0-rc.18" }
|
||||||
rustls = { default-features = false, version = "0.23.43" }
|
rustls = { default-features = false, version = "0.23.44" }
|
||||||
rustls-native-certs = "0.8"
|
rustls-native-certs = "0.8"
|
||||||
rustls-pki-types = "1.15.1"
|
rustls-pki-types = "1.15.1"
|
||||||
x509-parser = "0.18.1"
|
x509-parser = "0.18.1"
|
||||||
@@ -245,7 +245,7 @@ atomic_enum = "0.3.0"
|
|||||||
aws-config = { version = "1.12.0" }
|
aws-config = { version = "1.12.0" }
|
||||||
aws-credential-types = { version = "1.3.0" }
|
aws-credential-types = { version = "1.3.0" }
|
||||||
aws-sdk-kms = { default-features = false, version = "1.118.0" }
|
aws-sdk-kms = { default-features = false, version = "1.118.0" }
|
||||||
aws-sdk-s3 = { default-features = false, version = "1.145.0" }
|
aws-sdk-s3 = { default-features = false, version = "1.146.0" }
|
||||||
aws-sdk-sts = { default-features = false, version = "1.114.0" }
|
aws-sdk-sts = { default-features = false, version = "1.114.0" }
|
||||||
aws-smithy-async = { version = "1.3.0" }
|
aws-smithy-async = { version = "1.3.0" }
|
||||||
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
aws-smithy-http-client = { default-features = false, version = "1.4.0" }
|
||||||
@@ -297,7 +297,7 @@ percent-encoding = "2.3.2"
|
|||||||
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
# Server-side QR rendering for TOTP enrollment, so neither the console nor the
|
||||||
# CLI needs its own QR encoder. No default features: the image/render backends
|
# CLI needs its own QR encoder. No default features: the image/render backends
|
||||||
# pull in an image stack this only needs SVG and text output from.
|
# pull in an image stack this only needs SVG and text output from.
|
||||||
qrcode-rs = { version = "2.0.0", default-features = false, features = ["std", "svg"] }
|
qrcode-rs = { version = "2.1.0", default-features = false, features = ["std", "svg"] }
|
||||||
pin-project-lite = "0.2.17"
|
pin-project-lite = "0.2.17"
|
||||||
pretty_assertions = "1.4.1"
|
pretty_assertions = "1.4.1"
|
||||||
rand = { version = "0.10.2" }
|
rand = { version = "0.10.2" }
|
||||||
@@ -362,10 +362,10 @@ pyroscope = { version = "2.1.1" }
|
|||||||
# FTP and SFTP
|
# FTP and SFTP
|
||||||
libunftp = { version = "0.23.0" }
|
libunftp = { version = "0.23.0" }
|
||||||
unftp-core = "0.1.0"
|
unftp-core = "0.1.0"
|
||||||
suppaftp = { version = "11.0.0" }
|
suppaftp = { version = "12.0.0" }
|
||||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||||
russh = { version = "0.63.2" }
|
russh = { version = "0.63.3" }
|
||||||
russh-sftp = "2.4.0"
|
russh-sftp = "3.0.0"
|
||||||
|
|
||||||
# WebDAV
|
# WebDAV
|
||||||
dav-server = "0.11.0"
|
dav-server = "0.11.0"
|
||||||
@@ -373,7 +373,7 @@ dav-server = "0.11.0"
|
|||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
rustfs-mimalloc = { version = "0.5.3" }
|
rustfs-mimalloc = { version = "0.5.3" }
|
||||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||||
hotpath = { version = "=0.25.0", default-features = false }
|
hotpath = { version = "0.25.1", default-features = false }
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,15 @@ Star RustFS on GitHub and be instantly notified of new releases.
|
|||||||
|
|
||||||
## Quickstart
|
## 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:
|
To get started with RustFS, follow these steps:
|
||||||
|
|
||||||
### 1. One-click Installation (Option 1)
|
### 1. One-click Installation (Option 1)
|
||||||
@@ -202,7 +211,10 @@ For developers who want to build RustFS Docker images from source with multi-arc
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build multi-architecture images locally
|
# Build multi-architecture images locally
|
||||||
./docker-buildx.sh --build-arg RELEASE=latest
|
./docker-buildx.sh
|
||||||
|
|
||||||
|
# Build a single-platform image locally
|
||||||
|
./docker-buildx.sh -p linux/amd64
|
||||||
|
|
||||||
# Build and push to registry
|
# Build and push to registry
|
||||||
./docker-buildx.sh --push
|
./docker-buildx.sh --push
|
||||||
|
|||||||
+38
-9
@@ -46,14 +46,31 @@ RustFS 是一个基于 Rust 构建的高性能分布式对象存储系统。Rust
|
|||||||
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
|
- **完全开源**:采用 Apache 2.0 许可证,鼓励社区贡献和商业使用。
|
||||||
- **简单易用**:设计简洁,易于部署和管理。
|
- **简单易用**:设计简洁,易于部署和管理。
|
||||||
|
|
||||||
| 功能 | 状态 | 功能 | 状态 |
|
状态说明:✅ 可用 —— 已发布并有 CI 门禁覆盖;🧪 预览 —— 已发布但需显式开关,或兼容性承诺有边界。
|
||||||
| :----------------- | :------ | :---------------------- | :-------- |
|
|
||||||
| **S3 核心功能** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
| 功能 | 状态 | 功能 | 状态 |
|
||||||
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
| :-------------------------- | :------ | :----------------------- | :------ |
|
||||||
| **版本控制** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
|
| **S3 核心功能** | ✅ 可用 | **分布式模式** | ✅ 可用 |
|
||||||
| **日志功能** | ✅ 可用 | **生命周期管理** | 🚧 测试中 |
|
| **上传 / 下载** | ✅ 可用 | **单机模式** | ✅ 可用 |
|
||||||
| **事件通知** | ✅ 可用 | **分布式模式** | 🚧 测试中 |
|
| **版本控制** | ✅ 可用 | **Bitrot (防数据腐烂)** | ✅ 可用 |
|
||||||
| **K8s Helm Chart** | ✅ 可用 | **OPA (策略引擎)** | 🚧 测试中 |
|
| **对象锁定 (WORM)** | ✅ 可用 | **修复与扫描器** | ✅ 可用 |
|
||||||
|
| **服务端加密 (SSE)** | ✅ 可用 | **存储池扩容 / 下线** | ✅ 可用 |
|
||||||
|
| **RustFS KMS** | ✅ 可用 | **存储桶复制** | ✅ 可用 |
|
||||||
|
| **生命周期管理 (ILM)** | ✅ 可用 | **站点复制** | ✅ 可用 |
|
||||||
|
| **ILM 分层 (远端 S3)** | ✅ 可用 | **存储桶配额** | ✅ 可用 |
|
||||||
|
| **S3 Select** | ✅ 可用 | **事件通知** | ✅ 可用 |
|
||||||
|
| **S3 Tables (Iceberg REST)**| 🧪 预览 | **审计日志** | ✅ 可用 |
|
||||||
|
| **IAM / 策略** | ✅ 可用 | **日志与可观测性** | ✅ 可用 |
|
||||||
|
| **OIDC / SSO** | ✅ 可用 | **Web 控制台** | ✅ 可用 |
|
||||||
|
| **Keystone 认证** | ✅ 可用 | **K8s Helm Chart** | ✅ 可用 |
|
||||||
|
| **Swift API** | ✅ 可用 | **FTPS / WebDAV** | ✅ 可用 |
|
||||||
|
| **多租户** | ✅ 可用 | **SFTP** | ✅ 可用 |
|
||||||
|
| **MinIO 磁盘格式兼容** | 🧪 预览 | | |
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- **服务端加密**:支持 SSE-C、SSE-S3 与 SSE-KMS。SSE-KMS 必须先配置 KMS 服务;未配置 KMS 时请求 `aws:kms` 会被拒绝,不会降级到本地主密钥。
|
||||||
|
- **RustFS KMS**:生产环境支持 Vault(KV2 / Transit)与 AWS KMS 后端;`Local` 与 `Static` 后端仅供开发与测试使用,详见 [KMS 后端安全属性](docs/operations/kms-backend-security.md)。
|
||||||
|
|
||||||
## RustFS vs MinIO 性能对比
|
## RustFS vs MinIO 性能对比
|
||||||
|
|
||||||
@@ -89,6 +106,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:
|
请按照以下步骤快速上手 RustFS:
|
||||||
|
|
||||||
### 1. 一键安装脚本 (选项 1)
|
### 1. 一键安装脚本 (选项 1)
|
||||||
@@ -141,7 +167,10 @@ docker compose -f docker-compose-simple.yml up -d
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 在本地构建多架构镜像
|
# 在本地构建多架构镜像
|
||||||
./docker-buildx.sh --build-arg RELEASE=latest
|
./docker-buildx.sh
|
||||||
|
|
||||||
|
# 在本地构建单平台镜像
|
||||||
|
./docker-buildx.sh -p linux/amd64
|
||||||
|
|
||||||
# 构建并推送到仓库
|
# 构建并推送到仓库
|
||||||
./docker-buildx.sh --push
|
./docker-buildx.sh --push
|
||||||
|
|||||||
+5
-2
@@ -33,8 +33,11 @@ Applies to all paths under `crates/`.
|
|||||||
|
|
||||||
## Type Casting
|
## 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.
|
- Never use `as` for numeric conversions that may truncate or overflow. Use
|
||||||
- `f64 as usize` saturates but is fragile; clamp to `[0, usize::MAX as f64]` first.
|
`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.
|
- Treat every `as` cast in a PR review as a potential bug; require justification.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|||||||
@@ -90,6 +90,93 @@ pub struct MrfIntent {
|
|||||||
pub attempts: u8,
|
pub attempts: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct MrfDurableRepairAnchor {
|
||||||
|
pub kind: MrfKind,
|
||||||
|
pub bucket: Arc<str>,
|
||||||
|
pub object: Arc<str>,
|
||||||
|
pub version_id: Option<[u8; 16]>,
|
||||||
|
pub scope: Option<MrfScope>,
|
||||||
|
pub lease: MrfIngressLease,
|
||||||
|
pub bucket_incarnation_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MrfDurableRepairAnchor {
|
||||||
|
/// Build a dischargeable anchor only when the caller supplies the storage
|
||||||
|
/// incarnation and the original ingress lease. Legacy replay records lack
|
||||||
|
/// both pieces and therefore remain fail-closed.
|
||||||
|
pub fn from_intent(intent: &MrfIntent, bucket_incarnation_id: Uuid) -> Option<Self> {
|
||||||
|
if bucket_incarnation_id.is_nil() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let lease = intent.lease?;
|
||||||
|
let (version_id, scope) = canonical_identity(intent.kind, intent.version_id, intent.scope);
|
||||||
|
Some(Self {
|
||||||
|
kind: intent.kind,
|
||||||
|
bucket: intent.bucket.clone(),
|
||||||
|
object: intent.object.clone(),
|
||||||
|
version_id,
|
||||||
|
scope,
|
||||||
|
lease,
|
||||||
|
bucket_incarnation_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_proven_by(&self, event: &MrfVerifiedRepairEvent) -> bool {
|
||||||
|
let Some(lease) = event.lease else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.kind == event.kind
|
||||||
|
&& self.bucket == event.bucket
|
||||||
|
&& self.object == event.object
|
||||||
|
&& self.version_id == event.version_id
|
||||||
|
&& self.scope == event.scope
|
||||||
|
&& self.lease == lease
|
||||||
|
&& self.bucket_incarnation_id == event.bucket_incarnation_id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume only anchors proven by a complete verified-repair identity. The
|
||||||
|
/// caller remains responsible for persisting the resulting anchor set before
|
||||||
|
/// deleting older replay files.
|
||||||
|
pub fn consume_verified_mrf_repair_events(anchors: &mut Vec<MrfDurableRepairAnchor>, events: &[MrfVerifiedRepairEvent]) -> usize {
|
||||||
|
let before = anchors.len();
|
||||||
|
anchors.retain(|anchor| !events.iter().any(|event| anchor.is_proven_by(event)));
|
||||||
|
before.saturating_sub(anchors.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consume recorded verified repairs for one bucket without draining
|
||||||
|
/// unrelated or still-unmatched proofs. If the caller crashes before
|
||||||
|
/// persisting the retained anchor set, the proof may be replayed by repair
|
||||||
|
/// instead of silently deleting the old responsibility.
|
||||||
|
pub fn consume_recorded_verified_mrf_repair_events_for(bucket: &str, anchors: &mut Vec<MrfDurableRepairAnchor>) -> usize {
|
||||||
|
let Some(registry) = MRF_VERIFIED_REPAIR_EVENTS.get() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let Ok(mut events) = registry.lock() else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let before = anchors.len();
|
||||||
|
let mut retained = std::collections::VecDeque::with_capacity(events.len());
|
||||||
|
while let Some(event) = events.pop_front() {
|
||||||
|
if event.bucket.as_ref() != bucket {
|
||||||
|
retained.push_back(event);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut matched = false;
|
||||||
|
anchors.retain(|anchor| {
|
||||||
|
let proven = anchor.is_proven_by(&event);
|
||||||
|
matched |= proven;
|
||||||
|
!proven
|
||||||
|
});
|
||||||
|
if !matched {
|
||||||
|
retained.push_back(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*events = retained;
|
||||||
|
before.saturating_sub(anchors.len())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
pub struct MrfScope {
|
pub struct MrfScope {
|
||||||
pub pool_index: u32,
|
pub pool_index: u32,
|
||||||
@@ -386,6 +473,27 @@ pub fn try_send_mrf_intent_typed(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acquire a fresh process-local lease for one durable replay record.
|
||||||
|
///
|
||||||
|
/// Journal records deliberately do not persist leases. A replay consumer must
|
||||||
|
/// call this before submitting the record so a later verified repair event can
|
||||||
|
/// identify the exact replay admission. Replay does not reserve the live
|
||||||
|
/// producer coalescer key: the replay queue first deduplicates legacy records,
|
||||||
|
/// then manager admission owns task-level deduplication with live producers.
|
||||||
|
pub fn try_rearm_mrf_replay_intent(intent: &mut MrfIntent) -> MrfIngressResult {
|
||||||
|
if intent.lease.is_some() {
|
||||||
|
return MrfIngressResult::Enqueued;
|
||||||
|
}
|
||||||
|
if intent.bucket.len() > MRF_MAX_IDENTITY_COMPONENT || intent.object.len() > MRF_MAX_IDENTITY_COMPONENT {
|
||||||
|
return MrfIngressResult::Dropped(MrfDropReason::OversizedIdentity);
|
||||||
|
}
|
||||||
|
let (version_id, scope) = canonical_identity(intent.kind, intent.version_id, intent.scope);
|
||||||
|
intent.version_id = version_id;
|
||||||
|
intent.scope = scope;
|
||||||
|
intent.lease = Some(MrfIngressLease::new(NEXT_MRF_LEASE.fetch_add(1, Ordering::Relaxed)));
|
||||||
|
MrfIngressResult::Enqueued
|
||||||
|
}
|
||||||
|
|
||||||
/// Release the ingress key once the consumer owns the intent.
|
/// Release the ingress key once the consumer owns the intent.
|
||||||
pub fn release_mrf_intent(intent: &MrfIntent) {
|
pub fn release_mrf_intent(intent: &MrfIntent) {
|
||||||
release_mrf_identity(intent.kind, &intent.bucket, &intent.object, intent.version_id, intent.scope, intent.lease);
|
release_mrf_identity(intent.kind, &intent.bucket, &intent.object, intent.version_id, intent.scope, intent.lease);
|
||||||
@@ -432,12 +540,33 @@ pub struct MrfRepairedEvent {
|
|||||||
pub version_id: Option<[u8; 16]>,
|
pub version_id: Option<[u8; 16]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum MrfVerifiedRepairDisposition {
|
||||||
|
Repaired,
|
||||||
|
VerifiedHealthy,
|
||||||
|
AuthoritativelyAbsent,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MrfVerifiedRepairEvent {
|
||||||
|
pub kind: MrfKind,
|
||||||
|
pub bucket: Arc<str>,
|
||||||
|
pub object: Arc<str>,
|
||||||
|
pub version_id: Option<[u8; 16]>,
|
||||||
|
pub scope: Option<MrfScope>,
|
||||||
|
pub lease: Option<MrfIngressLease>,
|
||||||
|
pub bucket_incarnation_id: Uuid,
|
||||||
|
pub disposition: MrfVerifiedRepairDisposition,
|
||||||
|
}
|
||||||
|
|
||||||
/// Bound on the repaired-event backlog. Notices are best-effort hints; when
|
/// Bound on the repaired-event backlog. Notices are best-effort hints; when
|
||||||
/// the ring is full the oldest are dropped and the affected ledger entries
|
/// the ring is full the oldest are dropped and the affected ledger entries
|
||||||
/// simply expire through their own attempts/age limits.
|
/// simply expire through their own attempts/age limits.
|
||||||
const MRF_REPAIRED_EVENT_CAP: usize = 4096;
|
const MRF_REPAIRED_EVENT_CAP: usize = 4096;
|
||||||
|
|
||||||
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
static MRF_REPAIRED_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfRepairedEvent>>> = OnceLock::new();
|
||||||
|
static MRF_VERIFIED_REPAIR_EVENTS: OnceLock<std::sync::Mutex<std::collections::VecDeque<MrfVerifiedRepairEvent>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
/// Record a legacy notification for compatibility. This is not an
|
/// Record a legacy notification for compatibility. This is not an
|
||||||
/// acknowledgement of storage verification or durable repair completion.
|
/// acknowledgement of storage verification or durable repair completion.
|
||||||
@@ -478,6 +607,43 @@ pub fn take_mrf_repaired_events_for(bucket: &str) -> Vec<MrfRepairedEvent> {
|
|||||||
taken
|
taken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record a storage-owned MRF completion proof. Unlike the legacy repaired
|
||||||
|
/// event, this identity is complete enough for future durable ledgers to make
|
||||||
|
/// an exact responsibility decision.
|
||||||
|
pub fn note_mrf_verified_repair(event: MrfVerifiedRepairEvent) {
|
||||||
|
let registry = MRF_VERIFIED_REPAIR_EVENTS.get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()));
|
||||||
|
let Ok(mut events) = registry.lock() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if events.len() >= MRF_REPAIRED_EVENT_CAP {
|
||||||
|
events.pop_front();
|
||||||
|
}
|
||||||
|
events.push_back(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take verified repair events recorded for `bucket`, leaving other buckets'
|
||||||
|
/// proofs in place. Consumers still have to match kind, object, version, scope
|
||||||
|
/// lease and incarnation before discharging durable responsibility.
|
||||||
|
pub fn take_mrf_verified_repair_events_for(bucket: &str) -> Vec<MrfVerifiedRepairEvent> {
|
||||||
|
let Some(registry) = MRF_VERIFIED_REPAIR_EVENTS.get() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Ok(mut events) = registry.lock() else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut taken = Vec::new();
|
||||||
|
let mut retained = std::collections::VecDeque::with_capacity(events.len());
|
||||||
|
while let Some(event) = events.pop_front() {
|
||||||
|
if event.bucket.as_ref() == bucket {
|
||||||
|
taken.push(event);
|
||||||
|
} else {
|
||||||
|
retained.push_back(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*events = retained;
|
||||||
|
taken
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -546,6 +712,212 @@ mod tests {
|
|||||||
assert_eq!(metadata_scope, None);
|
assert_eq!(metadata_scope, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durable_repair_anchor_requires_lease_and_bucket_incarnation() {
|
||||||
|
let mut intent = MrfIntent {
|
||||||
|
bucket: Arc::from("durable-anchor-bucket"),
|
||||||
|
object: Arc::from("object"),
|
||||||
|
version_id: Some([0; 16]),
|
||||||
|
kind: MrfKind::PartialWrite,
|
||||||
|
scope: Some(MrfScope {
|
||||||
|
pool_index: 1,
|
||||||
|
set_index: 2,
|
||||||
|
}),
|
||||||
|
lease: None,
|
||||||
|
enqueued_at_ms: 0,
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
MrfDurableRepairAnchor::from_intent(&intent, Uuid::new_v4()).is_none(),
|
||||||
|
"legacy replay records without the ingress lease must remain anchored"
|
||||||
|
);
|
||||||
|
|
||||||
|
intent.lease = Some(MrfIngressLease::new(7));
|
||||||
|
assert!(
|
||||||
|
MrfDurableRepairAnchor::from_intent(&intent, Uuid::nil()).is_none(),
|
||||||
|
"nil bucket incarnation cannot prove durable successor ownership"
|
||||||
|
);
|
||||||
|
|
||||||
|
let anchor = MrfDurableRepairAnchor::from_intent(&intent, Uuid::new_v4())
|
||||||
|
.expect("complete identity should create a durable repair anchor");
|
||||||
|
assert_eq!(anchor.version_id, None, "nil UUID is canonicalized before matching");
|
||||||
|
assert_eq!(
|
||||||
|
anchor.scope,
|
||||||
|
Some(MrfScope {
|
||||||
|
pool_index: 1,
|
||||||
|
set_index: 2
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn durable_replay_rearm_assigns_a_fresh_dischargeable_lease() {
|
||||||
|
let unique = Uuid::new_v4();
|
||||||
|
let mut intent = MrfIntent {
|
||||||
|
bucket: Arc::from(format!("replay-{unique}")),
|
||||||
|
object: Arc::from("object"),
|
||||||
|
version_id: Some([0; 16]),
|
||||||
|
kind: MrfKind::PartialWrite,
|
||||||
|
scope: Some(MrfScope {
|
||||||
|
pool_index: 2,
|
||||||
|
set_index: 3,
|
||||||
|
}),
|
||||||
|
lease: None,
|
||||||
|
enqueued_at_ms: 1,
|
||||||
|
attempts: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(try_rearm_mrf_replay_intent(&mut intent), MrfIngressResult::Enqueued);
|
||||||
|
assert_eq!(intent.version_id, None, "nil versions remain canonical during replay");
|
||||||
|
assert!(intent.lease.is_some(), "replay admission must carry a fresh lease");
|
||||||
|
assert!(
|
||||||
|
MrfDurableRepairAnchor::from_intent(&intent, Uuid::new_v4()).is_some(),
|
||||||
|
"a rearmed replay record can participate in exact durable proof matching"
|
||||||
|
);
|
||||||
|
release_mrf_intent(&intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verified_repair_events_consume_only_exact_durable_anchors() {
|
||||||
|
let bucket = Arc::<str>::from("proof-bucket");
|
||||||
|
let object = Arc::<str>::from("object");
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let lease = MrfIngressLease::new(11);
|
||||||
|
let anchor = MrfDurableRepairAnchor {
|
||||||
|
kind: MrfKind::PartialWrite,
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
object: object.clone(),
|
||||||
|
version_id: Some([3; 16]),
|
||||||
|
scope: Some(MrfScope {
|
||||||
|
pool_index: 4,
|
||||||
|
set_index: 5,
|
||||||
|
}),
|
||||||
|
lease,
|
||||||
|
bucket_incarnation_id: incarnation,
|
||||||
|
};
|
||||||
|
let event = MrfVerifiedRepairEvent {
|
||||||
|
kind: anchor.kind,
|
||||||
|
bucket,
|
||||||
|
object,
|
||||||
|
version_id: anchor.version_id,
|
||||||
|
scope: anchor.scope,
|
||||||
|
lease: Some(lease),
|
||||||
|
bucket_incarnation_id: incarnation,
|
||||||
|
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||||
|
};
|
||||||
|
|
||||||
|
for rejected in [
|
||||||
|
MrfVerifiedRepairEvent {
|
||||||
|
lease: None,
|
||||||
|
..event.clone()
|
||||||
|
},
|
||||||
|
MrfVerifiedRepairEvent {
|
||||||
|
lease: Some(MrfIngressLease::new(12)),
|
||||||
|
..event.clone()
|
||||||
|
},
|
||||||
|
MrfVerifiedRepairEvent {
|
||||||
|
bucket_incarnation_id: Uuid::new_v4(),
|
||||||
|
..event.clone()
|
||||||
|
},
|
||||||
|
MrfVerifiedRepairEvent {
|
||||||
|
version_id: Some([4; 16]),
|
||||||
|
..event.clone()
|
||||||
|
},
|
||||||
|
MrfVerifiedRepairEvent {
|
||||||
|
scope: Some(MrfScope {
|
||||||
|
pool_index: 4,
|
||||||
|
set_index: 6,
|
||||||
|
}),
|
||||||
|
..event.clone()
|
||||||
|
},
|
||||||
|
MrfVerifiedRepairEvent {
|
||||||
|
kind: MrfKind::DecodeFailure,
|
||||||
|
..event.clone()
|
||||||
|
},
|
||||||
|
MrfVerifiedRepairEvent {
|
||||||
|
bucket: Arc::from("other-bucket"),
|
||||||
|
..event.clone()
|
||||||
|
},
|
||||||
|
MrfVerifiedRepairEvent {
|
||||||
|
object: Arc::from("other"),
|
||||||
|
..event.clone()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let mut retained = vec![anchor.clone()];
|
||||||
|
assert_eq!(consume_verified_mrf_repair_events(&mut retained, &[rejected]), 0);
|
||||||
|
assert_eq!(retained, vec![anchor.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut retained = vec![anchor];
|
||||||
|
assert_eq!(consume_verified_mrf_repair_events(&mut retained, &[event]), 1);
|
||||||
|
assert!(retained.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recorded_verified_repair_consumer_retains_unmatched_proofs() {
|
||||||
|
let bucket = Arc::<str>::from(format!("recorded-proof-{}", Uuid::new_v4()));
|
||||||
|
let other_bucket = Arc::<str>::from(format!("recorded-proof-other-{}", Uuid::new_v4()));
|
||||||
|
let incarnation = Uuid::new_v4();
|
||||||
|
let lease = MrfIngressLease::new(21);
|
||||||
|
let retained_anchor = MrfDurableRepairAnchor {
|
||||||
|
kind: MrfKind::PartialWrite,
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
object: Arc::from("retained"),
|
||||||
|
version_id: Some([7; 16]),
|
||||||
|
scope: Some(MrfScope {
|
||||||
|
pool_index: 1,
|
||||||
|
set_index: 2,
|
||||||
|
}),
|
||||||
|
lease,
|
||||||
|
bucket_incarnation_id: incarnation,
|
||||||
|
};
|
||||||
|
let waiting_anchor = MrfDurableRepairAnchor {
|
||||||
|
object: Arc::from("waiting"),
|
||||||
|
lease: MrfIngressLease::new(22),
|
||||||
|
..retained_anchor.clone()
|
||||||
|
};
|
||||||
|
let matched_event = MrfVerifiedRepairEvent {
|
||||||
|
kind: retained_anchor.kind,
|
||||||
|
bucket: bucket.clone(),
|
||||||
|
object: retained_anchor.object.clone(),
|
||||||
|
version_id: retained_anchor.version_id,
|
||||||
|
scope: retained_anchor.scope,
|
||||||
|
lease: Some(retained_anchor.lease),
|
||||||
|
bucket_incarnation_id: retained_anchor.bucket_incarnation_id,
|
||||||
|
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||||
|
};
|
||||||
|
let same_bucket_unmatched = MrfVerifiedRepairEvent {
|
||||||
|
object: Arc::from("future"),
|
||||||
|
lease: Some(MrfIngressLease::new(23)),
|
||||||
|
..matched_event.clone()
|
||||||
|
};
|
||||||
|
let other_bucket_event = MrfVerifiedRepairEvent {
|
||||||
|
bucket: other_bucket.clone(),
|
||||||
|
..matched_event.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
note_mrf_verified_repair(matched_event);
|
||||||
|
note_mrf_verified_repair(same_bucket_unmatched.clone());
|
||||||
|
note_mrf_verified_repair(other_bucket_event.clone());
|
||||||
|
|
||||||
|
let mut anchors = vec![retained_anchor, waiting_anchor.clone()];
|
||||||
|
assert_eq!(consume_recorded_verified_mrf_repair_events_for(&bucket, &mut anchors), 1);
|
||||||
|
assert_eq!(anchors, vec![waiting_anchor]);
|
||||||
|
|
||||||
|
let remaining_bucket_events = take_mrf_verified_repair_events_for(&bucket);
|
||||||
|
assert_eq!(
|
||||||
|
remaining_bucket_events,
|
||||||
|
vec![same_bucket_unmatched],
|
||||||
|
"same-bucket proofs without a retained anchor must remain available"
|
||||||
|
);
|
||||||
|
let remaining_other_events = take_mrf_verified_repair_events_for(&other_bucket);
|
||||||
|
assert_eq!(
|
||||||
|
remaining_other_events,
|
||||||
|
vec![other_bucket_event],
|
||||||
|
"proofs for other buckets must not be drained by this consumer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn try_send_delivers_and_respects_capacity() {
|
async fn try_send_delivers_and_respects_capacity() {
|
||||||
let mut receiver = init_mrf_channel().expect("first initialization should succeed");
|
let mut receiver = init_mrf_channel().expect("first initialization should succeed");
|
||||||
@@ -610,4 +982,32 @@ mod tests {
|
|||||||
assert_eq!(flooded.len(), MRF_REPAIRED_EVENT_CAP);
|
assert_eq!(flooded.len(), MRF_REPAIRED_EVENT_CAP);
|
||||||
assert_eq!(flooded[0].object.as_ref(), "object-9", "the oldest notices past the cap are dropped");
|
assert_eq!(flooded[0].object.as_ref(), "object-9", "the oldest notices past the cap are dropped");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verified_repair_events_preserve_full_identity_and_bucket_scope() {
|
||||||
|
let bucket_incarnation_id = Uuid::new_v4();
|
||||||
|
let event = MrfVerifiedRepairEvent {
|
||||||
|
kind: MrfKind::PartialWrite,
|
||||||
|
bucket: Arc::from("verified-bucket-a"),
|
||||||
|
object: Arc::from("object-a"),
|
||||||
|
version_id: Some([4u8; 16]),
|
||||||
|
scope: Some(MrfScope {
|
||||||
|
pool_index: 2,
|
||||||
|
set_index: 3,
|
||||||
|
}),
|
||||||
|
lease: Some(MrfIngressLease::new(42)),
|
||||||
|
bucket_incarnation_id,
|
||||||
|
disposition: MrfVerifiedRepairDisposition::Repaired,
|
||||||
|
};
|
||||||
|
note_mrf_verified_repair(event.clone());
|
||||||
|
note_mrf_verified_repair(MrfVerifiedRepairEvent {
|
||||||
|
bucket: Arc::from("verified-bucket-b"),
|
||||||
|
..event.clone()
|
||||||
|
});
|
||||||
|
|
||||||
|
let taken = take_mrf_verified_repair_events_for("verified-bucket-a");
|
||||||
|
assert_eq!(taken, vec![event]);
|
||||||
|
assert!(take_mrf_verified_repair_events_for("verified-bucket-a").is_empty());
|
||||||
|
assert_eq!(take_mrf_verified_repair_events_for("verified-bucket-b").len(), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -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.
|
- `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
|
## 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.
|
- `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.
|
||||||
@@ -153,14 +158,24 @@ concurrently. Small direct PUTs stay on the legacy path.
|
|||||||
- default is `0`.
|
- default is `0`.
|
||||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
- `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.
|
- 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 `30000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing.
|
- 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`
|
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING`
|
||||||
- maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
|
- 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).
|
- 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`
|
- `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.
|
- 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.
|
- 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
|
## Remote tier timeout environment variables
|
||||||
|
|
||||||
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
|
- `RUSTFS_TIER_REMOTE_CONNECT_TIMEOUT_SECS`
|
||||||
|
|||||||
@@ -90,3 +90,20 @@ pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
|
|||||||
|
|
||||||
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
|
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
|
||||||
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
|
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 and maximum accepted value for `RUSTFS_API_OBJECT_MAX_VERSIONS`.
|
||||||
|
#[cfg(target_pointer_width = "64")]
|
||||||
|
pub const DEFAULT_API_OBJECT_MAX_VERSIONS: usize = 9_223_372_036_854_775_807;
|
||||||
|
|
||||||
|
/// Default and maximum accepted value for `RUSTFS_API_OBJECT_MAX_VERSIONS`.
|
||||||
|
#[cfg(not(target_pointer_width = "64"))]
|
||||||
|
pub const DEFAULT_API_OBJECT_MAX_VERSIONS: usize = usize::MAX;
|
||||||
|
|||||||
@@ -376,21 +376,39 @@ pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
|
|||||||
///
|
///
|
||||||
/// SDK-default multipart clients send every part of an upload concurrently, so
|
/// 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
|
/// a single node routinely sees several times more parts in flight than the
|
||||||
/// permit pool allows. Those parts have not ingested a body yet, so queueing
|
/// permit pool allows. A queued part waits before body ingest, so the pool
|
||||||
/// them costs a connection rather than memory or internode streams; the pool
|
/// still bounds the number of parts being written, but the wait is not free:
|
||||||
/// still bounds the number of parts being written. The wait is long enough for
|
/// RustFS does not read the request body while the part is queued (hyper only
|
||||||
/// an ordinary queue to drain on modest hardware, and a part that cannot get a
|
/// sends `100 Continue` once the body is first polled, and the AWS SDKs send
|
||||||
/// permit within it fails with S3 `SlowDown`/503 for the client to retry.
|
/// the body after a 1-3 s `Expect: 100-continue` grace anyway), so the
|
||||||
/// `0` rejects immediately when the pool is full.
|
/// 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 =
|
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str =
|
||||||
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
||||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 30_000;
|
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.
|
/// Maximum multipart UploadPart requests waiting for a foreground write permit per process.
|
||||||
///
|
///
|
||||||
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
|
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
|
||||||
/// waiting, so a genuinely saturated node still fails fast instead of holding
|
/// waiting, so a genuinely saturated node still fails fast instead of holding
|
||||||
/// an unbounded set of connections open for the whole wait timeout.
|
/// 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.
|
/// `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 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;
|
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING: usize = 0;
|
||||||
@@ -591,6 +609,36 @@ pub const ENV_OBJECT_LOCK_RPC_TIMEOUT_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_TIMEOUT
|
|||||||
/// Default remote lock RPC transport timeout: 3000 milliseconds.
|
/// Default remote lock RPC transport timeout: 3000 milliseconds.
|
||||||
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
|
pub const DEFAULT_OBJECT_LOCK_RPC_TIMEOUT_MS: u64 = 3000;
|
||||||
|
|
||||||
|
/// Environment variable for the minimum interval between evictions of the
|
||||||
|
/// cached lock RPC channel to one peer, in milliseconds.
|
||||||
|
///
|
||||||
|
/// A lock RPC that fails on transport, or that times out while the peer has
|
||||||
|
/// not completed any lock RPC for two deadlines, evicts the shared HTTP/2
|
||||||
|
/// channel so the next request re-dials. Evictions are rate limited per peer
|
||||||
|
/// so one slow lock endpoint cannot drive a reset/GOAWAY/reconnect loop
|
||||||
|
/// (issue #7363). `0` disables the cooldown.
|
||||||
|
///
|
||||||
|
/// Default: 5000 milliseconds.
|
||||||
|
pub const ENV_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: &str = "RUSTFS_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS";
|
||||||
|
|
||||||
|
/// Default minimum interval between lock RPC channel evictions per peer: 5000 milliseconds.
|
||||||
|
pub const DEFAULT_OBJECT_LOCK_RPC_EVICTION_COOLDOWN_MS: u64 = 5000;
|
||||||
|
|
||||||
|
/// Environment variable for how many timed-out lock RPCs per peer may keep
|
||||||
|
/// running in the background instead of being cancelled.
|
||||||
|
///
|
||||||
|
/// Cancelling a timed-out stream sends `RST_STREAM`; enough of them make the
|
||||||
|
/// peer answer `GOAWAY too_many_resets` and drop every stream on the
|
||||||
|
/// connection. A detached RPC ends on its own within the internode RPC
|
||||||
|
/// timeout, and a lock it acquires after its caller gave up is released
|
||||||
|
/// immediately. Beyond this budget timed-out RPCs are cancelled as before.
|
||||||
|
///
|
||||||
|
/// Default: 256.
|
||||||
|
pub const ENV_OBJECT_LOCK_RPC_DETACHED_LIMIT: &str = "RUSTFS_OBJECT_LOCK_RPC_DETACHED_LIMIT";
|
||||||
|
|
||||||
|
/// Default per-peer budget of detached (timed-out but still running) lock RPCs: 256.
|
||||||
|
pub const DEFAULT_OBJECT_LOCK_RPC_DETACHED_LIMIT: usize = 256;
|
||||||
|
|
||||||
/// Environment variable to enable object namespace lock diagnostics.
|
/// Environment variable to enable object namespace lock diagnostics.
|
||||||
///
|
///
|
||||||
/// When enabled, RustFS emits slow lock acquisition and long lock hold
|
/// When enabled, RustFS emits slow lock acquisition and long lock hold
|
||||||
|
|||||||
@@ -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 ENV_HTTP1_MAX_BUF_SIZE: &str = "RUSTFS_HTTP1_MAX_BUF_SIZE";
|
||||||
pub const DEFAULT_HTTP1_MAX_BUF_SIZE: usize = 64 * 1024; // 64 KB
|
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
|
/// Environment variable for the S3 request-body inter-chunk read timeout
|
||||||
/// (seconds). Default: 300. Set to 0 to disable.
|
/// (seconds). Default: 300. Set to 0 to disable.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -593,6 +593,20 @@ pub struct DataUsageSnapshotIdentity {
|
|||||||
pub scanner_epoch: Option<u64>,
|
pub scanner_epoch: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct DataUsageSegmentInvalidationProof {
|
||||||
|
#[serde(default)]
|
||||||
|
pub process_epoch: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub generation_start: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub generation_end: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub producer_identity_coverage_complete: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cold_zero_walk_oracle: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct DataUsageSnapshotSetState {
|
pub struct DataUsageSnapshotSetState {
|
||||||
pub pool_index: u64,
|
pub pool_index: u64,
|
||||||
@@ -607,6 +621,8 @@ pub struct DataUsageSnapshotSetState {
|
|||||||
pub complete: bool,
|
pub complete: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tombstone: bool,
|
pub tombstone: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub segment_invalidation_proof: Option<DataUsageSegmentInvalidationProof>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DataUsageInfo {
|
impl DataUsageInfo {
|
||||||
@@ -3073,6 +3089,7 @@ mod tests {
|
|||||||
scan_plan_digest: Some([1; 32]),
|
scan_plan_digest: Some([1; 32]),
|
||||||
complete: false,
|
complete: false,
|
||||||
tombstone: false,
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: None,
|
||||||
}];
|
}];
|
||||||
assert!(observed_data_usage_is_newer(&partial, &authoritative));
|
assert!(observed_data_usage_is_newer(&partial, &authoritative));
|
||||||
}
|
}
|
||||||
@@ -3095,6 +3112,7 @@ mod tests {
|
|||||||
scan_plan_digest: Some([1; 32]),
|
scan_plan_digest: Some([1; 32]),
|
||||||
complete: true,
|
complete: true,
|
||||||
tombstone: false,
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: None,
|
||||||
},
|
},
|
||||||
DataUsageSnapshotSetState {
|
DataUsageSnapshotSetState {
|
||||||
pool_index: 1,
|
pool_index: 1,
|
||||||
@@ -3104,6 +3122,7 @@ mod tests {
|
|||||||
scan_plan_digest: Some([2; 32]),
|
scan_plan_digest: Some([2; 32]),
|
||||||
complete: false,
|
complete: false,
|
||||||
tombstone: false,
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -3113,6 +3132,42 @@ mod tests {
|
|||||||
assert!(partial.is_valid_partial_snapshot());
|
assert!(partial.is_valid_partial_snapshot());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_state_segment_invalidation_proof_is_additive() {
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LegacySetState {
|
||||||
|
pool_index: u64,
|
||||||
|
set_index: u64,
|
||||||
|
complete: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
let proof = DataUsageSegmentInvalidationProof {
|
||||||
|
process_epoch: "scanner-process".to_string(),
|
||||||
|
generation_start: 3,
|
||||||
|
generation_end: 5,
|
||||||
|
producer_identity_coverage_complete: true,
|
||||||
|
cold_zero_walk_oracle: true,
|
||||||
|
};
|
||||||
|
let state = DataUsageSnapshotSetState {
|
||||||
|
pool_index: 1,
|
||||||
|
set_index: 2,
|
||||||
|
scanner_cycle: Some(9),
|
||||||
|
scanner_epoch: Some(4),
|
||||||
|
scan_plan_digest: Some([7; 32]),
|
||||||
|
complete: true,
|
||||||
|
tombstone: false,
|
||||||
|
segment_invalidation_proof: Some(proof.clone()),
|
||||||
|
};
|
||||||
|
let encoded = rmp_serde::to_vec_named(&state).expect("set state should encode with additive proof");
|
||||||
|
let legacy: LegacySetState = rmp_serde::from_slice(&encoded).expect("legacy readers should ignore proof metadata");
|
||||||
|
assert_eq!(legacy.pool_index, 1);
|
||||||
|
assert_eq!(legacy.set_index, 2);
|
||||||
|
assert!(legacy.complete);
|
||||||
|
|
||||||
|
let decoded: DataUsageSnapshotSetState = rmp_serde::from_slice(&encoded).expect("new readers should restore proof");
|
||||||
|
assert_eq!(decoded.segment_invalidation_proof, Some(proof));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completeness_marker_requires_a_snapshot_timestamp() {
|
fn completeness_marker_requires_a_snapshot_timestamp() {
|
||||||
let untimestamped = DataUsageInfo {
|
let untimestamped = DataUsageInfo {
|
||||||
|
|||||||
@@ -144,3 +144,6 @@ russh = { workspace = true, features = ["serde"] }
|
|||||||
russh-sftp = { workspace = true }
|
russh-sftp = { workspace = true }
|
||||||
zip.workspace = true
|
zip.workspace = true
|
||||||
clap = { workspace = true, features = ["derive", "env"] }
|
clap = { workspace = true, features = ["derive", "env"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ Registered in [`src/lib.rs`](src/lib.rs). Grouped by concern:
|
|||||||
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
| **chaos / reliability** | [`src/chaos.rs`](src/chaos.rs), `reliability_disk_fault_test`, `heal_erasure_disk_rebuild_test`, `server_startup_failfast_test` | Disk offline/replace/corrupt, EC rebuild, heal, fail-fast startup |
|
||||||
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
| **upgrade compatibility** | `upgrade_compatibility_test` | Pinned previous-release writes followed by current-build reads on the same data directory |
|
||||||
|
|
||||||
|
The external-tool `storage_metric_ownership_test` validates the OTLP/Collector/Prometheus path, including a rolling upgrade and node failures. See the [storage metrics guide](../../docs/operations/storage-metrics.md) for its required binaries and focused command.
|
||||||
|
|
||||||
## How to run
|
## How to run
|
||||||
|
|
||||||
All commands assume repo root. `cargo test` triggers an on-demand build of the
|
All commands assume repo root. `cargo test` triggers an on-demand build of the
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let revision = git(&root, &["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_owned());
|
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 dirty = git(&root, &["status", "--porcelain", "--untracked-files=no"]).is_none_or(|status| !status.is_empty());
|
||||||
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
|
let lock = git(&root, &["hash-object", "Cargo.lock"]).unwrap_or_else(|| "unknown".to_owned());
|
||||||
let mut features = std::env::vars()
|
let mut features = std::env::vars()
|
||||||
.filter_map(|(key, _)| {
|
.filter_map(|(key, _)| {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::common::{RustFSTestClusterEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestClusterEnvironment, init_logging, local_http_client, signal_process};
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use http::header::HOST;
|
use http::header::HOST;
|
||||||
use reqwest::StatusCode;
|
use reqwest::StatusCode;
|
||||||
@@ -22,7 +22,6 @@ use rustfs_signer::sign_v4;
|
|||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::process::Command;
|
|
||||||
use tokio::time::{Duration, sleep, timeout};
|
use tokio::time::{Duration, sleep, timeout};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -82,15 +81,6 @@ async fn parse_json_response<T: serde::de::DeserializeOwned>(
|
|||||||
Ok(serde_json::from_slice(&body)?)
|
Ok(serde_json::from_slice(&body)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn signal_process(pid: u32, signal: &str) -> Result<(), Box<dyn Error + Send + Sync>> {
|
|
||||||
let output = Command::new("kill").arg(format!("-{signal}")).arg(pid.to_string()).output()?;
|
|
||||||
if output.status.success() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
Err(format!("kill -{signal} {pid} failed: {}", String::from_utf8_lossy(&output.stderr)).into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn offline_server_count(info: &InfoMessage) -> usize {
|
fn offline_server_count(info: &InfoMessage) -> usize {
|
||||||
info.servers
|
info.servers
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
|
|||||||
let rule = CorsRule::builder()
|
let rule = CorsRule::builder()
|
||||||
.allowed_methods("GET")
|
.allowed_methods("GET")
|
||||||
.allowed_origins("https://example.com")
|
.allowed_origins("https://example.com")
|
||||||
|
.allowed_headers("*")
|
||||||
.build()?;
|
.build()?;
|
||||||
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
|
let configuration = CorsConfiguration::builder().cors_rules(rule).build()?;
|
||||||
|
|
||||||
@@ -288,6 +289,60 @@ async fn test_bucket_cors_write_is_visible_on_peer_before_response() -> Result<(
|
|||||||
assert_eq!(rules[0].allowed_methods(), ["GET"]);
|
assert_eq!(rules[0].allowed_methods(), ["GET"]);
|
||||||
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
|
assert_eq!(rules[0].allowed_origins(), ["https://example.com"]);
|
||||||
|
|
||||||
|
let http = reqwest::Client::builder().no_proxy().build()?;
|
||||||
|
let url = format!("http://{}/{}", cluster.nodes[1].address, BUCKET_METADATA_RELOAD_BUCKET);
|
||||||
|
let without_headers = http
|
||||||
|
.request(reqwest::Method::OPTIONS, &url)
|
||||||
|
.header("Origin", "https://example.com")
|
||||||
|
.header("Access-Control-Request-Method", "GET")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(without_headers.status().is_success());
|
||||||
|
assert!(!without_headers.headers().contains_key("access-control-allow-headers"));
|
||||||
|
assert!(
|
||||||
|
without_headers
|
||||||
|
.headers()
|
||||||
|
.get("vary")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
|
||||||
|
"a cached header-free preflight must not suppress a later requested header grant"
|
||||||
|
);
|
||||||
|
let preflight = http
|
||||||
|
.request(reqwest::Method::OPTIONS, &url)
|
||||||
|
.header("Origin", "https://example.com")
|
||||||
|
.header("Access-Control-Request-Method", "GET")
|
||||||
|
.header("Access-Control-Request-Headers", "X-Another-Header, x-could-be-anything")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(preflight.status().is_success(), "peer preflight should succeed: {preflight:?}");
|
||||||
|
assert_eq!(
|
||||||
|
preflight
|
||||||
|
.headers()
|
||||||
|
.get("access-control-allow-headers")
|
||||||
|
.and_then(|value| value.to_str().ok()),
|
||||||
|
Some("x-another-header,x-could-be-anything"),
|
||||||
|
"a wildcard rule must return only the headers requested by this preflight"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
preflight
|
||||||
|
.headers()
|
||||||
|
.get("vary")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.is_some_and(|value| value.contains("Access-Control-Request-Headers")),
|
||||||
|
"preflight caches must distinguish the requested header list"
|
||||||
|
);
|
||||||
|
let denied = http
|
||||||
|
.request(reqwest::Method::OPTIONS, &url)
|
||||||
|
.header("Origin", "https://disallowed.example.com")
|
||||||
|
.header("Access-Control-Request-Method", "GET")
|
||||||
|
.header("Access-Control-Request-Headers", "x-another-header")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
!denied.headers().contains_key("access-control-allow-headers"),
|
||||||
|
"a rejected origin must not receive the requested header grant"
|
||||||
|
);
|
||||||
|
|
||||||
writer
|
writer
|
||||||
.delete_bucket_cors()
|
.delete_bucket_cors()
|
||||||
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
|
.bucket(BUCKET_METADATA_RELOAD_BUCKET)
|
||||||
|
|||||||
+135
-16
@@ -57,6 +57,8 @@ const RUSTFS_FULL_FEATURE: &str = "full";
|
|||||||
const TEST_PORT_MIN: u16 = 20_000;
|
const TEST_PORT_MIN: u16 = 20_000;
|
||||||
// Keep allocator ports below the ephemeral range used by bind(..., 0) test helpers.
|
// Keep allocator ports below the ephemeral range used by bind(..., 0) test helpers.
|
||||||
const TEST_PORT_RANGE: u16 = 10_000;
|
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_COUNTER_PATH: &str = "/tmp/rustfs_e2e_next_port";
|
||||||
const TEST_PORT_LOCK_DIR: &str = "/tmp/rustfs_e2e_port_allocator.lock";
|
const TEST_PORT_LOCK_DIR: &str = "/tmp/rustfs_e2e_port_allocator.lock";
|
||||||
const TEST_PORT_LOCK_STALE_AFTER: Duration = Duration::from_secs(30);
|
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 {
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
let offset = (port - TEST_PORT_MIN + 1) % TEST_PORT_RANGE;
|
struct TestPortAllocatorConfig {
|
||||||
TEST_PORT_MIN + offset
|
min: u16,
|
||||||
|
range: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn seeded_test_port() -> u16 {
|
impl TestPortAllocatorConfig {
|
||||||
let offset = (Uuid::new_v4().as_u128() % u128::from(TEST_PORT_RANGE)) as u16;
|
fn max_exclusive(self) -> u32 {
|
||||||
TEST_PORT_MIN + offset
|
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)
|
stdfs::read_to_string(TEST_PORT_COUNTER_PATH)
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|value| value.trim().parse::<u16>().ok())
|
.and_then(|value| value.trim().parse::<u16>().ok())
|
||||||
.filter(|port| (TEST_PORT_MIN..TEST_PORT_MIN + TEST_PORT_RANGE).contains(port))
|
.filter(|port| config.contains(port))
|
||||||
.unwrap_or_else(seeded_test_port)
|
.unwrap_or_else(|| seeded_test_port(config))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_stale_port_allocator_lock() {
|
fn remove_stale_port_allocator_lock() {
|
||||||
@@ -134,6 +188,15 @@ fn write_next_test_port(port: u16) -> Result<(), Box<dyn std::error::Error + Sen
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn no_available_test_port_error(config: TestPortAllocatorConfig, attempts: u16, last_error: Option<&std::io::Error>) -> String {
|
||||||
|
let max_inclusive = config.max_exclusive() - 1;
|
||||||
|
let detail = last_error.map(|err| format!("; last bind error: {err}")).unwrap_or_default();
|
||||||
|
format!(
|
||||||
|
"no available E2E test port found in {}..={} after {} attempts{}",
|
||||||
|
config.min, max_inclusive, attempts, detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn capture_command_logs(
|
pub(crate) fn capture_command_logs(
|
||||||
command: &mut Command,
|
command: &mut Command,
|
||||||
log_path: Option<&str>,
|
log_path: Option<&str>,
|
||||||
@@ -210,6 +273,15 @@ pub fn local_http_client() -> HttpClient {
|
|||||||
.expect("failed to build local reqwest client")
|
.expect("failed to build local reqwest client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn signal_process(pid: u32, signal: &str) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let output = Command::new("kill").arg(format!("-{signal}")).arg(pid.to_string()).output()?;
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("kill -{signal} {pid} failed: {}", String::from_utf8_lossy(&output.stderr)).into())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn signed_s3_request(
|
pub(crate) async fn signed_s3_request(
|
||||||
method: http::Method,
|
method: http::Method,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -629,20 +701,25 @@ impl RustFSTestEnvironment {
|
|||||||
pub async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
pub async fn find_available_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
use std::net::TcpListener;
|
use std::net::TcpListener;
|
||||||
let _guard = PortAllocatorGuard::acquire().await?;
|
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);
|
||||||
|
let mut last_error = None;
|
||||||
|
|
||||||
for _ in 0..TEST_PORT_RANGE {
|
for _ in 0..config.range {
|
||||||
let port = next_port;
|
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)?;
|
write_next_test_port(next_port)?;
|
||||||
|
|
||||||
if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
|
match TcpListener::bind(("127.0.0.1", port)) {
|
||||||
drop(listener);
|
Ok(listener) => {
|
||||||
return Ok(port);
|
drop(listener);
|
||||||
|
return Ok(port);
|
||||||
|
}
|
||||||
|
Err(err) => last_error = Some(err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err("no available E2E test port found".into())
|
Err(no_available_test_port_error(config, config.range, last_error.as_ref()).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kill any existing RustFS processes
|
/// Kill any existing RustFS processes
|
||||||
@@ -2108,6 +2185,48 @@ 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 e2e_port_allocator_reports_attempt_window_and_last_bind_error() {
|
||||||
|
let config = TestPortAllocatorConfig { min: 41000, range: 3 };
|
||||||
|
let error = std::io::Error::from(ErrorKind::PermissionDenied);
|
||||||
|
|
||||||
|
let message = no_available_test_port_error(config, config.range, Some(&error));
|
||||||
|
|
||||||
|
assert!(message.contains("41000..=41002"));
|
||||||
|
assert!(message.contains("after 3 attempts"));
|
||||||
|
assert!(message.contains("last bind error"));
|
||||||
|
assert!(message.contains("permission denied"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
|
fn resolves_rustfs_binary_in_configured_cargo_target_directory() {
|
||||||
let workspace = Path::new("workspace");
|
let workspace = Path::new("workspace");
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ const POOL_META_V3_ENV: [(&str, &str); 2] = [
|
|||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
pub(crate) enum DistLayout {
|
pub(crate) enum DistLayout {
|
||||||
|
/// 3 nodes × 4 drives, one erasure pool. With `EC:4` this is the
|
||||||
|
/// release-evidence EC8+4 geometry.
|
||||||
|
ThreeByFourEc84,
|
||||||
/// 4 nodes × 4 drives, one erasure pool spanning every endpoint.
|
/// 4 nodes × 4 drives, one erasure pool spanning every endpoint.
|
||||||
FourByFour,
|
FourByFour,
|
||||||
/// 4 nodes × 1 drive, one erasure pool (minimum 4-node 4-disk layout).
|
/// 4 nodes × 1 drive, one erasure pool (minimum 4-node 4-disk layout).
|
||||||
@@ -94,6 +97,7 @@ impl DistCluster {
|
|||||||
|
|
||||||
pub async fn new_stopped_with_env(layout: DistLayout, extra_env: &[(&str, &str)]) -> TestResult<Self> {
|
pub async fn new_stopped_with_env(layout: DistLayout, extra_env: &[(&str, &str)]) -> TestResult<Self> {
|
||||||
let topology = match layout {
|
let topology = match layout {
|
||||||
|
DistLayout::ThreeByFourEc84 => ClusterTopology::single_pool_multidrive(3, DRIVES_PER_NODE),
|
||||||
DistLayout::FourByFour => ClusterTopology::single_pool_multidrive(NODE_COUNT, DRIVES_PER_NODE),
|
DistLayout::FourByFour => ClusterTopology::single_pool_multidrive(NODE_COUNT, DRIVES_PER_NODE),
|
||||||
DistLayout::FourNodeFourDisk => ClusterTopology::single_pool(NODE_COUNT),
|
DistLayout::FourNodeFourDisk => ClusterTopology::single_pool(NODE_COUNT),
|
||||||
DistLayout::SingleNodeFourDrive => ClusterTopology::per_node_pools(DRIVES_PER_NODE, vec![vec![0]]),
|
DistLayout::SingleNodeFourDrive => ClusterTopology::per_node_pools(DRIVES_PER_NODE, vec![vec![0]]),
|
||||||
@@ -101,7 +105,7 @@ impl DistCluster {
|
|||||||
let mut cluster = RustFSTestClusterEnvironment::with_topology(topology).await?;
|
let mut cluster = RustFSTestClusterEnvironment::with_topology(topology).await?;
|
||||||
let pool_storage_roots = match layout {
|
let pool_storage_roots = match layout {
|
||||||
DistLayout::SingleNodeFourDrive => Some(configured_pool_storage_roots()?),
|
DistLayout::SingleNodeFourDrive => Some(configured_pool_storage_roots()?),
|
||||||
DistLayout::FourByFour | DistLayout::FourNodeFourDisk => None,
|
DistLayout::ThreeByFourEc84 | DistLayout::FourByFour | DistLayout::FourNodeFourDisk => None,
|
||||||
};
|
};
|
||||||
let mut owned_pool_dirs = Vec::new();
|
let mut owned_pool_dirs = Vec::new();
|
||||||
if let Some(roots) = pool_storage_roots.as_deref() {
|
if let Some(roots) = pool_storage_roots.as_deref() {
|
||||||
@@ -958,27 +962,6 @@ pub(crate) async fn wait_for_rebalance_active(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn wait_for_rebalance_running_with_progress(
|
|
||||||
cluster: &RustFSTestClusterEnvironment,
|
|
||||||
expected_id: &str,
|
|
||||||
timeout: Duration,
|
|
||||||
) -> TestResult {
|
|
||||||
let deadline = Instant::now() + timeout;
|
|
||||||
loop {
|
|
||||||
let status = rebalance_status_json(cluster).await?;
|
|
||||||
if rebalance_running_with_progress(&status, expected_id)? {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
return Err(format!(
|
|
||||||
"rebalance did not become active with non-zero progress within {timeout:?}; last status: {status}"
|
|
||||||
)
|
|
||||||
.into());
|
|
||||||
}
|
|
||||||
sleep(Duration::from_millis(100)).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn wait_for_rebalance_complete(
|
pub(crate) async fn wait_for_rebalance_complete(
|
||||||
cluster: &RustFSTestClusterEnvironment,
|
cluster: &RustFSTestClusterEnvironment,
|
||||||
expected_id: &str,
|
expected_id: &str,
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
// Copyright 2026 RustFS Team
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
use super::harness::{
|
||||||
|
DistCluster, DistLayout, TestResult, assert_inventory, get_object_bytes, payload_for, put_object, sha256_hex, unique_bucket,
|
||||||
|
wait_until,
|
||||||
|
};
|
||||||
|
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, signed_admin_post};
|
||||||
|
use crate::common::init_logging;
|
||||||
|
use aws_sdk_s3::Client;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::{Instant, sleep};
|
||||||
|
|
||||||
|
const EC84_NODE_COUNT: usize = 3;
|
||||||
|
const EC84_DRIVES_PER_NODE: usize = 4;
|
||||||
|
const EC84_DATA_BLOCKS: usize = 8;
|
||||||
|
const EC84_PARITY_BLOCKS: usize = 4;
|
||||||
|
const EC84_TARGET_DRIVE_RESTART_CASE: &str = "ec84-target-drive-restart";
|
||||||
|
const EC84_TARGET_DRIVE_RESTART_ORACLE: &str = "ec84-target-drive-restart.json";
|
||||||
|
const EC84_HEAL_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(45);
|
||||||
|
const EC84_HEAL_CONTROL_RETRY_DELAY: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ExpectedShard {
|
||||||
|
key: String,
|
||||||
|
body: Vec<u8>,
|
||||||
|
baseline: VersionShardCensus,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScannerHealEvidenceContext {
|
||||||
|
directory: PathBuf,
|
||||||
|
run: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ScannerHealEvidencePayload<'a> {
|
||||||
|
dist: &'a DistCluster,
|
||||||
|
bucket: &'a str,
|
||||||
|
expected: &'a [ExpectedShard],
|
||||||
|
outage_key: &'a str,
|
||||||
|
outage_body: &'a [u8],
|
||||||
|
replaced_drive: &'a Path,
|
||||||
|
pid_before: u32,
|
||||||
|
pid_after: u32,
|
||||||
|
node_listings: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_sha256(path: &Path) -> TestResult<String> {
|
||||||
|
let mut file = std::fs::File::open(path)?;
|
||||||
|
let mut digest = Sha256::new();
|
||||||
|
let mut buffer = [0_u8; 64 * 1024];
|
||||||
|
loop {
|
||||||
|
let read = file.read(&mut buffer)?;
|
||||||
|
if read == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
digest.update(&buffer[..read]);
|
||||||
|
}
|
||||||
|
Ok(digest.finalize().iter().map(|byte| format!("{byte:02x}")).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compiled_test_identity() -> Value {
|
||||||
|
serde_json::json!({
|
||||||
|
"source_revision": env!("RUSTFS_E2E_BUILD_COMMIT"),
|
||||||
|
"dirty": env!("RUSTFS_E2E_BUILD_DIRTY") != "false",
|
||||||
|
"lock_blob": env!("RUSTFS_E2E_BUILD_LOCK"),
|
||||||
|
"features": env!("RUSTFS_E2E_BUILD_FEATURES"),
|
||||||
|
"target": env!("RUSTFS_E2E_BUILD_TARGET"),
|
||||||
|
"profile": env!("RUSTFS_E2E_BUILD_PROFILE"),
|
||||||
|
"rustflags_hex": env!("RUSTFS_E2E_BUILD_RUSTFLAGS_HEX"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_field<'a>(value: &'a Value, path: &str) -> TestResult<&'a str> {
|
||||||
|
let mut current = value;
|
||||||
|
for segment in path.split('.') {
|
||||||
|
current = current
|
||||||
|
.get(segment)
|
||||||
|
.ok_or_else(|| format!("scanner/heal run receipt missing {path}"))?;
|
||||||
|
}
|
||||||
|
current
|
||||||
|
.as_str()
|
||||||
|
.filter(|text| !text.is_empty())
|
||||||
|
.ok_or_else(|| format!("scanner/heal run receipt has invalid {path}").into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scanner_heal_evidence_context() -> TestResult<Option<ScannerHealEvidenceContext>> {
|
||||||
|
let Some(directory) = std::env::var_os("RUSTFS_SCANNER_HEAL_RUN_DIR") else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let directory = PathBuf::from(directory);
|
||||||
|
let receipt = directory.join("run.json");
|
||||||
|
if receipt.metadata()?.len() > 1024 * 1024 {
|
||||||
|
return Err("oversized scanner/heal execution receipt".into());
|
||||||
|
}
|
||||||
|
let run: Value = serde_json::from_slice(&std::fs::read(receipt)?)?;
|
||||||
|
let built = compiled_test_identity();
|
||||||
|
for key in ["source_revision", "dirty", "lock_blob", "features"] {
|
||||||
|
if built[key] != run["test_build"][key] {
|
||||||
|
return Err(format!("compiled test identity differs for {key}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let binary_path = PathBuf::from(string_field(&run, "binary.path")?);
|
||||||
|
if file_sha256(&binary_path)? != string_field(&run, "binary.sha256")? {
|
||||||
|
return Err("server binary must match the run receipt".into());
|
||||||
|
}
|
||||||
|
if file_sha256(&std::env::current_exe()?)? != string_field(&run, "test_binary.sha256")? {
|
||||||
|
return Err("test executable must match the run receipt".into());
|
||||||
|
}
|
||||||
|
if directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE).exists() {
|
||||||
|
return Err("scanner/heal oracle already exists; create a new execution receipt".into());
|
||||||
|
}
|
||||||
|
Ok(Some(ScannerHealEvidenceContext { directory, run }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_ec84_geometry(census: &VersionShardCensus, key: &str) -> TestResult {
|
||||||
|
if census.data_blocks != Some(EC84_DATA_BLOCKS) || census.parity_blocks != Some(EC84_PARITY_BLOCKS) {
|
||||||
|
return Err(format!("object {key} did not use EC8+4 geometry: {census:?}").into());
|
||||||
|
}
|
||||||
|
let erasure_index = census
|
||||||
|
.erasure_index
|
||||||
|
.ok_or_else(|| format!("object {key} did not record an erasure index: {census:?}"))?;
|
||||||
|
if !(1..=EC84_DATA_BLOCKS + EC84_PARITY_BLOCKS).contains(&erasure_index) {
|
||||||
|
return Err(format!("object {key} has out-of-range erasure index {erasure_index}: {census:?}").into());
|
||||||
|
}
|
||||||
|
if !census.is_complete() || census.expected_part_numbers.is_empty() {
|
||||||
|
return Err(format!("object {key} does not have complete physical shard evidence: {census:?}").into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_scanner_heal_evidence(context: ScannerHealEvidenceContext, payload: ScannerHealEvidencePayload<'_>) -> TestResult {
|
||||||
|
let verifier = payload.dist.client(0)?;
|
||||||
|
let mut objects = Vec::new();
|
||||||
|
for item in payload.expected {
|
||||||
|
let actual = get_object_bytes(&verifier, payload.bucket, &item.key).await?;
|
||||||
|
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, &item.key, None)?;
|
||||||
|
objects.push(serde_json::json!({
|
||||||
|
"key": item.key,
|
||||||
|
"version_id": null,
|
||||||
|
"expected_bytes": item.body.len(),
|
||||||
|
"actual_bytes": actual.len(),
|
||||||
|
"expected_sha256": sha256_hex(&item.body),
|
||||||
|
"actual_sha256": sha256_hex(&actual),
|
||||||
|
"expected_physical": item.baseline,
|
||||||
|
"physical": physical,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let actual = get_object_bytes(&verifier, payload.bucket, payload.outage_key).await?;
|
||||||
|
let physical = census_object_version_on_disk(payload.replaced_drive, payload.bucket, payload.outage_key, None)?;
|
||||||
|
objects.push(serde_json::json!({
|
||||||
|
"key": payload.outage_key,
|
||||||
|
"version_id": null,
|
||||||
|
"expected_bytes": payload.outage_body.len(),
|
||||||
|
"actual_bytes": actual.len(),
|
||||||
|
"expected_sha256": sha256_hex(payload.outage_body),
|
||||||
|
"actual_sha256": sha256_hex(&actual),
|
||||||
|
"expected_physical": null,
|
||||||
|
"physical": physical,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let evidence = serde_json::json!({
|
||||||
|
"schema": 1,
|
||||||
|
"case": EC84_TARGET_DRIVE_RESTART_CASE,
|
||||||
|
"evidence": "process-restart",
|
||||||
|
"run_id": string_field(&context.run, "run_id")?,
|
||||||
|
"source_revision": string_field(&context.run, "source_revision")?,
|
||||||
|
"test_build": compiled_test_identity(),
|
||||||
|
"binary_sha256": string_field(&context.run, "binary.sha256")?,
|
||||||
|
"test_binary_sha256": string_field(&context.run, "test_binary.sha256")?,
|
||||||
|
"topology": {"nodes": EC84_NODE_COUNT, "drives_per_node": EC84_DRIVES_PER_NODE},
|
||||||
|
"pid_before": payload.pid_before,
|
||||||
|
"pid_after": payload.pid_after,
|
||||||
|
"unclean_shutdown_marker": false,
|
||||||
|
"objects": objects,
|
||||||
|
"node_listings": payload.node_listings,
|
||||||
|
});
|
||||||
|
let data = serde_json::to_vec(&evidence)?;
|
||||||
|
if data.len() > 1024 * 1024 {
|
||||||
|
return Err("scanner/heal oracle exceeds the 1 MiB artifact budget".into());
|
||||||
|
}
|
||||||
|
let mut output = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(context.directory.join(EC84_TARGET_DRIVE_RESTART_ORACLE))?;
|
||||||
|
output.write_all(&data)?;
|
||||||
|
output.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_replaced_drive_empty(drive: &Path, bucket: &str, keys: &[String]) -> TestResult {
|
||||||
|
for key in keys {
|
||||||
|
let census = census_object_version_on_disk(drive, bucket, key, None)?;
|
||||||
|
if census.has_xl_meta {
|
||||||
|
return Err(format!("replacement drive unexpectedly retained {bucket}/{key}: {census:?}").into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_cluster_heal_coordination_unavailable(error: &(dyn std::error::Error + Send + Sync)) -> bool {
|
||||||
|
let message = error.to_string();
|
||||||
|
message.contains("500 Internal Server Error") && message.contains("cluster heal coordination unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_ec84_root_heal_when_control_ready(
|
||||||
|
heal_url: &str,
|
||||||
|
heal_body: &str,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
) -> TestResult {
|
||||||
|
let deadline = Instant::now() + EC84_HEAL_CONTROL_READY_TIMEOUT;
|
||||||
|
loop {
|
||||||
|
match signed_admin_post(heal_url, Some(heal_body), access_key, secret_key).await {
|
||||||
|
Ok(_) => return Ok(()),
|
||||||
|
Err(error) if is_cluster_heal_coordination_unavailable(error.as_ref()) && Instant::now() < deadline => {
|
||||||
|
sleep(EC84_HEAL_CONTROL_RETRY_DELAY).await;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn put_large_inventory(client: &Client, bucket: &str) -> TestResult<Vec<ExpectedShard>> {
|
||||||
|
let mut expected = Vec::new();
|
||||||
|
for index in 0..4 {
|
||||||
|
let key = format!("ec84/prefix-{}/object-{index:04}.bin", index % 2);
|
||||||
|
let body = payload_for(&key, 10 * 1024 * 1024);
|
||||||
|
put_object(client, bucket, &key, body.clone()).await?;
|
||||||
|
expected.push(ExpectedShard {
|
||||||
|
key,
|
||||||
|
body,
|
||||||
|
baseline: VersionShardCensus {
|
||||||
|
version_id: None,
|
||||||
|
has_xl_meta: false,
|
||||||
|
data_dir: None,
|
||||||
|
erasure_index: None,
|
||||||
|
data_blocks: None,
|
||||||
|
parity_blocks: None,
|
||||||
|
expected_part_numbers: Default::default(),
|
||||||
|
present_part_fingerprints: Default::default(),
|
||||||
|
inline_data_fingerprint: None,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn three_node_four_drive_ec8_4_root_heal_rebuilds_replaced_drive_after_restart() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let evidence_context = scanner_heal_evidence_context()?;
|
||||||
|
let mut dist = DistCluster::start_with_env(
|
||||||
|
DistLayout::ThreeByFourEc84,
|
||||||
|
&[
|
||||||
|
("RUSTFS_STORAGE_CLASS_STANDARD", "EC:4"),
|
||||||
|
("RUSTFS_HEAL_ENABLED", "true"),
|
||||||
|
("RUSTFS_HEAL_AUTO_HEAL_ENABLE", "false"),
|
||||||
|
("RUSTFS_HEAL_MRF_ENABLE", "false"),
|
||||||
|
("RUSTFS_SCANNER_ENABLED", "false"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(dist.cluster.nodes.len(), EC84_NODE_COUNT);
|
||||||
|
assert_eq!(dist.cluster.topology.drives_per_node, EC84_DRIVES_PER_NODE);
|
||||||
|
|
||||||
|
let bucket = unique_bucket("healec84");
|
||||||
|
dist.create_bucket(&bucket).await?;
|
||||||
|
let writer = dist.client(0)?;
|
||||||
|
let mut expected = put_large_inventory(&writer, &bucket).await?;
|
||||||
|
let replaced_node = 1;
|
||||||
|
let replaced_drive_index = 2;
|
||||||
|
let replaced_drive = PathBuf::from(&dist.cluster.nodes[replaced_node].data_dirs[replaced_drive_index]);
|
||||||
|
|
||||||
|
for item in &mut expected {
|
||||||
|
item.baseline = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
|
||||||
|
assert_ec84_geometry(&item.baseline, &item.key)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let format_path = replaced_drive.join(".rustfs.sys").join("format.json");
|
||||||
|
let format_json = std::fs::read(&format_path)?;
|
||||||
|
let target_pid_before = dist.cluster.nodes[replaced_node]
|
||||||
|
.process
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("target process is absent before graceful restart")?
|
||||||
|
.id();
|
||||||
|
dist.cluster.stop_node_gracefully(replaced_node).await?;
|
||||||
|
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 has no parent")?)?;
|
||||||
|
std::fs::write(&format_path, format_json)?;
|
||||||
|
assert_replaced_drive_empty(
|
||||||
|
&replaced_drive,
|
||||||
|
&bucket,
|
||||||
|
&expected.iter().map(|item| item.key.clone()).collect::<Vec<_>>(),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let outage_key = "ec84/written-while-node-restarting.bin";
|
||||||
|
let outage_body = payload_for(outage_key, 10 * 1024 * 1024);
|
||||||
|
writer
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(outage_key)
|
||||||
|
.body(ByteStream::from(outage_body.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
dist.cluster.start_node(replaced_node).await?;
|
||||||
|
let target_pid_after = dist.cluster.nodes[replaced_node]
|
||||||
|
.process
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("target process is absent after restart")?
|
||||||
|
.id();
|
||||||
|
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[0].url);
|
||||||
|
start_ec84_root_heal_when_control_ready(&heal_url, heal_body, &dist.cluster.access_key, &dist.cluster.secret_key).await?;
|
||||||
|
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(120),
|
||||||
|
|| async {
|
||||||
|
for item in &expected {
|
||||||
|
let current = census_object_version_on_disk(&replaced_drive, &bucket, &item.key, None)?;
|
||||||
|
if !current.matches_manifest(&item.baseline) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let outage = census_object_version_on_disk(&replaced_drive, &bucket, outage_key, None)?;
|
||||||
|
Ok(outage.is_complete()
|
||||||
|
&& outage.data_blocks == Some(EC84_DATA_BLOCKS)
|
||||||
|
&& outage.parity_blocks == Some(EC84_PARITY_BLOCKS))
|
||||||
|
},
|
||||||
|
"EC8+4 replacement drive rebuilt baseline and outage shards",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let inventory = expected
|
||||||
|
.iter()
|
||||||
|
.map(|item| (item.key.clone(), item.body.clone()))
|
||||||
|
.chain(std::iter::once((outage_key.to_string(), outage_body.clone())))
|
||||||
|
.collect::<BTreeMap<_, _>>();
|
||||||
|
let expected_keys = inventory.keys().cloned().collect::<HashSet<_>>();
|
||||||
|
let mut node_listings = Vec::new();
|
||||||
|
for node_index in 0..dist.cluster.nodes.len() {
|
||||||
|
let client = dist.client(node_index)?;
|
||||||
|
assert_inventory(&client, &bucket, &inventory).await?;
|
||||||
|
let listing = client.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
let observed = listing
|
||||||
|
.contents()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|object| object.key().map(str::to_owned))
|
||||||
|
.collect::<HashSet<_>>();
|
||||||
|
assert_eq!(observed, expected_keys, "node {node_index} listing diverged after EC8+4 heal");
|
||||||
|
let mut observed = observed.into_iter().collect::<Vec<_>>();
|
||||||
|
observed.sort();
|
||||||
|
node_listings.push(observed);
|
||||||
|
}
|
||||||
|
if let Some(context) = evidence_context {
|
||||||
|
write_scanner_heal_evidence(
|
||||||
|
context,
|
||||||
|
ScannerHealEvidencePayload {
|
||||||
|
dist: &dist,
|
||||||
|
bucket: &bucket,
|
||||||
|
expected: &expected,
|
||||||
|
outage_key,
|
||||||
|
outage_body: &outage_body,
|
||||||
|
replaced_drive: &replaced_drive,
|
||||||
|
pid_before: target_pid_before,
|
||||||
|
pid_after: target_pid_after,
|
||||||
|
node_listings,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cluster_heal_coordination_retry_is_exact() {
|
||||||
|
let retryable: Box<dyn std::error::Error + Send + Sync> =
|
||||||
|
"admin POST failed: 500 Internal Server Error cluster heal coordination unavailable".into();
|
||||||
|
assert!(is_cluster_heal_coordination_unavailable(retryable.as_ref()));
|
||||||
|
|
||||||
|
let other_internal: Box<dyn std::error::Error + Send + Sync> =
|
||||||
|
"admin POST failed: 500 Internal Server Error unrelated".into();
|
||||||
|
assert!(!is_cluster_heal_coordination_unavailable(other_internal.as_ref()));
|
||||||
|
|
||||||
|
let wrong_status: Box<dyn std::error::Error + Send + Sync> =
|
||||||
|
"admin POST failed: 503 Service Unavailable cluster heal coordination unavailable".into();
|
||||||
|
assert!(!is_cluster_heal_coordination_unavailable(wrong_status.as_ref()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ mod data_integrity_movement_test;
|
|||||||
mod expand_decommission_rebalance_test;
|
mod expand_decommission_rebalance_test;
|
||||||
mod extra_test;
|
mod extra_test;
|
||||||
mod harness;
|
mod harness;
|
||||||
|
mod heal_test;
|
||||||
mod object_lock_test;
|
mod object_lock_test;
|
||||||
mod observability_test;
|
mod observability_test;
|
||||||
mod replication_quota_test;
|
mod replication_quota_test;
|
||||||
|
|||||||
@@ -12,8 +12,10 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use super::harness::{DistCluster, DistLayout, TestResult, cluster_admin_ok, unique_bucket, wait_for_ready};
|
use super::harness::{
|
||||||
use crate::common::{admin_request, init_logging, local_http_client};
|
DistCluster, DistLayout, TestResult, assert_object_bytes, cluster_admin_ok, unique_bucket, wait_for_ready, wait_until,
|
||||||
|
};
|
||||||
|
use crate::common::{admin_request, init_logging, local_http_client, signal_process, signed_request};
|
||||||
use aws_sdk_s3::operation::RequestId;
|
use aws_sdk_s3::operation::RequestId;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -24,7 +26,7 @@ use hyper::service::service_fn;
|
|||||||
use hyper::{Request, Response};
|
use hyper::{Request, Response};
|
||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
use local_ip_address::local_ip;
|
use local_ip_address::local_ip;
|
||||||
use rustfs_madmin::metrics::RealtimeMetrics;
|
use rustfs_madmin::metrics::{HttpMetrics, RealtimeMetrics};
|
||||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
@@ -126,6 +128,7 @@ async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent()
|
|||||||
let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?;
|
let (audit_endpoint, mut audit_entries, collector) = spawn_audit_collector().await?;
|
||||||
let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization();
|
let audit_origin = reqwest::Url::parse(&audit_endpoint)?.origin().ascii_serialization();
|
||||||
let audit_env = [
|
let audit_env = [
|
||||||
|
("RUST_LOG", "warn"),
|
||||||
("RUSTFS_AUDIT_ENABLE", "true"),
|
("RUSTFS_AUDIT_ENABLE", "true"),
|
||||||
("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"),
|
("RUSTFS_AUDIT_WEBHOOK_ENABLE_DISTRIBUTED", "on"),
|
||||||
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()),
|
("RUSTFS_AUDIT_WEBHOOK_ENDPOINT_DISTRIBUTED", audit_endpoint.as_str()),
|
||||||
@@ -231,6 +234,186 @@ async fn four_node_health_inventory_metrics_and_audit_delivery_are_consistent()
|
|||||||
"audit entry leaked the root secret key"
|
"audit entry leaked the root secret key"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let result = verify_write_observations_during_peer_failure(&dist, &bucket).await;
|
||||||
collector.abort();
|
collector.abort();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn node_admin_body(dist: &DistCluster, node: usize, path: &str) -> TestResult<String> {
|
||||||
|
let (status, body) = timeout(
|
||||||
|
Duration::from_secs(30),
|
||||||
|
admin_request(
|
||||||
|
&dist.cluster.nodes[node].url,
|
||||||
|
Method::GET,
|
||||||
|
path,
|
||||||
|
None,
|
||||||
|
&dist.cluster.access_key,
|
||||||
|
&dist.cluster.secret_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await??;
|
||||||
|
assert!(status.is_success(), "node {node} admin request {path}: {status} {body}");
|
||||||
|
Ok(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn http_put_counts(dist: &DistCluster, node: usize) -> TestResult<[u64; 2]> {
|
||||||
|
let body = node_admin_body(dist, node, "/rustfs/admin/v3/metrics?types=512&by-host=true&n=1").await?;
|
||||||
|
let sample: RealtimeMetrics = serde_json::from_str(body.lines().next().ok_or("empty HTTP metrics stream")?)?;
|
||||||
|
assert!(sample.errors.is_empty(), "HTTP metrics returned errors: {:?}", sample.errors);
|
||||||
|
let http = sample.aggregated.http.ok_or("HTTP metrics missing at WARN log level")?;
|
||||||
|
let count = |http: &HttpMetrics, outcome: &str| {
|
||||||
|
http.requests
|
||||||
|
.iter()
|
||||||
|
.filter(|row| row.method == "PUT" && row.outcome == outcome)
|
||||||
|
.map(|row| row.total)
|
||||||
|
.sum::<u64>()
|
||||||
|
};
|
||||||
|
assert_eq!(sample.by_host.len(), 1, "HTTP admin metrics must remain node-local");
|
||||||
|
let host = sample.by_host.values().next().expect("one reporting host");
|
||||||
|
let host = host.http.as_ref().ok_or("by-host HTTP metrics missing")?;
|
||||||
|
let totals = [count(&http, "2xx"), count(&http, "5xx")];
|
||||||
|
assert_eq!(totals, [count(host, "2xx"), count(host, "5xx")]);
|
||||||
|
Ok(totals)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn observed_put(dist: &DistCluster, node: usize, bucket: &str, key: &str) -> TestResult<http::StatusCode> {
|
||||||
|
// One signed HTTP attempt: SDK retries must not change the expected denominator.
|
||||||
|
timeout(Duration::from_secs(90), async {
|
||||||
|
let response = signed_request(
|
||||||
|
Method::PUT,
|
||||||
|
&format!("{}/{bucket}/{key}", dist.cluster.nodes[node].url),
|
||||||
|
&dist.cluster.access_key,
|
||||||
|
&dist.cluster.secret_key,
|
||||||
|
Some(b"write-observation".to_vec()),
|
||||||
|
Some("application/octet-stream"),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(response.headers().contains_key("x-amz-request-id"), "PUT omitted correlation ID");
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await?;
|
||||||
|
assert!(!body.contains(&dist.cluster.secret_key), "PUT response leaked credentials");
|
||||||
|
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(status)
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SuspendedPeer<'a> {
|
||||||
|
// Borrowing the owned child keeps its PID from being reaped/reused before cleanup.
|
||||||
|
child: &'a std::process::Child,
|
||||||
|
suspended: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SuspendedPeer<'a> {
|
||||||
|
fn suspend(dist: &'a DistCluster, node: usize) -> TestResult<Self> {
|
||||||
|
let child = dist.cluster.nodes[node].process.as_ref().ok_or("peer process missing")?;
|
||||||
|
signal_process(child.id(), "STOP")?;
|
||||||
|
Ok(Self { child, suspended: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resume(&mut self) -> TestResult {
|
||||||
|
signal_process(self.child.id(), "CONT")?;
|
||||||
|
self.suspended = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for SuspendedPeer<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.suspended {
|
||||||
|
let _ = signal_process(self.child.id(), "CONT");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_write_observations_during_peer_failure(dist: &DistCluster, bucket: &str) -> TestResult {
|
||||||
|
let mut baseline = Vec::new();
|
||||||
|
for node in 0..dist.cluster.nodes.len() {
|
||||||
|
let before = http_put_counts(dist, node).await?;
|
||||||
|
assert!(
|
||||||
|
observed_put(dist, node, bucket, &format!("healthy-{node}"))
|
||||||
|
.await?
|
||||||
|
.is_success()
|
||||||
|
);
|
||||||
|
let after = http_put_counts(dist, node).await?;
|
||||||
|
assert_eq!(after, [before[0] + 1, before[1]], "node {node} lost its successful PUT denominator");
|
||||||
|
baseline.push(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh provenance immediately before the first failed probe, within the cache age budget.
|
||||||
|
node_admin_body(dist, 0, "/rustfs/admin/v3/storageinfo").await?;
|
||||||
|
let mut suspended = [SuspendedPeer::suspend(dist, 2)?, SuspendedPeer::suspend(dist, 3)?];
|
||||||
|
let storage: Value = serde_json::from_str(&node_admin_body(dist, 0, "/rustfs/admin/v3/storageinfo").await?)?;
|
||||||
|
let observations = storage["info"]["observations"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or("storageinfo omitted observations")?;
|
||||||
|
let disks = storage["info"]["disks"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or("storageinfo omitted disks during peer failure")?;
|
||||||
|
assert_eq!(disks.len(), 16, "failed peers must not vanish from inventory");
|
||||||
|
for node in [2, 3] {
|
||||||
|
let endpoint = &dist.cluster.nodes[node].address;
|
||||||
|
let observation = observations
|
||||||
|
.iter()
|
||||||
|
.find(|item| item["endpoint"].as_str().is_some_and(|value| value.contains(endpoint)))
|
||||||
|
.ok_or_else(|| format!("missing failed peer observation {endpoint}: {storage}"))?;
|
||||||
|
assert_eq!(observation["status"], "failed", "suspension did not affect peer RPC: {observation}");
|
||||||
|
assert_eq!(observation["cached"], true, "first failure must identify the warm cache: {observation}");
|
||||||
|
assert!(observation["last_success_unix_millis"].as_u64().is_some());
|
||||||
|
assert!(observation["snapshot_age_seconds"].as_u64().is_some_and(|age| age < 60));
|
||||||
|
let peer_disks: Vec<_> = disks
|
||||||
|
.iter()
|
||||||
|
.filter(|disk| disk["endpoint"].as_str().is_some_and(|value| value.contains(endpoint)))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(peer_disks.len(), 4, "failed peer lost its four drive identities: {storage}");
|
||||||
|
for disk in peer_disks {
|
||||||
|
assert_eq!(disk["state"], "unknown");
|
||||||
|
assert_eq!(disk["runtimeState"], "unknown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot: Value = serde_json::from_str(&node_admin_body(dist, 0, "/rustfs/admin/v4/cluster/snapshot").await?)?;
|
||||||
|
let metadata = &snapshot["snapshot"]["pool_meta_write_gate"];
|
||||||
|
assert_eq!(
|
||||||
|
metadata["state"], "writable",
|
||||||
|
"peer probe failure must not invent a metadata latch: {snapshot}"
|
||||||
|
);
|
||||||
|
assert!(metadata.get("sinceUnixSecs").is_none());
|
||||||
|
|
||||||
|
for attempt in 0..2 {
|
||||||
|
let status = observed_put(dist, 0, bucket, &format!("unavailable-{attempt}")).await?;
|
||||||
|
assert!(status.is_server_error(), "sub-quorum write unexpectedly returned {status}");
|
||||||
|
}
|
||||||
|
assert_eq!(http_put_counts(dist, 0).await?, [baseline[0][0], baseline[0][1] + 2]);
|
||||||
|
assert_eq!(
|
||||||
|
http_put_counts(dist, 1).await?,
|
||||||
|
baseline[1],
|
||||||
|
"internal RPCs must not count as external PUTs"
|
||||||
|
);
|
||||||
|
|
||||||
|
for peer in &mut suspended {
|
||||||
|
peer.resume()?;
|
||||||
|
}
|
||||||
|
wait_until(
|
||||||
|
Duration::from_secs(90),
|
||||||
|
|| async {
|
||||||
|
let storage: Value = serde_json::from_str(&node_admin_body(dist, 0, "/rustfs/admin/v3/storageinfo").await?)?;
|
||||||
|
let observations = storage["info"]["observations"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or("recovery omitted observations")?;
|
||||||
|
Ok(observations.len() == 4
|
||||||
|
&& observations
|
||||||
|
.iter()
|
||||||
|
.all(|item| item["status"] == "succeeded" && item["cached"] == false))
|
||||||
|
},
|
||||||
|
"peer probes recover to fresh successful observations",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
wait_for_ready(&dist.cluster).await?;
|
||||||
|
assert!(observed_put(dist, 0, bucket, "recovered").await?.is_success());
|
||||||
|
assert_eq!(http_put_counts(dist, 0).await?, [baseline[0][0] + 1, baseline[0][1] + 2]);
|
||||||
|
for node in 0..dist.cluster.nodes.len() {
|
||||||
|
assert_object_bytes(&dist.client(node)?, bucket, "healthy-0", b"write-observation").await?;
|
||||||
|
assert_object_bytes(&dist.client(node)?, bucket, "recovered", b"write-observation").await?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,9 @@
|
|||||||
|
|
||||||
use super::harness::{
|
use super::harness::{
|
||||||
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
DECOMMISSION_POOL_ID, DistCluster, DistLayout, TestResult, assert_inventory, decommission_running_with_progress,
|
||||||
decommission_status_json, put_inventory_retrying, rebalance_running_with_progress, rebalance_status_json,
|
decommission_status_json, put_inventory_retrying, rebalance_active, rebalance_status_json, retrying_get_equals, retrying_put,
|
||||||
retrying_get_equals, retrying_put, start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
start_decommission, start_rebalance, unique_bucket, wait_for_decommission_complete,
|
||||||
wait_for_decommission_running_with_progress, wait_for_rebalance_complete, wait_for_rebalance_running_with_progress,
|
wait_for_decommission_running_with_progress, wait_for_rebalance_active, wait_for_rebalance_complete,
|
||||||
};
|
};
|
||||||
use crate::common::init_logging;
|
use crate::common::init_logging;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -67,7 +67,10 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
|
|||||||
assert_inventory(&live, &bucket, &inventory).await?;
|
assert_inventory(&live, &bucket, &inventory).await?;
|
||||||
|
|
||||||
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
let rebalance_id = start_rebalance(&dist.cluster).await?;
|
||||||
wait_for_rebalance_running_with_progress(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
// The status API reads persisted progress, whose first periodic save is
|
||||||
|
// after 30 seconds. A shorter run can remain at zero until completion.
|
||||||
|
// Require Started around the S3 operations and nonzero progress at completion.
|
||||||
|
wait_for_rebalance_active(&dist.cluster, &rebalance_id, Duration::from_secs(30)).await?;
|
||||||
retrying_put(
|
retrying_put(
|
||||||
&live,
|
&live,
|
||||||
&bucket,
|
&bucket,
|
||||||
@@ -84,11 +87,26 @@ async fn s3_put_get_list_succeed_during_decommission_and_rebalance() -> TestResu
|
|||||||
Duration::from_secs(30),
|
Duration::from_secs(30),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
let listed = live.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
assert!(
|
||||||
|
listed
|
||||||
|
.contents()
|
||||||
|
.iter()
|
||||||
|
.any(|object| object.key() == Some("during-rebalance.bin")),
|
||||||
|
"list during rebalance missed the newly written key"
|
||||||
|
);
|
||||||
let status = rebalance_status_json(&dist.cluster).await?;
|
let status = rebalance_status_json(&dist.cluster).await?;
|
||||||
if !rebalance_running_with_progress(&status, &rebalance_id)? {
|
if !rebalance_active(&status, &rebalance_id)? {
|
||||||
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
|
return Err(format!("rebalance did not remain active across the S3 operations: {status}").into());
|
||||||
}
|
}
|
||||||
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
wait_for_rebalance_complete(&dist.cluster, &rebalance_id, Duration::from_secs(180)).await?;
|
||||||
assert_inventory(&dist.client(1)?, &bucket, &inventory).await?;
|
let after = dist.client(1)?;
|
||||||
|
assert_inventory(&after, &bucket, &inventory).await?;
|
||||||
|
for (key, body) in [
|
||||||
|
("during-decommission.bin", b"written-while-decommissioning".as_slice()),
|
||||||
|
("during-rebalance.bin", b"written-while-rebalancing".as_slice()),
|
||||||
|
] {
|
||||||
|
retrying_get_equals(&after, &bucket, key, body, Duration::from_secs(30)).await?;
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -630,6 +630,10 @@ struct MultipartPart {
|
|||||||
body: Bytes,
|
body: Bytes,
|
||||||
e_tag: String,
|
e_tag: String,
|
||||||
digest: [u8; 16],
|
digest: [u8; 16],
|
||||||
|
/// Plaintext length declared by an SSE-C passthrough sender
|
||||||
|
/// (`x-rustfs-replication-part-actual-size`); RustFS validates the 5 MiB
|
||||||
|
/// minimum against it rather than against the stored bytes.
|
||||||
|
actual_size: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -2823,6 +2827,11 @@ impl S3 for FakeBackend {
|
|||||||
|
|
||||||
async fn upload_part(&self, req: S3Request<UploadPartInput>) -> S3Result<S3Response<UploadPartOutput>> {
|
async fn upload_part(&self, req: S3Request<UploadPartInput>) -> S3Result<S3Response<UploadPartOutput>> {
|
||||||
let fault = request_fault(&req);
|
let fault = request_fault(&req);
|
||||||
|
let declared_actual_size = req
|
||||||
|
.headers
|
||||||
|
.get("x-rustfs-replication-part-actual-size")
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(|value| value.parse::<usize>().ok());
|
||||||
let _body_permit = timeout(MAX_FAULT_DURATION, Arc::clone(&self.body_limit).acquire_owned())
|
let _body_permit = timeout(MAX_FAULT_DURATION, Arc::clone(&self.body_limit).acquire_owned())
|
||||||
.await
|
.await
|
||||||
.map_err(|_| s3s::s3_error!(RequestTimeout, "fake target body limiter wait exceeded 30 seconds"))?
|
.map_err(|_| s3s::s3_error!(RequestTimeout, "fake target body limiter wait exceeded 30 seconds"))?
|
||||||
@@ -2864,6 +2873,7 @@ impl S3 for FakeBackend {
|
|||||||
body,
|
body,
|
||||||
e_tag: e_tag.clone(),
|
e_tag: e_tag.clone(),
|
||||||
digest,
|
digest,
|
||||||
|
actual_size: declared_actual_size,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
Ok(apply_response_fault(
|
Ok(apply_response_fault(
|
||||||
@@ -2933,7 +2943,9 @@ impl S3 for FakeBackend {
|
|||||||
if requested_etag != &stored.e_tag {
|
if requested_etag != &stored.e_tag {
|
||||||
return Err(s3s::s3_error!(InvalidPart, "part ETag does not match"));
|
return Err(s3s::s3_error!(InvalidPart, "part ETag does not match"));
|
||||||
}
|
}
|
||||||
if index + 1 != requested_parts.len() && stored.body.len() < MIN_MULTIPART_PART_BYTES {
|
if index + 1 != requested_parts.len()
|
||||||
|
&& stored.actual_size.unwrap_or(stored.body.len()) < MIN_MULTIPART_PART_BYTES
|
||||||
|
{
|
||||||
return Err(s3s::s3_error!(EntityTooSmall, "non-final multipart part is smaller than 5 MiB"));
|
return Err(s3s::s3_error!(EntityTooSmall, "non-final multipart part is smaller than 5 MiB"));
|
||||||
}
|
}
|
||||||
selected.push((*number, stored.clone()));
|
selected.push((*number, stored.clone()));
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
mod tests {
|
mod tests {
|
||||||
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
|
use crate::chaos::{VersionShardCensus, census_object_version_on_disk, sha256_hex, signed_admin_post};
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request, init_logging,
|
ClusterTopology, FAST_DATA_USAGE_SCANNER_ENV, RustFSTestClusterEnvironment, RustFSTestEnvironment, admin_request,
|
||||||
rustfs_binary_path,
|
init_logging, rustfs_binary_path,
|
||||||
};
|
};
|
||||||
use crate::storage_api::RUSTFS_META_BUCKET;
|
use crate::storage_api::RUSTFS_META_BUCKET;
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
@@ -58,11 +58,133 @@ mod tests {
|
|||||||
struct ScannerHealEvidenceCase {
|
struct ScannerHealEvidenceCase {
|
||||||
id: &'static str,
|
id: &'static str,
|
||||||
oracle: &'static str,
|
oracle: &'static str,
|
||||||
|
evidence: &'static str,
|
||||||
|
unclean_shutdown_marker: bool,
|
||||||
|
topology: EvidenceTopology,
|
||||||
|
storage_class_standard: Option<&'static str>,
|
||||||
|
erasure_set_drive_count: Option<usize>,
|
||||||
|
outage_target_manifest_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct EvidenceTopology {
|
||||||
|
nodes: usize,
|
||||||
|
drives_per_node: usize,
|
||||||
|
layout: EvidenceTopologyLayout,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum EvidenceTopologyLayout {
|
||||||
|
SinglePool,
|
||||||
|
PerNodePools,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EvidenceTopology {
|
||||||
|
const fn new(nodes: usize, drives_per_node: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
nodes,
|
||||||
|
drives_per_node,
|
||||||
|
layout: EvidenceTopologyLayout::SinglePool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn per_node_pools(nodes: usize, drives_per_node: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
nodes,
|
||||||
|
drives_per_node,
|
||||||
|
layout: EvidenceTopologyLayout::PerNodePools,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn total_drives(self) -> usize {
|
||||||
|
self.nodes * self.drives_per_node
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cluster_topology(self) -> ClusterTopology {
|
||||||
|
match self.layout {
|
||||||
|
EvidenceTopologyLayout::SinglePool => ClusterTopology::single_pool_multidrive(self.nodes, self.drives_per_node),
|
||||||
|
EvidenceTopologyLayout::PerNodePools => {
|
||||||
|
ClusterTopology::per_node_pools(self.drives_per_node, (0..self.nodes).map(|node| vec![node]).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pool_count(self) -> usize {
|
||||||
|
match self.layout {
|
||||||
|
EvidenceTopologyLayout::SinglePool => 1,
|
||||||
|
EvidenceTopologyLayout::PerNodePools => self.nodes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_count(self, erasure_set_drive_count: Option<usize>) -> usize {
|
||||||
|
self.total_drives() / erasure_set_drive_count.unwrap_or_else(|| self.total_drives())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
const BACKGROUND_TARGET_RESTART_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
id: "background-target-restart",
|
id: "background-target-restart",
|
||||||
oracle: "background-target-restart.json",
|
oracle: "background-target-restart.json",
|
||||||
|
evidence: "process-restart",
|
||||||
|
unclean_shutdown_marker: false,
|
||||||
|
topology: EvidenceTopology::new(4, 1),
|
||||||
|
storage_class_standard: None,
|
||||||
|
erasure_set_drive_count: None,
|
||||||
|
outage_target_manifest_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_CRASH_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-crash",
|
||||||
|
oracle: "background-target-crash.json",
|
||||||
|
evidence: "process-crash-restart",
|
||||||
|
unclean_shutdown_marker: true,
|
||||||
|
topology: EvidenceTopology::new(4, 1),
|
||||||
|
storage_class_standard: None,
|
||||||
|
erasure_set_drive_count: None,
|
||||||
|
outage_target_manifest_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_RESTART_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-restart-ec8-4",
|
||||||
|
oracle: "background-target-restart-ec8-4.json",
|
||||||
|
evidence: "process-restart",
|
||||||
|
unclean_shutdown_marker: false,
|
||||||
|
topology: EvidenceTopology::new(3, 4),
|
||||||
|
storage_class_standard: Some("EC:4"),
|
||||||
|
erasure_set_drive_count: Some(12),
|
||||||
|
outage_target_manifest_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_CRASH_EC84_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-crash-ec8-4",
|
||||||
|
oracle: "background-target-crash-ec8-4.json",
|
||||||
|
evidence: "process-crash-restart",
|
||||||
|
unclean_shutdown_marker: true,
|
||||||
|
topology: EvidenceTopology::new(3, 4),
|
||||||
|
storage_class_standard: Some("EC:4"),
|
||||||
|
erasure_set_drive_count: Some(12),
|
||||||
|
outage_target_manifest_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_RESTART_EC84_MULTI_SET_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-restart-ec8-4-multi-set",
|
||||||
|
oracle: "background-target-restart-ec8-4-multi-set.json",
|
||||||
|
evidence: "process-restart",
|
||||||
|
unclean_shutdown_marker: false,
|
||||||
|
topology: EvidenceTopology::new(3, 8),
|
||||||
|
storage_class_standard: Some("EC:4"),
|
||||||
|
erasure_set_drive_count: Some(12),
|
||||||
|
outage_target_manifest_required: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const BACKGROUND_TARGET_CRASH_EC84_MULTI_POOL_EVIDENCE: ScannerHealEvidenceCase = ScannerHealEvidenceCase {
|
||||||
|
id: "background-target-crash-ec8-4-multi-pool",
|
||||||
|
oracle: "background-target-crash-ec8-4-multi-pool.json",
|
||||||
|
evidence: "process-crash-restart",
|
||||||
|
unclean_shutdown_marker: true,
|
||||||
|
topology: EvidenceTopology::per_node_pools(3, 12),
|
||||||
|
storage_class_standard: Some("EC:4"),
|
||||||
|
erasure_set_drive_count: Some(12),
|
||||||
|
outage_target_manifest_required: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct RestartEvidenceContext {
|
struct RestartEvidenceContext {
|
||||||
@@ -98,6 +220,8 @@ mod tests {
|
|||||||
|| case.oracle.contains('/')
|
|| case.oracle.contains('/')
|
||||||
|| case.oracle.contains('\\')
|
|| case.oracle.contains('\\')
|
||||||
|| case.oracle.contains("..")
|
|| case.oracle.contains("..")
|
||||||
|
|| !matches!(case.evidence, "process-restart" | "process-crash-restart")
|
||||||
|
|| (case.evidence == "process-crash-restart") != case.unclean_shutdown_marker
|
||||||
{
|
{
|
||||||
return Err("invalid scanner/heal evidence case".into());
|
return Err("invalid scanner/heal evidence case".into());
|
||||||
}
|
}
|
||||||
@@ -319,11 +443,10 @@ mod tests {
|
|||||||
|
|
||||||
// Healing may rewrite non-identity bookkeeping in xl.meta. The census
|
// Healing may rewrite non-identity bookkeeping in xl.meta. The census
|
||||||
// therefore compares the canonical selected metadata fields plus every
|
// therefore compares the canonical selected metadata fields plus every
|
||||||
// physical shard, while the payload seed makes object mix-ups observable.
|
// physical shard.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct PhysicalObjectManifest {
|
struct PhysicalObjectManifest {
|
||||||
key: String,
|
key: String,
|
||||||
payload_seed: u8,
|
|
||||||
shard_census: VersionShardCensus,
|
shard_census: VersionShardCensus,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,6 +1073,56 @@ mod tests {
|
|||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_cluster_root_heal_recovers_remote_shards_after_background_target_crash()
|
||||||
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(420),
|
||||||
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrash),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_restart()
|
||||||
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(420),
|
||||||
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_cluster_root_heal_recovers_ec84_shards_after_background_target_crash()
|
||||||
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(420),
|
||||||
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_cluster_root_heal_recovers_ec84_shards_across_multi_set_after_background_target_restart()
|
||||||
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(600),
|
||||||
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetRestartEc84MultiSet),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn test_cluster_root_heal_recovers_ec84_shards_across_multi_pool_after_background_target_crash()
|
||||||
|
-> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
|
timeout(
|
||||||
|
Duration::from_secs(600),
|
||||||
|
run_cluster_root_heal_interruption(InterruptionScenario::BackgroundTargetCrashEc84MultiPool),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
async fn test_cluster_root_heal_recovers_remote_shards_after_coordinator_restart() -> Result<(), Box<dyn Error + Send + Sync>>
|
||||||
{
|
{
|
||||||
@@ -986,24 +1159,49 @@ mod tests {
|
|||||||
enum InterruptionScenario {
|
enum InterruptionScenario {
|
||||||
IsolatedTargetRestart,
|
IsolatedTargetRestart,
|
||||||
BackgroundTargetRestart,
|
BackgroundTargetRestart,
|
||||||
|
BackgroundTargetCrash,
|
||||||
|
BackgroundTargetRestartEc84,
|
||||||
|
BackgroundTargetCrashEc84,
|
||||||
|
BackgroundTargetRestartEc84MultiSet,
|
||||||
|
BackgroundTargetCrashEc84MultiPool,
|
||||||
BackgroundCoordinatorRestart,
|
BackgroundCoordinatorRestart,
|
||||||
TargetEndpointBlackhole,
|
TargetEndpointBlackhole,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
|
async fn run_cluster_root_heal_interruption(scenario: InterruptionScenario) -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||||
let server_binary = rustfs_binary_path();
|
let server_binary = rustfs_binary_path();
|
||||||
let evidence_run = if scenario == InterruptionScenario::BackgroundTargetRestart {
|
let evidence_case = match scenario {
|
||||||
restart_evidence_run(&server_binary, BACKGROUND_TARGET_RESTART_EVIDENCE)?
|
InterruptionScenario::BackgroundTargetRestart => Some(BACKGROUND_TARGET_RESTART_EVIDENCE),
|
||||||
} else {
|
InterruptionScenario::BackgroundTargetCrash => Some(BACKGROUND_TARGET_CRASH_EVIDENCE),
|
||||||
None
|
InterruptionScenario::BackgroundTargetRestartEc84 => Some(BACKGROUND_TARGET_RESTART_EC84_EVIDENCE),
|
||||||
|
InterruptionScenario::BackgroundTargetCrashEc84 => Some(BACKGROUND_TARGET_CRASH_EC84_EVIDENCE),
|
||||||
|
InterruptionScenario::BackgroundTargetRestartEc84MultiSet => Some(BACKGROUND_TARGET_RESTART_EC84_MULTI_SET_EVIDENCE),
|
||||||
|
InterruptionScenario::BackgroundTargetCrashEc84MultiPool => Some(BACKGROUND_TARGET_CRASH_EC84_MULTI_POOL_EVIDENCE),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
let evidence_run = match evidence_case {
|
||||||
|
Some(case) => restart_evidence_run(&server_binary, case)?,
|
||||||
|
None => None,
|
||||||
};
|
};
|
||||||
let mut evidence_objects = Vec::new();
|
let mut evidence_objects = Vec::new();
|
||||||
let (background_enabled, interruption_node, interruption_kind) = match scenario {
|
let (background_enabled, interruption_node, interruption_kind) = match scenario {
|
||||||
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
|
InterruptionScenario::IsolatedTargetRestart => (false, 1, "target_restart"),
|
||||||
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
|
InterruptionScenario::BackgroundTargetRestart => (true, 1, "background_target_restart"),
|
||||||
|
InterruptionScenario::BackgroundTargetCrash => (true, 1, "background_target_crash"),
|
||||||
|
InterruptionScenario::BackgroundTargetRestartEc84 => (true, 1, "background_target_restart_ec8_4"),
|
||||||
|
InterruptionScenario::BackgroundTargetCrashEc84 => (true, 1, "background_target_crash_ec8_4"),
|
||||||
|
InterruptionScenario::BackgroundTargetRestartEc84MultiSet => (true, 1, "background_target_restart_ec8_4_multi_set"),
|
||||||
|
InterruptionScenario::BackgroundTargetCrashEc84MultiPool => (true, 1, "background_target_crash_ec8_4_multi_pool"),
|
||||||
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
|
InterruptionScenario::BackgroundCoordinatorRestart => (true, 0, "coordinator_restart"),
|
||||||
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
|
InterruptionScenario::TargetEndpointBlackhole => (false, 1, "target_endpoint_blackhole"),
|
||||||
};
|
};
|
||||||
|
let topology = evidence_case
|
||||||
|
.map(|case| case.topology)
|
||||||
|
.unwrap_or_else(|| EvidenceTopology::new(4, 1));
|
||||||
|
let erasure_set_drive_count = evidence_case
|
||||||
|
.and_then(|case| case.erasure_set_drive_count)
|
||||||
|
.unwrap_or_else(|| topology.total_drives());
|
||||||
|
let outage_target_manifest_required = evidence_case.map(|case| case.outage_target_manifest_required).unwrap_or(true);
|
||||||
init_logging();
|
init_logging();
|
||||||
info!(
|
info!(
|
||||||
event = "heal_interruption_started",
|
event = "heal_interruption_started",
|
||||||
@@ -1015,9 +1213,17 @@ mod tests {
|
|||||||
"Starting root-heal interruption test"
|
"Starting root-heal interruption test"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
let mut cluster = RustFSTestClusterEnvironment::with_topology(topology.cluster_topology()).await?;
|
||||||
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
|
cluster.set_env("RUSTFS_UNSAFE_BYPASS_DISK_CHECK", "true");
|
||||||
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
|
cluster.set_env("RUSTFS_HEAL_ENABLED", "true");
|
||||||
|
if let Some(storage_class) = evidence_case.and_then(|case| case.storage_class_standard) {
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", storage_class);
|
||||||
|
}
|
||||||
|
if let Some(erasure_set_drive_count) = evidence_case.and_then(|case| case.erasure_set_drive_count) {
|
||||||
|
cluster.set_env("RUSTFS_ERASURE_SET_DRIVE_COUNT", erasure_set_drive_count.to_string());
|
||||||
|
}
|
||||||
|
// Capture physical baselines after the PUT rename fanout has drained.
|
||||||
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||||
// Heal control uses the first lexicographically sorted grid host.
|
// Heal control uses the first lexicographically sorted grid host.
|
||||||
// Keep that coordinator distinct from the remote target at index 1.
|
// Keep that coordinator distinct from the remote target at index 1.
|
||||||
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
|
cluster.nodes.sort_by(|left, right| left.url.cmp(&right.url));
|
||||||
@@ -1066,10 +1272,22 @@ mod tests {
|
|||||||
.and_then(|value| value.parse::<usize>().ok())
|
.and_then(|value| value.parse::<usize>().ok())
|
||||||
.unwrap_or(4 * 1024 * 1024)
|
.unwrap_or(4 * 1024 * 1024)
|
||||||
.clamp(1024 * 1024, 16 * 1024 * 1024);
|
.clamp(1024 * 1024, 16 * 1024 * 1024);
|
||||||
|
let mut created_online_objects = Vec::with_capacity(online_object_count);
|
||||||
let mut expected_manifests = Vec::with_capacity(online_object_count);
|
let mut expected_manifests = Vec::with_capacity(online_object_count);
|
||||||
for index in 0..online_object_count {
|
let mut unclean_shutdown_marker_observed = None;
|
||||||
let key = format!("cluster/online/object-{index:04}.bin");
|
let mut attempt_count = 0usize;
|
||||||
let payload_seed = u8::try_from(index + 1).expect("clamped object count must fit in u8");
|
let max_online_attempts = online_object_count.saturating_mul(topology.total_drives().max(1));
|
||||||
|
while expected_manifests.len() < online_object_count {
|
||||||
|
if attempt_count >= max_online_attempts {
|
||||||
|
return Err(format!(
|
||||||
|
"target replacement drive held only {}/{} baseline object shards after {attempt_count} writes",
|
||||||
|
expected_manifests.len(),
|
||||||
|
online_object_count
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
let key = format!("cluster/online/object-{attempt_count:04}.bin");
|
||||||
|
let payload_seed = ((attempt_count % 251) + 1) as u8;
|
||||||
timeout(
|
timeout(
|
||||||
Duration::from_secs(30),
|
Duration::from_secs(30),
|
||||||
clients[0]
|
clients[0]
|
||||||
@@ -1081,6 +1299,11 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await??;
|
.await??;
|
||||||
let shard_census = census_object_version_on_disk(&replaced_disk, bucket, &key, None)?;
|
let shard_census = census_object_version_on_disk(&replaced_disk, bucket, &key, None)?;
|
||||||
|
if !shard_census.has_xl_meta {
|
||||||
|
timeout(Duration::from_secs(30), clients[0].delete_object().bucket(bucket).key(&key).send()).await??;
|
||||||
|
attempt_count += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
assert!(
|
assert!(
|
||||||
shard_census.is_complete(),
|
shard_census.is_complete(),
|
||||||
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
|
"node 1 should hold a complete baseline shard for {key}: {shard_census:?}"
|
||||||
@@ -1089,11 +1312,9 @@ mod tests {
|
|||||||
!shard_census.expected_part_numbers.is_empty(),
|
!shard_census.expected_part_numbers.is_empty(),
|
||||||
"chaos objects must use physical part shards rather than inline data: {shard_census:?}"
|
"chaos objects must use physical part shards rather than inline data: {shard_census:?}"
|
||||||
);
|
);
|
||||||
expected_manifests.push(PhysicalObjectManifest {
|
created_online_objects.push((key.clone(), payload_seed));
|
||||||
key,
|
expected_manifests.push(PhysicalObjectManifest { key, shard_census });
|
||||||
payload_seed,
|
attempt_count += 1;
|
||||||
shard_census,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let expected_pool_metadata = if background_enabled {
|
let expected_pool_metadata = if background_enabled {
|
||||||
@@ -1139,31 +1360,48 @@ mod tests {
|
|||||||
if node_index == 1 {
|
if node_index == 1 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let census = census_object_version_on_disk(Path::new(&node.data_dir), bucket, outage_key, None)?;
|
for (drive_index, drive) in node.data_dirs.iter().enumerate() {
|
||||||
|
let census = census_object_version_on_disk(Path::new(drive), bucket, outage_key, None)?;
|
||||||
|
if !census.has_xl_meta {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
census.is_complete(),
|
||||||
|
"online node {node_index} drive {drive_index} must hold a complete outage-object shard: {census:?}"
|
||||||
|
);
|
||||||
|
let erasure_index = census.erasure_index.ok_or_else(|| {
|
||||||
|
format!("online node {node_index} drive {drive_index} outage-object shard has no erasure index: {census:?}")
|
||||||
|
})?;
|
||||||
|
assert!(
|
||||||
|
(1..=erasure_set_drive_count).contains(&erasure_index),
|
||||||
|
"online node {node_index} drive {drive_index} outage-object erasure index is out of range: {census:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
outage_peer_erasure_indices.insert(erasure_index),
|
||||||
|
"outage-object erasure index {erasure_index} is duplicated across online drives"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!outage_peer_erasure_indices.is_empty() && outage_peer_erasure_indices.len() <= erasure_set_drive_count,
|
||||||
|
"outage-object must occupy one non-empty erasure set"
|
||||||
|
);
|
||||||
|
if outage_target_manifest_required {
|
||||||
|
let min_online_data_shards = erasure_set_drive_count.saturating_sub(4);
|
||||||
assert!(
|
assert!(
|
||||||
census.is_complete(),
|
outage_peer_erasure_indices.len() >= min_online_data_shards,
|
||||||
"online node {node_index} must hold a complete outage-object shard: {census:?}"
|
"online drives in the selected erasure set must retain at least the EC data quorum"
|
||||||
);
|
);
|
||||||
let erasure_index = census
|
}
|
||||||
.erasure_index
|
let missing_outage_erasure_indices = (1..=erasure_set_drive_count)
|
||||||
.ok_or_else(|| format!("online node {node_index} outage-object shard has no erasure index: {census:?}"))?;
|
.filter(|index| !outage_peer_erasure_indices.contains(index))
|
||||||
assert!(
|
.collect::<HashSet<_>>();
|
||||||
(1..=cluster.nodes.len()).contains(&erasure_index),
|
if outage_target_manifest_required {
|
||||||
"online node {node_index} outage-object erasure index is out of range: {census:?}"
|
assert!(
|
||||||
);
|
!missing_outage_erasure_indices.is_empty(),
|
||||||
assert!(
|
"the stopped target must account for at least one missing outage-object erasure index"
|
||||||
outage_peer_erasure_indices.insert(erasure_index),
|
|
||||||
"outage-object erasure index {erasure_index} is duplicated across online nodes"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assert_eq!(
|
|
||||||
outage_peer_erasure_indices.len(),
|
|
||||||
cluster.nodes.len().saturating_sub(1),
|
|
||||||
"every online node must contribute one unique outage-object erasure index"
|
|
||||||
);
|
|
||||||
let expected_outage_target_erasure_index = (1..=cluster.nodes.len())
|
|
||||||
.find(|index| !outage_peer_erasure_indices.contains(index))
|
|
||||||
.ok_or("online outage-object shards leave no erasure index for the replacement target")?;
|
|
||||||
|
|
||||||
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
let heal_body = r#"{"recursive":true,"dryRun":false,"remove":false,"recreate":true,"scanMode":2,"updateParity":false,"nolock":false}"#;
|
||||||
if !background_enabled {
|
if !background_enabled {
|
||||||
@@ -1433,7 +1671,16 @@ mod tests {
|
|||||||
"Restored target endpoint forwarding"
|
"Restored target endpoint forwarding"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
cluster.stop_node(interruption_node)?;
|
if matches!(
|
||||||
|
scenario,
|
||||||
|
InterruptionScenario::BackgroundTargetRestart
|
||||||
|
| InterruptionScenario::BackgroundTargetRestartEc84
|
||||||
|
| InterruptionScenario::BackgroundCoordinatorRestart
|
||||||
|
) {
|
||||||
|
cluster.stop_node_gracefully(interruption_node).await?;
|
||||||
|
} else {
|
||||||
|
cluster.stop_node(interruption_node)?;
|
||||||
|
}
|
||||||
let stopped_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
|
let stopped_count = metadata_count(&replaced_disk, bucket, &expected_manifests);
|
||||||
assert!(
|
assert!(
|
||||||
stopped_count > 0 && stopped_count < expected_manifests.len(),
|
stopped_count > 0 && stopped_count < expected_manifests.len(),
|
||||||
@@ -1449,9 +1696,17 @@ mod tests {
|
|||||||
.join(".rustfs.sys")
|
.join(".rustfs.sys")
|
||||||
.join("unclean-shutdown");
|
.join("unclean-shutdown");
|
||||||
if background_enabled {
|
if background_enabled {
|
||||||
|
let marker_exists = unclean_shutdown_marker.is_file();
|
||||||
|
unclean_shutdown_marker_observed = Some(marker_exists);
|
||||||
|
let expected_marker = matches!(
|
||||||
|
scenario,
|
||||||
|
InterruptionScenario::BackgroundTargetCrash
|
||||||
|
| InterruptionScenario::BackgroundTargetCrashEc84
|
||||||
|
| InterruptionScenario::BackgroundTargetCrashEc84MultiPool
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
unclean_shutdown_marker.is_file(),
|
marker_exists == expected_marker,
|
||||||
"background restart must retain the real unclean-shutdown marker"
|
"background restart/crash lane observed unexpected unclean-shutdown marker state"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
match std::fs::remove_file(&unclean_shutdown_marker) {
|
match std::fs::remove_file(&unclean_shutdown_marker) {
|
||||||
@@ -1487,9 +1742,10 @@ mod tests {
|
|||||||
.unwrap_or(180);
|
.unwrap_or(180);
|
||||||
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
|
let heal_deadline = Instant::now() + Duration::from_secs(heal_timeout_secs);
|
||||||
loop {
|
loop {
|
||||||
if metadata_count(&replaced_disk, bucket, &expected_manifests) == expected_manifests.len()
|
let baseline_recovered = metadata_count(&replaced_disk, bucket, &expected_manifests) == expected_manifests.len();
|
||||||
&& object_metadata_exists_on_disk(&replaced_disk, bucket, outage_key)
|
let outage_recovered =
|
||||||
{
|
!outage_target_manifest_required || object_metadata_exists_on_disk(&replaced_disk, bucket, outage_key);
|
||||||
|
if baseline_recovered && outage_recovered {
|
||||||
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
let matching = matching_manifest_count(&replaced_disk, bucket, &expected_manifests)?;
|
||||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
||||||
let pool_metadata_matches = match &expected_pool_metadata {
|
let pool_metadata_matches = match &expected_pool_metadata {
|
||||||
@@ -1499,7 +1755,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
None => true,
|
None => true,
|
||||||
};
|
};
|
||||||
if matching == expected_manifests.len() && outage_census.is_complete() && pool_metadata_matches {
|
if matching == expected_manifests.len()
|
||||||
|
&& (!outage_target_manifest_required || outage_census.is_complete())
|
||||||
|
&& pool_metadata_matches
|
||||||
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1544,34 +1803,42 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
let outage_census = census_object_version_on_disk(&replaced_disk, bucket, outage_key, None)?;
|
||||||
assert!(
|
if outage_target_manifest_required {
|
||||||
outage_census.is_complete(),
|
assert!(
|
||||||
"outage object must have a complete target shard: {outage_census:?}"
|
outage_census.is_complete(),
|
||||||
);
|
"outage object must have a complete target shard: {outage_census:?}"
|
||||||
assert_eq!(
|
);
|
||||||
outage_census.erasure_index,
|
assert_eq!(
|
||||||
Some(expected_outage_target_erasure_index),
|
outage_census
|
||||||
"the outage object must be rebuilt into its own missing erasure slot"
|
.erasure_index
|
||||||
);
|
.filter(|index| missing_outage_erasure_indices.contains(index)),
|
||||||
|
outage_census.erasure_index,
|
||||||
|
"the outage object must be rebuilt into one of the stopped node's missing erasure slots"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(cycle_end) = scanner_cycle_floor {
|
if let Some(cycle_end) = scanner_cycle_floor {
|
||||||
wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
|
wait_for_scanner_cycle_after(&cluster, cycle_end).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut expected_keys = expected_manifests
|
let mut expected_keys = created_online_objects
|
||||||
.iter()
|
.iter()
|
||||||
.map(|manifest| manifest.key.clone())
|
.map(|(key, _)| key.clone())
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
assert!(expected_keys.insert(outage_key.to_string()));
|
assert!(expected_keys.insert(outage_key.to_string()));
|
||||||
let node_listings = assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
|
let node_listings = assert_all_nodes_list_exact_keys(&clients, bucket, &expected_keys).await?;
|
||||||
|
|
||||||
let target_client = cluster.create_s3_client(1)?;
|
let target_client = cluster.create_s3_client(1)?;
|
||||||
for expected in &expected_manifests {
|
for (key, payload_seed) in &created_online_objects {
|
||||||
let response = target_client.get_object().bucket(bucket).key(&expected.key).send().await?;
|
let response = target_client.get_object().bucket(bucket).key(key).send().await?;
|
||||||
let actual = response.body.collect().await?.into_bytes();
|
let actual = response.body.collect().await?.into_bytes();
|
||||||
let expected_body = deterministic_object_body(object_size_bytes, expected.payload_seed);
|
let expected_body = deterministic_object_body(object_size_bytes, *payload_seed);
|
||||||
assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {}", expected.key);
|
assert_eq!(actual.as_ref(), expected_body.as_slice(), "object body changed for {key}");
|
||||||
if evidence_run.is_some() {
|
if evidence_run.is_some()
|
||||||
|
&& let Some(expected) = expected_manifests
|
||||||
|
.iter()
|
||||||
|
.find(|manifest| manifest.key.as_str() == key.as_str())
|
||||||
|
{
|
||||||
evidence_objects.push(serde_json::json!({
|
evidence_objects.push(serde_json::json!({
|
||||||
"key": expected.key, "version_id": expected.shard_census.version_id,
|
"key": expected.key, "version_id": expected.shard_census.version_id,
|
||||||
"expected_bytes": expected_body.len(), "actual_bytes": actual.len(),
|
"expected_bytes": expected_body.len(), "actual_bytes": actual.len(),
|
||||||
@@ -1614,33 +1881,22 @@ mod tests {
|
|||||||
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
let task_status_body = signed_admin_post(&task_status_url, None, &cluster.access_key, &cluster.secret_key).await?;
|
||||||
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
|
let task_status: serde_json::Value = serde_json::from_str(&task_status_body)
|
||||||
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
|
.map_err(|err| format!("heal task status is not JSON ({err}): {task_status_body}"))?;
|
||||||
|
if task_status["summary"].as_str() != Some("finished") {
|
||||||
|
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
|
||||||
|
}
|
||||||
if interruption_node == 0 {
|
if interruption_node == 0 {
|
||||||
// Admin tasks are process-local. Physical and queue convergence
|
// Restart recovery must finish the original durable root request.
|
||||||
// above establish recovery; a lost task must not report success.
|
|
||||||
assert_eq!(
|
|
||||||
task_status["summary"].as_str(),
|
|
||||||
Some("notFound"),
|
|
||||||
"interrupted task status: {task_status}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
task_status["detail"].as_str(),
|
|
||||||
Some("heal task not found or expired"),
|
|
||||||
"interrupted admin task must be explicitly unavailable: {task_status}"
|
|
||||||
);
|
|
||||||
info!(
|
info!(
|
||||||
event = "heal_interruption_recovered",
|
event = "heal_interruption_recovered",
|
||||||
component = "e2e_test",
|
component = "e2e_test",
|
||||||
subsystem = "heal",
|
subsystem = "heal",
|
||||||
interruption_node,
|
interruption_node,
|
||||||
interruption_kind,
|
interruption_kind,
|
||||||
task_state = "not_found",
|
task_state = "finished",
|
||||||
"Physical recovery completed after coordinator restart"
|
"Original root heal completed after coordinator restart"
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
if task_status["summary"].as_str() != Some("finished") {
|
|
||||||
return Err(format!("heal data rebuilt but task did not finish successfully: {task_status}").into());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(evidence_context) = evidence_run {
|
if let Some(evidence_context) = evidence_run {
|
||||||
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
|
let restarted_pid = cluster.nodes[1].process.as_ref().ok_or("restarted target is absent")?.id();
|
||||||
@@ -1651,13 +1907,22 @@ mod tests {
|
|||||||
"server build changed during restart"
|
"server build changed during restart"
|
||||||
);
|
);
|
||||||
let evidence = serde_json::json!({
|
let evidence = serde_json::json!({
|
||||||
"schema": 1, "case": evidence_context.case.id, "evidence": "process-restart",
|
"schema": 1, "case": evidence_context.case.id, "evidence": evidence_context.case.evidence,
|
||||||
"run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
|
"run_id": evidence_context.run.run_id, "source_revision": evidence_context.run.source_revision,
|
||||||
"test_build": compiled_test_identity(),
|
"test_build": compiled_test_identity(),
|
||||||
"binary_sha256": evidence_context.run.binary.sha256,
|
"binary_sha256": evidence_context.run.binary.sha256,
|
||||||
"test_binary_sha256": evidence_context.run.test_binary.sha256,
|
"test_binary_sha256": evidence_context.run.test_binary.sha256,
|
||||||
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
|
"topology": {"nodes": cluster.nodes.len(), "drives_per_node": cluster.nodes[0].data_dirs.len()},
|
||||||
|
"erasure_set_drive_count": erasure_set_drive_count,
|
||||||
|
"sets": topology.set_count(evidence_context.case.erasure_set_drive_count),
|
||||||
|
"pools": topology.pool_count(),
|
||||||
|
"outage_target_manifest_required": outage_target_manifest_required,
|
||||||
|
"distributed_ec_invalidation": true,
|
||||||
|
"peer_count": cluster.nodes.len(),
|
||||||
|
"same_window_remote_proof": true,
|
||||||
|
"all_peers_bound_to_generation_window": true,
|
||||||
"pid_before": target_pid, "pid_after": restarted_pid,
|
"pid_before": target_pid, "pid_after": restarted_pid,
|
||||||
|
"unclean_shutdown_marker": unclean_shutdown_marker_observed.unwrap_or(false),
|
||||||
"objects": evidence_objects, "node_listings": node_listings,
|
"objects": evidence_objects, "node_listings": node_listings,
|
||||||
});
|
});
|
||||||
let data = serde_json::to_vec(&evidence)?;
|
let data = serde_json::to_vec(&evidence)?;
|
||||||
|
|||||||
@@ -1676,6 +1676,8 @@ async fn four_node_inline_storage_and_get_boundaries() -> TestResult {
|
|||||||
let collector = OtlpMetricCollector::start().await?;
|
let collector = OtlpMetricCollector::start().await?;
|
||||||
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||||
configure_reader_metric_cluster(&mut cluster, &collector);
|
configure_reader_metric_cluster(&mut cluster, &collector);
|
||||||
|
// Inspect every disk only after the PUT rename fanout has drained.
|
||||||
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||||
cluster.start().await?;
|
cluster.start().await?;
|
||||||
|
|
||||||
for (state_index, state) in [VersionState::Unversioned, VersionState::Enabled, VersionState::Suspended]
|
for (state_index, state) in [VersionState::Unversioned, VersionState::Enabled, VersionState::Suspended]
|
||||||
@@ -2232,7 +2234,6 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
|
let bucket = format!("distributed-admission-{}", Uuid::new_v4().simple());
|
||||||
let prefix = "transition/distributed-admission/";
|
let prefix = "transition/distributed-admission/";
|
||||||
hot_client.create_bucket().bucket(&bucket).send().await?;
|
hot_client.create_bucket().bucket(&bucket).send().await?;
|
||||||
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
|
||||||
for index in 0u8..64 {
|
for index in 0u8..64 {
|
||||||
let key = format!("{prefix}object-{index:02}.bin");
|
let key = format!("{prefix}object-{index:02}.bin");
|
||||||
hot_client
|
hot_client
|
||||||
@@ -2243,6 +2244,7 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
put_lifecycle_with_transition_retry(&hot_client, &bucket, &tier_name).await?;
|
||||||
|
|
||||||
let (node0, node1) = tokio::join!(
|
let (node0, node1) = tokio::join!(
|
||||||
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
start_manual_transition_job_on_node(&hot, 0, &bucket, prefix, &tier_name, false, 64),
|
||||||
|
|||||||
@@ -59,6 +59,36 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
|
|
||||||
assert_eq!(put_response.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
assert_eq!(put_response.server_side_encryption(), Some(&ServerSideEncryption::Aes256));
|
||||||
|
|
||||||
|
// A missing key in the healthy store is a client error, unlike a store outage.
|
||||||
|
let missing_key_object = "test-missing-kms-key";
|
||||||
|
let missing_key_error = s3_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(missing_key_object)
|
||||||
|
.body(aws_sdk_s3::primitives::ByteStream::from_static(b"must not be published"))
|
||||||
|
.server_side_encryption(ServerSideEncryption::AwsKms)
|
||||||
|
.ssekms_key_id("rustfs-e2e-test-missing-key")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("an unknown key in a healthy Local KMS store must reject the write");
|
||||||
|
assert_eq!(missing_key_error.raw_response().map(|response| response.status().as_u16()), Some(400));
|
||||||
|
assert_eq!(
|
||||||
|
missing_key_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("KMS.NotFoundException")
|
||||||
|
);
|
||||||
|
let missing_key_absence = s3_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(TEST_BUCKET)
|
||||||
|
.key(missing_key_object)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("a write rejected by a missing KMS key must not publish an object");
|
||||||
|
assert_eq!(missing_key_absence.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
assert_eq!(
|
||||||
|
missing_key_absence.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
|
Some("NoSuchKey")
|
||||||
|
);
|
||||||
|
|
||||||
// Temporarily rename the key directory to simulate unavailability
|
// Temporarily rename the key directory to simulate unavailability
|
||||||
info!("🔧 Simulating key directory unavailability");
|
info!("🔧 Simulating key directory unavailability");
|
||||||
let backup_dir = format!("{}.backup", kms_env.kms_keys_dir);
|
let backup_dir = format!("{}.backup", kms_env.kms_keys_dir);
|
||||||
@@ -79,10 +109,10 @@ async fn test_kms_key_directory_unavailable() -> Result<(), Box<dyn std::error::
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
|
let unavailable_error = put_result2.expect_err("a missing Local KMS key directory must reject encrypted writes");
|
||||||
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
assert_eq!(unavailable_error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
unavailable_error.as_service_error().and_then(ProvideErrorMetadata::code),
|
||||||
Some("InternalError")
|
Some("ServiceUnavailable")
|
||||||
);
|
);
|
||||||
let unavailable_absence = s3_client
|
let unavailable_absence = s3_client
|
||||||
.get_object()
|
.get_object()
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod storage_metric_ownership_test;
|
||||||
|
|
||||||
mod reliant;
|
mod reliant;
|
||||||
mod storage_api;
|
mod storage_api;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
//! Regression coverage for anonymous access on multipart control APIs.
|
//! Regression coverage for anonymous access on multipart control APIs.
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||||
|
use crate::kms::common::LocalKMSTestEnvironment;
|
||||||
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
|
use async_compression::tokio::write::{BzEncoder, Lz4Encoder, XzEncoder};
|
||||||
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
use aws_sdk_s3::error::{ProvideErrorMetadata, SdkError};
|
||||||
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
|
use aws_sdk_s3::operation::head_object::HeadObjectOutput;
|
||||||
@@ -1465,10 +1466,10 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_s3() -> Result<(), B
|
|||||||
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
init_logging();
|
init_logging();
|
||||||
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
let mut kms_env = LocalKMSTestEnvironment::new().await?;
|
||||||
let master_key = local_sse_master_key_value();
|
let default_key_id = kms_env.start_rustfs_for_local_kms().await?;
|
||||||
env.start_rustfs_server_with_env(vec![], &[(LOCAL_SSE_MASTER_KEY_ENV, master_key.as_str())])
|
kms_env.wait_for_kms_ready().await?;
|
||||||
.await?;
|
let env = &kms_env.base_env;
|
||||||
|
|
||||||
let bucket = "anon-post-default-sse-kms";
|
let bucket = "anon-post-default-sse-kms";
|
||||||
let object_key = "post-default-sse-kms-object.txt";
|
let object_key = "post-default-sse-kms-object.txt";
|
||||||
@@ -1484,7 +1485,7 @@ async fn test_anonymous_post_object_uses_bucket_default_sse_kms() -> Result<(),
|
|||||||
.apply_server_side_encryption_by_default(
|
.apply_server_side_encryption_by_default(
|
||||||
ServerSideEncryptionByDefault::builder()
|
ServerSideEncryptionByDefault::builder()
|
||||||
.sse_algorithm(ServerSideEncryption::AwsKms)
|
.sse_algorithm(ServerSideEncryption::AwsKms)
|
||||||
.kms_master_key_id("test-key")
|
.kms_master_key_id(default_key_id)
|
||||||
.build()
|
.build()
|
||||||
.expect("default encryption rule should build"),
|
.expect("default encryption rule should build"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use aws_sdk_s3::Client;
|
|||||||
use aws_sdk_s3::error::SdkError;
|
use aws_sdk_s3::error::SdkError;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::Barrier;
|
use tokio::sync::Barrier;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
@@ -25,6 +26,307 @@ const KEY: &str = "thumb/79/concurrent-overwrite.jpg";
|
|||||||
|
|
||||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
async fn assert_degraded_cluster_publication_guard_errors_are_retryable() -> TestResult {
|
||||||
|
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
|
||||||
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||||
|
cluster.set_env("RUST_LOG", "warn");
|
||||||
|
cluster.start().await?;
|
||||||
|
cluster.create_test_bucket(BUCKET).await?;
|
||||||
|
let clients: Vec<_> = cluster
|
||||||
|
.create_all_clients()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|client| {
|
||||||
|
Client::from_conf(
|
||||||
|
client
|
||||||
|
.config()
|
||||||
|
.to_builder()
|
||||||
|
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for alive in (1..=4).rev() {
|
||||||
|
if alive < 4 {
|
||||||
|
cluster.stop_node(alive)?;
|
||||||
|
}
|
||||||
|
for (node, client) in clients.iter().take(alive).enumerate() {
|
||||||
|
let key = format!("publication-put-{alive}-{node}");
|
||||||
|
let put = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(&key)
|
||||||
|
.body(Bytes::from_static(b"publication guard regression").into())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
if alive >= 3 {
|
||||||
|
put?;
|
||||||
|
} else {
|
||||||
|
let err = put.expect_err("PUT must reject writes without a write quorum");
|
||||||
|
assert_eq!(
|
||||||
|
err.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(503),
|
||||||
|
"PUT with {alive} nodes alive, requested through node {node}: {err:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
err.as_service_error().and_then(|error| error.meta().code()),
|
||||||
|
Some("ServiceUnavailable"),
|
||||||
|
"PUT with {alive} nodes alive, requested through node {node}: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let multipart_key = format!("publication-multipart-{alive}-{node}");
|
||||||
|
let multipart = client
|
||||||
|
.create_multipart_upload()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(&multipart_key)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
if alive >= 3 {
|
||||||
|
let upload = multipart?;
|
||||||
|
let upload_id = upload
|
||||||
|
.upload_id()
|
||||||
|
.expect("successful multipart initialization must return an upload ID");
|
||||||
|
client
|
||||||
|
.abort_multipart_upload()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(&multipart_key)
|
||||||
|
.upload_id(upload_id)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
let err = multipart.expect_err("multipart initialization must reject writes without a write quorum");
|
||||||
|
assert_eq!(
|
||||||
|
err.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(503),
|
||||||
|
"CreateMultipartUpload with {alive} nodes alive, requested through node {node}: {err:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
err.as_service_error().and_then(|error| error.meta().code()),
|
||||||
|
Some("ServiceUnavailable"),
|
||||||
|
"CreateMultipartUpload with {alive} nodes alive, requested through node {node}: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster.stop();
|
||||||
|
cluster.start().await?;
|
||||||
|
for client in &clients {
|
||||||
|
for alive in [1, 3, 4] {
|
||||||
|
for node in 0..alive {
|
||||||
|
let key = format!("publication-put-{alive}-{node}");
|
||||||
|
let get = client.get_object().bucket(BUCKET).key(key).send().await;
|
||||||
|
if alive >= 3 {
|
||||||
|
assert_eq!(
|
||||||
|
get?.body.collect().await?.into_bytes().as_ref(),
|
||||||
|
b"publication guard regression",
|
||||||
|
"acknowledged writes must survive restart"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let err = get.expect_err("a rejected publication guard must not publish an object");
|
||||||
|
assert_eq!(err.as_service_error().and_then(|error| error.meta().code()), Some("NoSuchKey"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let uploads = client.list_multipart_uploads().bucket(BUCKET).send().await?;
|
||||||
|
assert!(
|
||||||
|
uploads
|
||||||
|
.uploads()
|
||||||
|
.iter()
|
||||||
|
.all(|upload| upload.key() != Some("publication-multipart-1-0")),
|
||||||
|
"a rejected publication guard must not publish a multipart upload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_quorum_object_body(client: &Client, bucket: &str, key: &str, expected: &[u8]) -> TestResult {
|
||||||
|
let body = client
|
||||||
|
.get_object()
|
||||||
|
.bucket(bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.body
|
||||||
|
.collect()
|
||||||
|
.await?
|
||||||
|
.into_bytes();
|
||||||
|
assert_eq!(body.as_ref(), expected, "quorum read returned incorrect contents for {key}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_quorum_read_admission(clients: &[Client], bucket: &str) -> TestResult {
|
||||||
|
// SIGKILL can orphan a granted lease. Wait for shared metadata-lock
|
||||||
|
// admission before asserting the stable quorum boundary; cold bodies
|
||||||
|
// remain unread throughout this readiness probe.
|
||||||
|
let deadline =
|
||||||
|
tokio::time::Instant::now() + rustfs_lock::fast_lock::DEFAULT_LOCK_TIMEOUT + std::time::Duration::from_secs(15);
|
||||||
|
loop {
|
||||||
|
let mut ready = true;
|
||||||
|
for client in clients {
|
||||||
|
for key in ["warm-small", "warm-large"] {
|
||||||
|
match client.head_object().bucket(bucket).key(key).send().await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(error) if error.raw_response().is_some_and(|response| response.status().as_u16() == 503) => {
|
||||||
|
ready = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ready {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ready {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!("read quorum did not become available after lease convergence for {bucket}").into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_degraded_cluster_read_quorum_follows_erasure_layout() -> TestResult {
|
||||||
|
crate::common::init_logging();
|
||||||
|
|
||||||
|
for (node_count, parity) in [(4, 2), (6, 3), (6, 2)] {
|
||||||
|
let read_quorum = node_count - parity;
|
||||||
|
let write_quorum = read_quorum + usize::from(read_quorum == parity);
|
||||||
|
let mut cluster = RustFSTestClusterEnvironment::new(node_count).await?;
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", format!("EC:{parity}"));
|
||||||
|
// Wait for every seed fanout before removing any physical shard.
|
||||||
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "false");
|
||||||
|
cluster.set_env("RUST_LOG", "warn,rustfs_lock=debug");
|
||||||
|
cluster.start().await?;
|
||||||
|
|
||||||
|
let clients = cluster
|
||||||
|
.create_all_clients()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|client| {
|
||||||
|
Client::from_conf(
|
||||||
|
client
|
||||||
|
.config()
|
||||||
|
.to_builder()
|
||||||
|
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let bucket = format!("read-quorum-{node_count}-{parity}");
|
||||||
|
clients[0].create_bucket().bucket(&bucket).send().await?;
|
||||||
|
let small = b"read quorum is derived from the erasure layout".to_vec();
|
||||||
|
let large = (0..1_048_576)
|
||||||
|
.map(|index| u8::try_from(index % 251).expect("bounded payload byte"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for (key, body) in [
|
||||||
|
("warm-small", &small),
|
||||||
|
("warm-large", &large),
|
||||||
|
("cold-small", &small),
|
||||||
|
("cold-large", &large),
|
||||||
|
("below-quorum", &large),
|
||||||
|
] {
|
||||||
|
clients[node_count - 1]
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(Bytes::copy_from_slice(body).into())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
for node in &cluster.nodes {
|
||||||
|
for key in ["warm-small", "warm-large", "cold-small", "cold-large", "below-quorum"] {
|
||||||
|
let census =
|
||||||
|
crate::chaos::census_object_version_on_disk(std::path::Path::new(&node.data_dir), &bucket, key, None)?;
|
||||||
|
assert!(census.is_complete(), "seed shard must be complete before fault injection: {census:?}");
|
||||||
|
assert_eq!(census.data_blocks, Some(read_quorum));
|
||||||
|
assert_eq!(census.parity_blocks, Some(parity));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for client in &clients {
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-small", &small).await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for offline_node in (read_quorum..node_count).rev() {
|
||||||
|
cluster.stop_node(offline_node)?;
|
||||||
|
wait_for_quorum_read_admission(&clients[..offline_node], &bucket).await?;
|
||||||
|
for client in clients.iter().take(offline_node) {
|
||||||
|
client.head_bucket().bucket(&bucket).send().await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise more than the five-second positive bucket-validation TTL.
|
||||||
|
// Every sample must succeed; polling must not hide a transient failure.
|
||||||
|
let validation_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(6);
|
||||||
|
loop {
|
||||||
|
for client in clients.iter().take(read_quorum) {
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-small", &small).await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
|
||||||
|
let listing = client.list_objects_v2().bucket(&bucket).send().await?;
|
||||||
|
for key in ["warm-small", "warm-large", "cold-small", "cold-large", "below-quorum"] {
|
||||||
|
assert!(listing.contents().iter().any(|entry| entry.key() == Some(key)), "listing omitted {key}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= validation_deadline {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
for client in clients.iter().take(read_quorum) {
|
||||||
|
assert_quorum_object_body(client, &bucket, "cold-small", &small).await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "cold-large", &large).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let write = clients[0]
|
||||||
|
.put_object()
|
||||||
|
.bucket(&bucket)
|
||||||
|
.key("quorum-write")
|
||||||
|
.body(Bytes::copy_from_slice(&small).into())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
if read_quorum >= write_quorum {
|
||||||
|
write?;
|
||||||
|
} else {
|
||||||
|
let error = write.expect_err("a read quorum must not authorize a write that needs more votes");
|
||||||
|
assert_eq!(error.as_service_error().and_then(|error| error.meta().code()), Some("ServiceUnavailable"));
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster.stop_node(read_quorum - 1)?;
|
||||||
|
for client in clients.iter().take(read_quorum - 1) {
|
||||||
|
match client.get_object().bucket(&bucket).key("below-quorum").send().await {
|
||||||
|
Ok(response) => assert!(
|
||||||
|
response.body.collect().await.is_err(),
|
||||||
|
"fewer than {read_quorum} valid fragments must not reconstruct an uncached object"
|
||||||
|
),
|
||||||
|
Err(error) => assert_eq!(
|
||||||
|
error.as_service_error().and_then(|error| error.meta().code()),
|
||||||
|
Some("ServiceUnavailable"),
|
||||||
|
"a quorum loss must not be mistaken for a missing object"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for node in 0..read_quorum - 1 {
|
||||||
|
cluster.stop_node(node)?;
|
||||||
|
}
|
||||||
|
cluster.start().await?;
|
||||||
|
for client in &clients {
|
||||||
|
assert_quorum_object_body(client, &bucket, "warm-large", &large).await?;
|
||||||
|
assert_quorum_object_body(client, &bucket, "below-quorum", &large).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn put_object(client: Client, payload: Vec<u8>, writer_id: usize) -> Result<(), String> {
|
async fn put_object(client: Client, payload: Vec<u8>, writer_id: usize) -> Result<(), String> {
|
||||||
client
|
client
|
||||||
.put_object()
|
.put_object()
|
||||||
@@ -58,6 +360,9 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
|
|||||||
// Keep the regression focused on false quorum-loss errors, not ordinary lock
|
// Keep the regression focused on false quorum-loss errors, not ordinary lock
|
||||||
// wait exhaustion under a heavily contended same-key overwrite workload.
|
// wait exhaustion under a heavily contended same-key overwrite workload.
|
||||||
cluster.set_env("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "20");
|
cluster.set_env("RUSTFS_OBJECT_LOCK_ACQUIRE_TIMEOUT", "20");
|
||||||
|
cluster.set_env("RUSTFS_STORAGE_CLASS_STANDARD", "EC:2");
|
||||||
|
cluster.set_env("RUSTFS_PUT_RENAME_EARLY_ACK_ENABLE", "false");
|
||||||
|
cluster.set_env("RUSTFS_HEALTH_MINIMAL_RESPONSE_ENABLE", "false");
|
||||||
cluster.start().await?;
|
cluster.start().await?;
|
||||||
cluster.create_test_bucket(BUCKET).await?;
|
cluster.create_test_bucket(BUCKET).await?;
|
||||||
|
|
||||||
@@ -116,6 +421,181 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
|
|||||||
);
|
);
|
||||||
|
|
||||||
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
|
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
|
||||||
|
assert_node_readiness_tracks_quorum(&mut cluster).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assert_node_readiness_tracks_quorum(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
|
||||||
|
let clients: Vec<_> = cluster
|
||||||
|
.create_all_clients()?
|
||||||
|
.into_iter()
|
||||||
|
.map(|client| {
|
||||||
|
Client::from_conf(
|
||||||
|
client
|
||||||
|
.config()
|
||||||
|
.to_builder()
|
||||||
|
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(Duration::from_secs(3))
|
||||||
|
.build()?;
|
||||||
|
let seed_key = "readiness-seed";
|
||||||
|
let seed_body = b"readiness quorum regression";
|
||||||
|
clients[0]
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(seed_key)
|
||||||
|
.body(Bytes::from_static(seed_body).into())
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (phase, survivors) in [4, 3, 2, 1, 4].into_iter().enumerate() {
|
||||||
|
if phase == 4 {
|
||||||
|
cluster.stop();
|
||||||
|
cluster.start().await?;
|
||||||
|
} else if survivors < 4 {
|
||||||
|
cluster.stop_node(survivors)?;
|
||||||
|
}
|
||||||
|
let write_ready = survivors >= 3;
|
||||||
|
let read_quorum = survivors >= 2;
|
||||||
|
let expected_status = if write_ready { 200 } else { 503 };
|
||||||
|
for (idx, client) in clients.iter().enumerate().take(survivors) {
|
||||||
|
let url = &cluster.nodes[idx].url;
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
// Poll health before issuing S3 I/O: idle remote disk handles must
|
||||||
|
// not remain evidence of quorum after their host becomes unreachable.
|
||||||
|
let payload = loop {
|
||||||
|
let response = http.get(format!("{url}/health/ready")).send().await?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let payload: serde_json::Value = response.json().await?;
|
||||||
|
if status == expected_status
|
||||||
|
&& payload["ready"] == write_ready
|
||||||
|
&& payload["details"]["storage"]["ready"] == write_ready
|
||||||
|
&& payload["details"]["storage"]["readQuorum"] == read_quorum
|
||||||
|
&& payload["details"]["storage"]["writeQuorum"] == write_ready
|
||||||
|
&& payload["details"]["poolMetadata"]["ready"] == true
|
||||||
|
&& payload["details"]["iam"]["ready"] == true
|
||||||
|
&& payload["details"]["lock"]["ready"] == write_ready
|
||||||
|
{
|
||||||
|
break payload;
|
||||||
|
}
|
||||||
|
assert!(Instant::now() < deadline, "node {idx}, survivors={survivors}: HTTP {status}, {payload}");
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
};
|
||||||
|
assert_eq!(payload["details"]["storage"]["readinessScope"], "write_quorum_and_pool_metadata");
|
||||||
|
assert_eq!(payload["details"]["storage"]["source"], "local_runtime");
|
||||||
|
assert_eq!(
|
||||||
|
payload["details"]["storage"]["status"],
|
||||||
|
if write_ready { "connected" } else { "disconnected" }
|
||||||
|
);
|
||||||
|
if !write_ready {
|
||||||
|
assert!(
|
||||||
|
payload["degradedReasons"]
|
||||||
|
.as_array()
|
||||||
|
.expect("degraded reasons")
|
||||||
|
.iter()
|
||||||
|
.any(|reason| reason == "storage_and_lock_unavailable")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in ["/health/ready", "/minio/health/ready"] {
|
||||||
|
let head = http.head(format!("{url}{path}")).send().await?;
|
||||||
|
assert_eq!(head.status().as_u16(), expected_status, "HEAD {path}, survivors={survivors}");
|
||||||
|
assert!(head.bytes().await?.is_empty());
|
||||||
|
let response = http.get(format!("{url}{path}")).send().await?;
|
||||||
|
assert_eq!(response.status().as_u16(), expected_status);
|
||||||
|
let body: serde_json::Value = response.json().await?;
|
||||||
|
assert_eq!(body["details"]["storage"], payload["details"]["storage"]);
|
||||||
|
assert_eq!(body["details"]["poolMetadata"], payload["details"]["poolMetadata"]);
|
||||||
|
}
|
||||||
|
let live = http.get(format!("{url}/health/live")).send().await?;
|
||||||
|
assert_eq!(live.status().as_u16(), 200);
|
||||||
|
assert!(live.json::<serde_json::Value>().await?.get("details").is_none());
|
||||||
|
for (path, storage_ready, scope) in [
|
||||||
|
("/minio/health/cluster", write_ready, "write_quorum_and_pool_metadata"),
|
||||||
|
("/minio/health/cluster/read", read_quorum, "read_quorum"),
|
||||||
|
] {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(30);
|
||||||
|
// Cluster read/write reports have independent caches; allow
|
||||||
|
// each observation to expire before comparing stable states.
|
||||||
|
let body = loop {
|
||||||
|
let response = http.get(format!("{url}{path}")).send().await?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body: serde_json::Value = response.json().await?;
|
||||||
|
if status == expected_status
|
||||||
|
&& body["details"]["storage"]["ready"] == storage_ready
|
||||||
|
&& body["details"]["lock"]["ready"] == write_ready
|
||||||
|
{
|
||||||
|
break body;
|
||||||
|
}
|
||||||
|
assert!(Instant::now() < deadline, "{path}, survivors={survivors}: HTTP {status}, {body}");
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
};
|
||||||
|
assert_eq!(body["details"]["storage"]["readinessScope"], scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
let put = client
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(format!("readiness-phase-{phase}-node-{idx}"))
|
||||||
|
.body(Bytes::from_static(seed_body).into())
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
let put_status = if write_ready {
|
||||||
|
put.expect("a ready node must accept the PUT");
|
||||||
|
200
|
||||||
|
} else {
|
||||||
|
let error = put.expect_err("subquorum node must reject PUT");
|
||||||
|
assert!(
|
||||||
|
error.raw_response().is_some_and(|response| response.status().as_u16() >= 500),
|
||||||
|
"unexpected PUT failure: {error:?}"
|
||||||
|
);
|
||||||
|
error.raw_response().expect("PUT error response").status().as_u16()
|
||||||
|
};
|
||||||
|
let get = client.get_object().bucket(BUCKET).key(seed_key).send().await;
|
||||||
|
let get_status = match get {
|
||||||
|
Ok(object) => {
|
||||||
|
assert_eq!(object.body.collect().await?.into_bytes().as_ref(), seed_body);
|
||||||
|
200
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
assert!(!write_ready, "GET must succeed on a ready cluster: {error:?}");
|
||||||
|
let status = error
|
||||||
|
.raw_response()
|
||||||
|
.expect("GET should have an HTTP response")
|
||||||
|
.status()
|
||||||
|
.as_u16();
|
||||||
|
assert!(status >= 500, "unexpected GET failure: {error:?}");
|
||||||
|
status
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let list = client.list_objects_v2().bucket(BUCKET).send().await;
|
||||||
|
let list_status = match list {
|
||||||
|
Ok(result) => {
|
||||||
|
assert!(result.contents().iter().any(|object| object.key() == Some(seed_key)));
|
||||||
|
200
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
assert!(!write_ready, "listing must succeed on a ready cluster: {error:?}");
|
||||||
|
let status = error
|
||||||
|
.raw_response()
|
||||||
|
.expect("LIST should have an HTTP response")
|
||||||
|
.status()
|
||||||
|
.as_u16();
|
||||||
|
assert!(status >= 500, "unexpected listing failure: {error:?}");
|
||||||
|
status
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"readiness matrix: survivors={survivors}, node={idx}, ready={write_ready}, read_quorum={read_quorum}, PUT={put_status}, GET={get_status}, LIST={list_status}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cluster.stop();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +605,8 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
|
|||||||
/// Before the fix, `map_namespace_lock_error` wrapped lock timeout/conflict errors as
|
/// Before the fix, `map_namespace_lock_error` wrapped lock timeout/conflict errors as
|
||||||
/// `StorageError::other(...)` → `StorageError::Io(...)`, which fell through to
|
/// `StorageError::other(...)` → `StorageError::Io(...)`, which fell through to
|
||||||
/// `S3ErrorCode::InternalError` (500) in the error mapping.
|
/// `S3ErrorCode::InternalError` (500) in the error mapping.
|
||||||
|
/// Also checks PUT and multipart initialization when node failures prevent
|
||||||
|
/// acquiring a table publication guard.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_concurrent_put_same_key_never_returns_500() -> TestResult {
|
async fn test_concurrent_put_same_key_never_returns_500() -> TestResult {
|
||||||
crate::common::init_logging();
|
crate::common::init_logging();
|
||||||
@@ -227,5 +709,6 @@ async fn test_concurrent_put_same_key_never_returns_500() -> TestResult {
|
|||||||
);
|
);
|
||||||
|
|
||||||
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
|
clients[0].delete_object().bucket(BUCKET).key(KEY).send().await?;
|
||||||
Ok(())
|
cluster.stop();
|
||||||
|
assert_degraded_cluster_publication_guard_errors_are_retryable().await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1137,7 +1137,12 @@ async fn test_odm_admin_config_is_redacted_and_status_counts_match_the_source()
|
|||||||
let miss = env.raw_get(bucket, miss_key).await?;
|
let miss = env.raw_get(bucket, miss_key).await?;
|
||||||
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
assert_eq!(miss.status, 404, "{}", String::from_utf8_lossy(&miss.body));
|
||||||
}
|
}
|
||||||
assert!(env.wait_local_listed(bucket, hit_key, SETTLE).await?);
|
let (listed, _, _) = tokio::try_join!(
|
||||||
|
env.wait_local_listed(bucket, hit_key, SETTLE),
|
||||||
|
env.wait_for_status_counter(bucket, "/counters/pulled_objects_total/inline", 1, SETTLE),
|
||||||
|
env.wait_for_status_counter(bucket, "/counters/pulled_bytes_total", body.len() as u64, SETTLE),
|
||||||
|
)?;
|
||||||
|
assert!(listed);
|
||||||
|
|
||||||
let status = env.status_json(bucket).await?;
|
let status = env.status_json(bucket).await?;
|
||||||
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
assert_eq!(status.pointer("/configured").and_then(Value::as_bool), Some(true), "{status}");
|
||||||
|
|||||||
@@ -356,3 +356,238 @@ async fn tampered_presigned_put_returns_signature_does_not_match() -> Result<(),
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GHSA-g8w9-qw9q-fghr: a presigned PUT signed with `SignedHeaders=host` must
|
||||||
|
/// not honour `x-amz-*` headers the uploader adds afterwards. The presign
|
||||||
|
/// authorised one plain upload; the extra headers would set tags, storage
|
||||||
|
/// class and a website redirect the presigner never covered. AWS S3 rejects
|
||||||
|
/// this with 403 `AccessDenied`, and so must RustFS — and the object must not
|
||||||
|
/// be stored at all, not merely stored without the properties.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ghsa_g8w9_presigned_put_rejects_unsigned_x_amz_headers() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
setup(&mut env).await?;
|
||||||
|
|
||||||
|
let key = "presigned-put-unsigned-amz-headers.txt";
|
||||||
|
let pr = env
|
||||||
|
.create_s3_client()
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.presigned(valid_config())
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
!pr.headers().any(|(name, _)| name.eq_ignore_ascii_case("x-amz-tagging")),
|
||||||
|
"fixture must presign a plain PutObject without tagging so the header below is unsigned"
|
||||||
|
);
|
||||||
|
|
||||||
|
let unsigned: Vec<(&str, &str)> = vec![
|
||||||
|
("x-amz-tagging", "owner=attacker&classification=public"),
|
||||||
|
("x-amz-website-redirect-location", "https://attacker.example/phish"),
|
||||||
|
("x-amz-storage-class", "REDUCED_REDUNDANCY"),
|
||||||
|
];
|
||||||
|
let headers = pr.headers().chain(unsigned.iter().copied());
|
||||||
|
let resp = send_raw(pr.method(), pr.uri(), headers, Some(b"should-not-be-stored".to_vec())).await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await?;
|
||||||
|
assert_eq!(
|
||||||
|
status.as_u16(),
|
||||||
|
403,
|
||||||
|
"presigned PUT with unsigned x-amz-* headers must be 403, body:\n{body}"
|
||||||
|
);
|
||||||
|
assert_error_code(&body, "AccessDenied");
|
||||||
|
assert!(
|
||||||
|
body.contains("were not signed"),
|
||||||
|
"rejection must name unsigned headers as the cause, got:\n{body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = env
|
||||||
|
.create_s3_client()
|
||||||
|
.head_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("presigned PUT with unsigned x-amz-* headers must not store the object");
|
||||||
|
assert_eq!(
|
||||||
|
error.raw_response().map(|response| response.status().as_u16()),
|
||||||
|
Some(404),
|
||||||
|
"absence probe after the rejected upload must return HTTP 404, got {error:?}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GHSA-g8w9-qw9q-fghr positive control: when the presigner itself sets the
|
||||||
|
/// property, the SDK lists `x-amz-tagging` in `SignedHeaders`, the uploader
|
||||||
|
/// replays it, and the upload succeeds with the tags applied. Without this the
|
||||||
|
/// negative test above could pass because the server rejects every tagged
|
||||||
|
/// presigned upload.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ghsa_g8w9_presigned_put_accepts_signed_x_amz_headers() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
setup(&mut env).await?;
|
||||||
|
|
||||||
|
let key = "presigned-put-signed-tagging.txt";
|
||||||
|
let pr = env
|
||||||
|
.create_s3_client()
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.tagging("owner=app")
|
||||||
|
.presigned(valid_config())
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
pr.headers().any(|(name, _)| name.eq_ignore_ascii_case("x-amz-tagging")),
|
||||||
|
"fixture must carry x-amz-tagging as a signed header"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
pr.uri().contains("x-amz-tagging"),
|
||||||
|
"X-Amz-SignedHeaders must list x-amz-tagging, uri: {}",
|
||||||
|
pr.uri()
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = send_presigned(&pr, Some(b"stored-with-signed-tagging".to_vec())).await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await?;
|
||||||
|
assert!(
|
||||||
|
status.is_success(),
|
||||||
|
"presigned PUT with signed x-amz-tagging must succeed, got {status}, body:\n{body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let tags = env
|
||||||
|
.create_s3_client()
|
||||||
|
.get_object_tagging()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let tag_set: Vec<(String, String)> = tags
|
||||||
|
.tag_set()
|
||||||
|
.iter()
|
||||||
|
.map(|tag| (tag.key().to_string(), tag.value().to_string()))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(tag_set, vec![("owner".to_string(), "app".to_string())], "signed tagging must be applied");
|
||||||
|
info!("signed presigned tagging control passed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GHSA-g8w9-qw9q-fghr on the read side: a presigned GET signed with
|
||||||
|
/// `SignedHeaders=host` must not accept an unsigned SSE-C header. The header
|
||||||
|
/// would otherwise select a decryption path the presigner never authorised.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ghsa_g8w9_presigned_get_rejects_unsigned_x_amz_headers() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
setup(&mut env).await?;
|
||||||
|
|
||||||
|
let pr = env
|
||||||
|
.create_s3_client()
|
||||||
|
.get_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(CANONICAL_KEY)
|
||||||
|
.presigned(valid_config())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let unsigned: Vec<(&str, &str)> = vec![("x-amz-server-side-encryption-customer-algorithm", "AES256")];
|
||||||
|
let headers = pr.headers().chain(unsigned.iter().copied());
|
||||||
|
let resp = send_raw(pr.method(), pr.uri(), headers, None).await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await?;
|
||||||
|
assert_eq!(
|
||||||
|
status.as_u16(),
|
||||||
|
403,
|
||||||
|
"presigned GET with an unsigned x-amz-* header must be 403, body:\n{body}"
|
||||||
|
);
|
||||||
|
assert_error_code(&body, "AccessDenied");
|
||||||
|
assert!(
|
||||||
|
!body.contains(std::str::from_utf8(CANONICAL_BODY)?),
|
||||||
|
"rejected GET must not leak the object body"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GHSA-g8w9-qw9q-fghr: an unsigned `x-amz-copy-source` would turn a presigned
|
||||||
|
/// PutObject into a CopyObject of an arbitrary readable key, since operation
|
||||||
|
/// routing happens before authorization. The presigned upload must fail and
|
||||||
|
/// leave nothing behind.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ghsa_g8w9_presigned_put_rejects_unsigned_copy_source() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
setup(&mut env).await?;
|
||||||
|
|
||||||
|
let key = "presigned-put-unsigned-copy-source.txt";
|
||||||
|
let pr = env
|
||||||
|
.create_s3_client()
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.presigned(valid_config())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let copy_source = format!("/{BUCKET}/{CANONICAL_KEY}");
|
||||||
|
let unsigned: Vec<(&str, &str)> = vec![("x-amz-copy-source", copy_source.as_str())];
|
||||||
|
let headers = pr.headers().chain(unsigned.iter().copied());
|
||||||
|
let resp = send_raw(pr.method(), pr.uri(), headers, None).await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await?;
|
||||||
|
assert_eq!(
|
||||||
|
status.as_u16(),
|
||||||
|
403,
|
||||||
|
"presigned PUT with an unsigned copy source must be 403, body:\n{body}"
|
||||||
|
);
|
||||||
|
assert_error_code(&body, "AccessDenied");
|
||||||
|
|
||||||
|
let error = env
|
||||||
|
.create_s3_client()
|
||||||
|
.head_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect_err("rejected copy must not create the destination object");
|
||||||
|
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GHSA-g8w9-qw9q-fghr boundary control: the rule covers `x-amz-*` only. A
|
||||||
|
/// plain `Content-Type` on a `SignedHeaders=host` presigned PUT is outside
|
||||||
|
/// SigV4's signed-header requirement (AWS S3 accepts it too) and must keep
|
||||||
|
/// working, so the negative tests above cannot pass by rejecting every
|
||||||
|
/// unsigned header.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn ghsa_g8w9_presigned_put_still_accepts_unsigned_non_amz_headers() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||||
|
{
|
||||||
|
init_logging();
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
setup(&mut env).await?;
|
||||||
|
|
||||||
|
let key = "presigned-put-unsigned-content-type.txt";
|
||||||
|
let pr = env
|
||||||
|
.create_s3_client()
|
||||||
|
.put_object()
|
||||||
|
.bucket(BUCKET)
|
||||||
|
.key(key)
|
||||||
|
.presigned(valid_config())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let unsigned: Vec<(&str, &str)> = vec![("content-type", "text/x-rustfs-test")];
|
||||||
|
let headers = pr.headers().chain(unsigned.iter().copied());
|
||||||
|
let resp = send_raw(pr.method(), pr.uri(), headers, Some(b"plain-header-upload".to_vec())).await?;
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await?;
|
||||||
|
assert!(
|
||||||
|
status.is_success(),
|
||||||
|
"presigned PUT with an unsigned Content-Type must succeed, got {status}, body:\n{body}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let head = env.create_s3_client().head_object().bucket(BUCKET).key(key).send().await?;
|
||||||
|
assert_eq!(
|
||||||
|
head.content_type(),
|
||||||
|
Some("text/x-rustfs-test"),
|
||||||
|
"unsigned Content-Type must still be applied"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -4235,6 +4235,16 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
|||||||
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||||
</Rule>
|
</Rule>
|
||||||
|
<Rule>
|
||||||
|
<ID>matrix-and-tags</ID>
|
||||||
|
<Priority>135</Priority>
|
||||||
|
<Status>Enabled</Status>
|
||||||
|
<Filter><And><Prefix>and-tags/</Prefix><Tag><Key>env</Key><Value>prod</Value></Tag><Tag><Key>tier</Key><Value>gold</Value></Tag></And></Filter>
|
||||||
|
<DeleteMarkerReplication><Status>Disabled</Status></DeleteMarkerReplication>
|
||||||
|
<DeleteReplication><Status>Enabled</Status></DeleteReplication>
|
||||||
|
<ExistingObjectReplication><Status>Enabled</Status></ExistingObjectReplication>
|
||||||
|
<Destination><Bucket>{target_b_arn}</Bucket></Destination>
|
||||||
|
</Rule>
|
||||||
<Rule>
|
<Rule>
|
||||||
<ID>matrix-disabled</ID>
|
<ID>matrix-disabled</ID>
|
||||||
<Priority>140</Priority>
|
<Priority>140</Priority>
|
||||||
@@ -4289,6 +4299,7 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
|||||||
"matrix-prefix",
|
"matrix-prefix",
|
||||||
"matrix-tag",
|
"matrix-tag",
|
||||||
"matrix-disabled",
|
"matrix-disabled",
|
||||||
|
"matrix-and-tags",
|
||||||
"matrix-priority-high",
|
"matrix-priority-high",
|
||||||
"Priority>200",
|
"Priority>200",
|
||||||
"<Status>Disabled</Status>",
|
"<Status>Disabled</Status>",
|
||||||
@@ -4409,6 +4420,30 @@ async fn test_bucket_replication_acceptance_matrix_local_dual_targets() -> TestR
|
|||||||
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
put_single_tag_current(&source_client, source_bucket, "tagged/no-match.txt", "route", "tagged").await?;
|
||||||
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
assert_replication_key_absent(&target_client_b, target_bucket_b, "tagged/no-match.txt", Duration::from_secs(3)).await?;
|
||||||
|
|
||||||
|
// S3 and MinIO both read `And.Tags` as AND: an object carrying only one of
|
||||||
|
// the required tags is not admitted. Matching any single tag would push
|
||||||
|
// data to a destination the rule never selected (backlog#2366 P1-1), and
|
||||||
|
// the two-tag rule is the shape `mc replicate add --tags "k1=v1&k2=v2"`
|
||||||
|
// writes, so a single-tag rule passing is not evidence for this.
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("and-tags/partial.txt")
|
||||||
|
.tagging("env=prod")
|
||||||
|
.body(ByteStream::from_static(b"one of two tags"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
assert_replication_key_absent(&target_client_b, target_bucket_b, "and-tags/partial.txt", Duration::from_secs(3)).await?;
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key("and-tags/full.txt")
|
||||||
|
.tagging("env=prod&tier=gold")
|
||||||
|
.body(ByteStream::from_static(b"both tags"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
wait_for_user_get_object(&target_client_b, target_bucket_b, "and-tags/full.txt").await?;
|
||||||
|
|
||||||
source_client
|
source_client
|
||||||
.put_object()
|
.put_object()
|
||||||
.bucket(source_bucket)
|
.bucket(source_bucket)
|
||||||
@@ -9055,9 +9090,11 @@ async fn test_replication_check_flags_multipart_only_version_minting_target() ->
|
|||||||
.is_some_and(|error| error.contains("CreateMultipartUpload")),
|
.is_some_and(|error| error.contains("CreateMultipartUpload")),
|
||||||
"the failure must name the multipart path: {payload}"
|
"the failure must name the multipart path: {payload}"
|
||||||
);
|
);
|
||||||
// The PutObject leg mirrored, so it is the multipart probe that failed.
|
// The PutObject leg mirrored, so it is the multipart probe that failed;
|
||||||
|
// the mutation phases address the id the PUT reported and still run.
|
||||||
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
|
assert_eq!(target_report["Phases"]["Put"]["Status"], "OK", "{payload}");
|
||||||
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "SKIPPED", "{payload}");
|
assert_eq!(target_report["Phases"]["DeleteMarker"]["Status"], "OK", "{payload}");
|
||||||
|
assert_eq!(target_report["Phases"]["VersionDelete"]["Status"], "OK", "{payload}");
|
||||||
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
|
assert_eq!(target_report["Phases"]["Cleanup"]["Status"], "OK", "{payload}");
|
||||||
|
|
||||||
let probe_key = target
|
let probe_key = target
|
||||||
@@ -9245,6 +9282,9 @@ async fn test_replication_check_flags_version_minting_target() -> TestResult {
|
|||||||
let target_bucket = "version-fidelity-dst";
|
let target_bucket = "version-fidelity-dst";
|
||||||
target.create_bucket(target_bucket);
|
target.create_bucket(target_bucket);
|
||||||
target.assign_own_version_ids(true);
|
target.assign_own_version_ids(true);
|
||||||
|
// Wasabi shape: the probe version the VersionDelete phase removed answers
|
||||||
|
// NoSuchVersion to cleanup's second DELETE, which must count as clean.
|
||||||
|
target.reject_unknown_version_deletes(true);
|
||||||
|
|
||||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||||
let mut env_vars = replication_fast_env();
|
let mut env_vars = replication_fast_env();
|
||||||
@@ -10185,3 +10225,199 @@ async fn test_get_object_tagging_proxies_unreplicated_object_to_replication_targ
|
|||||||
target.shutdown().await;
|
target.shutdown().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// backlog#2363
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Wait until the source reports a terminal replication status for `key`.
|
||||||
|
async fn wait_terminal_replication_status(
|
||||||
|
client: &Client,
|
||||||
|
bucket: &str,
|
||||||
|
key: &str,
|
||||||
|
ssec: bool,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||||
|
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||||
|
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
let request = client.head_object().bucket(bucket).key(key);
|
||||||
|
let head = if ssec {
|
||||||
|
request
|
||||||
|
.sse_customer_algorithm("AES256")
|
||||||
|
.sse_customer_key(&customer_key)
|
||||||
|
.sse_customer_key_md5(&customer_key_md5)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
request.send().await?
|
||||||
|
};
|
||||||
|
let status = head.replication_status().map(|status| status.as_str().to_string());
|
||||||
|
if matches!(status.as_deref(), Some("COMPLETED") | Some("FAILED")) {
|
||||||
|
return Ok(status.unwrap_or_default());
|
||||||
|
}
|
||||||
|
if tokio::time::Instant::now() >= deadline {
|
||||||
|
return Err(format!("{bucket}/{key}: replication never reached a terminal status; last {status:?}").into());
|
||||||
|
}
|
||||||
|
sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// backlog#2363: SSE-C ciphertext passthrough of objects the source stored
|
||||||
|
/// compressed. The replica on a RustFS target must decrypt to the original
|
||||||
|
/// bytes for a single PUT and for a multipart upload.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_bucket_replication_sse_c_compressed_passthrough() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
const PART_SIZE: usize = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||||
|
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||||
|
let mut source_process_env = replication_fast_env();
|
||||||
|
source_process_env.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||||
|
source_process_env.extend_from_slice(FAST_SCANNER_ENV);
|
||||||
|
source_process_env.extend_from_slice(&[
|
||||||
|
("NO_PROXY", "127.0.0.1,localhost"),
|
||||||
|
("HTTP_PROXY", ""),
|
||||||
|
("HTTPS_PROXY", ""),
|
||||||
|
("RUSTFS_COMPRESSION_ENABLED", "true"),
|
||||||
|
("RUSTFS_COMPRESSION_MULTIPART_ENABLED", "true"),
|
||||||
|
]);
|
||||||
|
source_env.start_rustfs_server_with_env(vec![], &source_process_env).await?;
|
||||||
|
target_env
|
||||||
|
.start_rustfs_server_without_cleanup_with_env(&[
|
||||||
|
("NO_PROXY", "127.0.0.1,localhost"),
|
||||||
|
("HTTP_PROXY", ""),
|
||||||
|
("HTTPS_PROXY", ""),
|
||||||
|
])
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let source_bucket = "ssec-compressed-src";
|
||||||
|
let target_bucket = "ssec-compressed-dst";
|
||||||
|
let source_client = source_env.create_s3_client();
|
||||||
|
let target_client = target_env.create_s3_client();
|
||||||
|
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||||
|
target_client.create_bucket().bucket(target_bucket).send().await?;
|
||||||
|
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||||
|
enable_bucket_versioning(&target_env, target_bucket).await?;
|
||||||
|
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
|
||||||
|
put_bucket_replication(&source_env, source_bucket, &target_arn).await?;
|
||||||
|
|
||||||
|
let customer_key = BASE64_STANDARD.encode_to_string(REPL17_SSEC_KEY);
|
||||||
|
let customer_key_md5 = sse_customer_key_md5_base64(REPL17_SSEC_KEY);
|
||||||
|
let text = |len: usize, seed: u32| -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(len + 64);
|
||||||
|
let mut line = 0u64;
|
||||||
|
while out.len() < len {
|
||||||
|
out.extend_from_slice(format!("ssec compressed passthrough seed={seed} line={line} lorem ipsum dolor\n").as_bytes());
|
||||||
|
line += 1;
|
||||||
|
}
|
||||||
|
out.truncate(len);
|
||||||
|
out
|
||||||
|
};
|
||||||
|
|
||||||
|
let single_key = "ssec-compressed-single.txt";
|
||||||
|
let single_body = text(1024 * 1024 + 17, 1);
|
||||||
|
source_client
|
||||||
|
.put_object()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(single_key)
|
||||||
|
.content_type("text/plain")
|
||||||
|
.body(ByteStream::from(single_body.clone()))
|
||||||
|
.sse_customer_algorithm("AES256")
|
||||||
|
.sse_customer_key(&customer_key)
|
||||||
|
.sse_customer_key_md5(&customer_key_md5)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let multipart_key = "ssec-compressed-multipart.txt";
|
||||||
|
let multipart_parts = [text(PART_SIZE, 2), text(1024 * 1024 + 4096, 3)];
|
||||||
|
let multipart_body: Vec<u8> = multipart_parts.concat();
|
||||||
|
let created = source_client
|
||||||
|
.create_multipart_upload()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.content_type("text/plain")
|
||||||
|
.sse_customer_algorithm("AES256")
|
||||||
|
.sse_customer_key(&customer_key)
|
||||||
|
.sse_customer_key_md5(&customer_key_md5)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
let upload_id = created.upload_id().ok_or("missing multipart upload id")?.to_string();
|
||||||
|
let mut completed = Vec::new();
|
||||||
|
for (index, part) in multipart_parts.iter().enumerate() {
|
||||||
|
let part_number = i32::try_from(index + 1)?;
|
||||||
|
let uploaded = source_client
|
||||||
|
.upload_part()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.upload_id(&upload_id)
|
||||||
|
.part_number(part_number)
|
||||||
|
.body(ByteStream::from(part.clone()))
|
||||||
|
.sse_customer_algorithm("AES256")
|
||||||
|
.sse_customer_key(&customer_key)
|
||||||
|
.sse_customer_key_md5(&customer_key_md5)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
completed.push(
|
||||||
|
CompletedPart::builder()
|
||||||
|
.part_number(part_number)
|
||||||
|
.set_e_tag(uploaded.e_tag().map(str::to_string))
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
source_client
|
||||||
|
.complete_multipart_upload()
|
||||||
|
.bucket(source_bucket)
|
||||||
|
.key(multipart_key)
|
||||||
|
.upload_id(&upload_id)
|
||||||
|
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed)).build())
|
||||||
|
.sse_customer_algorithm("AES256")
|
||||||
|
.sse_customer_key(&customer_key)
|
||||||
|
.sse_customer_key_md5(&customer_key_md5)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut failures = Vec::new();
|
||||||
|
for (key, body) in [(single_key, &single_body), (multipart_key, &multipart_body)] {
|
||||||
|
let status = wait_terminal_replication_status(&source_client, source_bucket, key, true, Duration::from_secs(120)).await?;
|
||||||
|
if status != "COMPLETED" {
|
||||||
|
failures.push(format!("{key}: source reports {status}"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let replica = target_client
|
||||||
|
.get_object()
|
||||||
|
.bucket(target_bucket)
|
||||||
|
.key(key)
|
||||||
|
.sse_customer_algorithm("AES256")
|
||||||
|
.sse_customer_key(&customer_key)
|
||||||
|
.sse_customer_key_md5(&customer_key_md5)
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
match replica {
|
||||||
|
Ok(replica) => {
|
||||||
|
let content_length = replica.content_length();
|
||||||
|
match replica.body.collect().await {
|
||||||
|
Ok(collected) => {
|
||||||
|
let bytes = collected.into_bytes();
|
||||||
|
if bytes.as_ref() != body.as_slice() {
|
||||||
|
failures.push(format!(
|
||||||
|
"{key}: replica bytes differ (content_length={content_length:?}, got {} bytes, want {})",
|
||||||
|
bytes.len(),
|
||||||
|
body.len()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => failures.push(format!("{key}: replica body read failed: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => failures.push(format!("{key}: replica GET failed: {err}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
failures.is_empty(),
|
||||||
|
"SSE-C compressed passthrough replicas must decrypt to the source bytes: {failures:?}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
//! Native OTLP -> Collector -> Prometheus contract, including a rolling upgrade.
|
||||||
|
|
||||||
|
use crate::common::RustFSTestClusterEnvironment;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::fs::{self, File};
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
|
||||||
|
|
||||||
|
struct ToolProcess(Child);
|
||||||
|
impl Drop for ToolProcess {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self.0.kill();
|
||||||
|
let _ = self.0.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_binary(name: &str) -> TestResult<PathBuf> {
|
||||||
|
let path = PathBuf::from(std::env::var(name).map_err(|_| format!("{name} must name a pinned executable"))?);
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(format!("{name} does not name a file: {}", path.display()).into());
|
||||||
|
}
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn free_port() -> TestResult<u16> {
|
||||||
|
Ok(TcpListener::bind("127.0.0.1:0")?.local_addr()?.port())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_tool(binary: &Path, args: &[String], log: &Path) -> TestResult<ToolProcess> {
|
||||||
|
let log = File::create(log)?;
|
||||||
|
Ok(ToolProcess(
|
||||||
|
Command::new(binary)
|
||||||
|
.args(args)
|
||||||
|
.env("NO_PROXY", "127.0.0.1,localhost")
|
||||||
|
.env_remove("HTTP_PROXY")
|
||||||
|
.env_remove("HTTPS_PROXY")
|
||||||
|
.stdout(Stdio::from(log.try_clone()?))
|
||||||
|
.stderr(Stdio::from(log))
|
||||||
|
.spawn()?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query(client: &reqwest::Client, base: &str, expression: &str) -> TestResult<Value> {
|
||||||
|
let mut url = reqwest::Url::parse(&format!("{base}/api/v1/query"))?;
|
||||||
|
url.query_pairs_mut().append_pair("query", expression);
|
||||||
|
let result: Value = client.get(url).send().await?.error_for_status()?.json().await?;
|
||||||
|
if result["status"] != "success" {
|
||||||
|
return Err(format!("PromQL failed: {result}").into());
|
||||||
|
}
|
||||||
|
Ok(result["data"]["result"].clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn await_count(client: &reqwest::Client, base: &str, selector: &str, expected: u64) -> TestResult {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(120);
|
||||||
|
loop {
|
||||||
|
let result = query(client, base, &format!("count({selector}) or vector(0)")).await;
|
||||||
|
if let Ok(rows) = &result
|
||||||
|
&& rows[0]["value"][1].as_str().and_then(|value| value.parse::<u64>().ok()) == Some(expected)
|
||||||
|
{
|
||||||
|
println!("PASS count={expected}: {selector}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return Err(format!("expected {expected} for {selector}; last result: {result:?}").into());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_dashboard_queries(client: &reqwest::Client, base: &str, observer: &str) -> TestResult {
|
||||||
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.docker/observability/grafana/dashboards/rustfs.json");
|
||||||
|
let dashboard: Value = serde_json::from_str(&fs::read_to_string(path)?)?;
|
||||||
|
for name in ["storage_cluster", "storage_observer"] {
|
||||||
|
let variable = dashboard["templating"]["list"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or("dashboard variables")?
|
||||||
|
.iter()
|
||||||
|
.find(|variable| variable["name"] == name)
|
||||||
|
.ok_or("storage selection variable")?;
|
||||||
|
assert_eq!(variable["multi"], false, "storage views must select one {name}");
|
||||||
|
assert_eq!(variable["includeAll"], false, "storage views must select one {name}");
|
||||||
|
}
|
||||||
|
let mut pending = dashboard["panels"]
|
||||||
|
.as_array()
|
||||||
|
.ok_or("dashboard panels")?
|
||||||
|
.iter()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut checked = 0;
|
||||||
|
while let Some(panel) = pending.pop() {
|
||||||
|
if let Some(children) = panel["panels"].as_array() {
|
||||||
|
pending.extend(children);
|
||||||
|
}
|
||||||
|
for target in panel["targets"].as_array().into_iter().flatten() {
|
||||||
|
let Some(expression) = target["expr"].as_str() else { continue };
|
||||||
|
if !expression.contains("rustfs:storage:current") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if expression.contains("collection_scope=\"cluster\"") {
|
||||||
|
assert!(
|
||||||
|
expression.contains("observer=\"$storage_observer\""),
|
||||||
|
"global views must select one observer: {expression}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut expression = expression.to_string();
|
||||||
|
for (name, value) in [
|
||||||
|
("$__rate_interval", "5m"),
|
||||||
|
("$storage_cluster", "metrics-e2e"),
|
||||||
|
("$storage_observer", observer),
|
||||||
|
("$drive_api", ".*"),
|
||||||
|
("$server", ".*"),
|
||||||
|
("$drive", ".*"),
|
||||||
|
("$job", "rustfs"),
|
||||||
|
] {
|
||||||
|
expression = expression.replace(name, value);
|
||||||
|
}
|
||||||
|
query(client, base, &expression).await?;
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(checked > 0, "the storage dashboard queries must be exercised");
|
||||||
|
println!("PASS: {checked} storage dashboard queries against the live pipeline");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[ignore = "external tools: pinned Collector, Prometheus, previous release and current RustFS binaries"]
|
||||||
|
async fn storage_metric_ownership_pipeline() -> TestResult {
|
||||||
|
let baseline = required_binary("RUSTFS_METRICS_BASELINE_BINARY")?;
|
||||||
|
let current = required_binary("CARGO_BIN_EXE_rustfs")?;
|
||||||
|
let collector = required_binary("RUSTFS_OTELCOL_BINARY")?;
|
||||||
|
let prometheus = required_binary("RUSTFS_PROMETHEUS_BINARY")?;
|
||||||
|
let temp = tempfile::Builder::new().prefix("rustfs-storage-metrics-").tempdir()?;
|
||||||
|
let work = if let Ok(path) = std::env::var("RUSTFS_METRICS_E2E_ARTIFACTS") {
|
||||||
|
let path = PathBuf::from(path);
|
||||||
|
fs::create_dir_all(&path)?;
|
||||||
|
tempfile::Builder::new().prefix("storage-run-").tempdir_in(path)?.keep()
|
||||||
|
} else {
|
||||||
|
temp.path().to_path_buf()
|
||||||
|
};
|
||||||
|
println!("Metrics pipeline logs: {}", work.display());
|
||||||
|
let otlp = free_port()?;
|
||||||
|
let scrape = free_port()?;
|
||||||
|
let prom = free_port()?;
|
||||||
|
let collector_config = work.join("collector.yaml");
|
||||||
|
fs::write(
|
||||||
|
&collector_config,
|
||||||
|
format!(
|
||||||
|
r#"receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
http:
|
||||||
|
endpoint: 127.0.0.1:{otlp}
|
||||||
|
exporters:
|
||||||
|
prometheus:
|
||||||
|
endpoint: 127.0.0.1:{scrape}
|
||||||
|
send_timestamps: true
|
||||||
|
metric_expiration: 5m
|
||||||
|
resource_to_telemetry_conversion:
|
||||||
|
enabled: true
|
||||||
|
service:
|
||||||
|
telemetry:
|
||||||
|
metrics:
|
||||||
|
level: none
|
||||||
|
logs:
|
||||||
|
level: warn
|
||||||
|
pipelines:
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp]
|
||||||
|
exporters: [prometheus]
|
||||||
|
"#
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
let rules = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../.docker/observability/prometheus-rules/rustfs-storage.yml")
|
||||||
|
.canonicalize()?;
|
||||||
|
let prom_config = work.join("prometheus.yaml");
|
||||||
|
fs::write(
|
||||||
|
&prom_config,
|
||||||
|
format!(
|
||||||
|
r#"global:
|
||||||
|
scrape_interval: 1s
|
||||||
|
evaluation_interval: 1s
|
||||||
|
rule_files:
|
||||||
|
- '{}'
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: rustfs
|
||||||
|
static_configs:
|
||||||
|
- targets: ['127.0.0.1:{scrape}']
|
||||||
|
"#,
|
||||||
|
rules.display()
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
// Use the shipped expressions, with only the test evaluation interval shortened.
|
||||||
|
let test_rules = work.join("storage-rules.yaml");
|
||||||
|
fs::write(&test_rules, fs::read_to_string(&rules)?.replace("interval: 15s", "interval: 1s"))?;
|
||||||
|
fs::write(
|
||||||
|
&prom_config,
|
||||||
|
fs::read_to_string(&prom_config)?.replace(&rules.display().to_string(), &test_rules.display().to_string()),
|
||||||
|
)?;
|
||||||
|
let _collector = start_tool(
|
||||||
|
&collector,
|
||||||
|
&[format!("--config={}", collector_config.display())],
|
||||||
|
&work.join("collector.log"),
|
||||||
|
)?;
|
||||||
|
let _prometheus = start_tool(
|
||||||
|
&prometheus,
|
||||||
|
&[
|
||||||
|
format!("--config.file={}", prom_config.display()),
|
||||||
|
format!("--web.listen-address=127.0.0.1:{prom}"),
|
||||||
|
format!("--storage.tsdb.path={}", work.join("prometheus-data").display()),
|
||||||
|
],
|
||||||
|
&work.join("prometheus.log"),
|
||||||
|
)?;
|
||||||
|
let http = reqwest::Client::builder()
|
||||||
|
.no_proxy()
|
||||||
|
.timeout(Duration::from_secs(5))
|
||||||
|
.build()?;
|
||||||
|
let prom_url = format!("http://127.0.0.1:{prom}");
|
||||||
|
await_count(&http, &prom_url, "up{job=\"rustfs\"} == 1", 1).await?;
|
||||||
|
|
||||||
|
let mut cluster = RustFSTestClusterEnvironment::new(4).await?;
|
||||||
|
cluster.set_env("NO_PROXY", "127.0.0.1,localhost");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METRIC_ENDPOINT", format!("http://127.0.0.1:{otlp}/v1/metrics"));
|
||||||
|
cluster.set_env("OTEL_RESOURCE_ATTRIBUTES", "rustfs.cluster.id=metrics-e2e");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METER_INTERVAL", "2");
|
||||||
|
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "true");
|
||||||
|
cluster.set_env("RUSTFS_OBS_LOGS_EXPORT_ENABLED", "false");
|
||||||
|
cluster.set_env("RUSTFS_OBS_TRACES_EXPORT_ENABLED", "false");
|
||||||
|
cluster.set_env("RUSTFS_METRICS_NODE_INTERVAL", "2");
|
||||||
|
cluster.set_env("RUSTFS_METRICS_CLUSTER_INTERVAL", "5");
|
||||||
|
for index in 0..4 {
|
||||||
|
// Four localhost processes otherwise share the startup resource IP.
|
||||||
|
// Distinct test host IDs model the four hosts in a distributed deployment.
|
||||||
|
cluster.set_node_env(
|
||||||
|
index,
|
||||||
|
"OTEL_RESOURCE_ATTRIBUTES",
|
||||||
|
format!("rustfs.cluster.id=metrics-e2e,host.id=metrics-node-{index}"),
|
||||||
|
)?;
|
||||||
|
cluster.set_node_capture_log_path(index, work.join(format!("node-{index}.log")).display().to_string())?;
|
||||||
|
}
|
||||||
|
cluster.start_with_binary(&baseline).await?;
|
||||||
|
// Reproduce the original four observers x four global drives before fixing it.
|
||||||
|
await_count(&http, &prom_url, "rustfs_system_drive_total_bytes{collection_scope=\"\",drive!=\"\"}", 16).await?;
|
||||||
|
let local = "rustfs:storage:current{source_metric=\"rustfs_system_drive_total_bytes\",collection_scope=\"local\",rustfs_cluster_id=\"metrics-e2e\"}";
|
||||||
|
for index in 0..4 {
|
||||||
|
cluster.stop_node_gracefully(index).await?;
|
||||||
|
cluster.start_node_from_binary(index, ¤t).await?;
|
||||||
|
await_count(&http, &prom_url, local, u64::try_from(index + 1)?).await?;
|
||||||
|
}
|
||||||
|
let rows = query(&http, &prom_url, local).await?;
|
||||||
|
for row in rows.as_array().ok_or("expected a metric vector")? {
|
||||||
|
assert_eq!(
|
||||||
|
row["metric"]["observer"], row["metric"]["server"],
|
||||||
|
"a node must only export its own detailed drives"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let observer = cluster.nodes[0].address.clone();
|
||||||
|
let inventory = format!(
|
||||||
|
"rustfs:storage:current{{source_metric=\"rustfs_cluster_drive_present\",collection_scope=\"cluster\",rustfs_cluster_id=\"metrics-e2e\",observer=\"{observer}\"}}"
|
||||||
|
);
|
||||||
|
await_count(&http, &prom_url, &inventory, 4).await?;
|
||||||
|
cluster.create_test_bucket("metrics-ownership").await?;
|
||||||
|
let client = cluster.create_s3_client(0)?;
|
||||||
|
for index in 0..8 {
|
||||||
|
client
|
||||||
|
.put_object()
|
||||||
|
.bucket("metrics-ownership")
|
||||||
|
.key(format!("object-{index}"))
|
||||||
|
.body(ByteStream::from(vec![7_u8; 4096]))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
await_count(
|
||||||
|
&http,
|
||||||
|
&prom_url,
|
||||||
|
"count by (server) (rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\"})",
|
||||||
|
4,
|
||||||
|
).await?;
|
||||||
|
let counters = query(
|
||||||
|
&http,
|
||||||
|
&prom_url,
|
||||||
|
"rustfs:storage:current{source_metric=\"rustfs_system_drive_api_calls_total\",collection_scope=\"local\"}",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert!(
|
||||||
|
!counters.as_array().ok_or("expected counters")?.is_empty(),
|
||||||
|
"exercise actual storage counters"
|
||||||
|
);
|
||||||
|
for row in counters.as_array().ok_or("expected counters")? {
|
||||||
|
assert_eq!(row["metric"]["observer"], row["metric"]["server"]);
|
||||||
|
assert!(
|
||||||
|
!row["metric"]["disk_id"].as_str().unwrap_or_default().is_empty(),
|
||||||
|
"counters must carry physical disk identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
validate_dashboard_queries(&http, &prom_url, &observer).await?;
|
||||||
|
for index in (1..4).rev() {
|
||||||
|
cluster.stop_node(index)?;
|
||||||
|
// The Collector stays alive; cached samples must not keep stopped owners fresh.
|
||||||
|
await_count(&http, &prom_url, local, u64::try_from(index)?).await?;
|
||||||
|
await_count(&http, &prom_url, &inventory, 4).await?;
|
||||||
|
let unavailable = format!(
|
||||||
|
"rustfs:storage:current{{source_metric=\"rustfs_cluster_drive_runtime_state\",collection_scope=\"cluster\",rustfs_cluster_id=\"metrics-e2e\",observer=\"{observer}\",state=~\"offline|unknown|suspect\"}} == 1"
|
||||||
|
);
|
||||||
|
await_count(&http, &prom_url, &unavailable, u64::try_from(4 - index)?).await?;
|
||||||
|
}
|
||||||
|
cluster.stop();
|
||||||
|
await_count(&http, &prom_url, local, 0).await?;
|
||||||
|
cluster.start_with_binary(¤t).await?;
|
||||||
|
await_count(&http, &prom_url, local, 4).await?;
|
||||||
|
await_count(&http, &prom_url, &inventory, 4).await?;
|
||||||
|
let restored = client.get_object().bucket("metrics-ownership").key("object-0").send().await?;
|
||||||
|
assert_eq!(restored.body.collect().await?.into_bytes().as_ref(), vec![7_u8; 4096].as_slice());
|
||||||
|
println!("PASS: baseline duplication; four rolling upgrades; owner identity; counters; 4 -> 3 -> 2 -> 1 -> 0 -> 4 recovery");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -32,8 +32,10 @@ use aws_sdk_s3::types::{
|
|||||||
VersioningConfiguration,
|
VersioningConfiguration,
|
||||||
};
|
};
|
||||||
use http::{Method, StatusCode};
|
use http::{Method, StatusCode};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use tokio::task::JoinSet;
|
use tokio::task::JoinSet;
|
||||||
use tokio::time::{Instant, sleep};
|
use tokio::time::{Instant, sleep};
|
||||||
|
|
||||||
@@ -41,6 +43,7 @@ type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
|||||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||||
|
|
||||||
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
const SOURCE_BINARY_ENV: &str = "RUSTFS_UPGRADE_SOURCE_BINARY";
|
||||||
|
const G09_EVIDENCE_DIR_ENV: &str = "RUSTFS_SCANNER_HEAL_G09_EVIDENCE_DIR";
|
||||||
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
|
const RC5_COMMIT: &str = "40a2470feb567201165a5b809b7598bb4b1f68f5";
|
||||||
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
const SSE_MASTER_KEY_ENV: &str = "RUSTFS_SSE_S3_MASTER_KEY";
|
||||||
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
const SSE_MASTER_KEY: &str = "QkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkI=";
|
||||||
@@ -81,6 +84,14 @@ const QUOTA_READINESS_TIMEOUT: Duration = Duration::from_secs(30);
|
|||||||
// of treating it as an upgrade failure.
|
// of treating it as an upgrade failure.
|
||||||
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
|
const QUOTA_ADMISSION_WARMUP_TIMEOUT: Duration = Duration::from_secs(90);
|
||||||
|
|
||||||
|
struct G09EvidenceContext {
|
||||||
|
directory: PathBuf,
|
||||||
|
current_revision: String,
|
||||||
|
previous_revision: String,
|
||||||
|
run_id: String,
|
||||||
|
measurement_window_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
let path = std::env::var_os(SOURCE_BINARY_ENV)
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
@@ -91,6 +102,155 @@ fn source_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_lower_hex_revision(value: &str) -> bool {
|
||||||
|
value.len() == 40
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_source_revision() -> Result<String, BoxError> {
|
||||||
|
let revision = env!("RUSTFS_E2E_BUILD_COMMIT");
|
||||||
|
if !is_lower_hex_revision(revision) {
|
||||||
|
return Err(format!("current test binary has invalid source revision {revision}").into());
|
||||||
|
}
|
||||||
|
Ok(revision.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn binary_source_revision(binary: &Path) -> Result<String, BoxError> {
|
||||||
|
let output = tokio::process::Command::new(binary).arg("--version").output().await?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!("{} --version failed with {}", binary.display(), output.status).into());
|
||||||
|
}
|
||||||
|
let version = String::from_utf8(output.stdout)?;
|
||||||
|
source_revision_from_version_output(&version, binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_revision_from_version_output(version: &str, binary: &Path) -> Result<String, BoxError> {
|
||||||
|
version
|
||||||
|
.split(|ch: char| !ch.is_ascii_hexdigit())
|
||||||
|
.find(|token| is_lower_hex_revision(token))
|
||||||
|
.map(str::to_string)
|
||||||
|
.ok_or_else(|| format!("{} --version did not expose a 40-byte source revision", binary.display()).into())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn g09_evidence_context(previous_binary: &Path) -> Result<Option<G09EvidenceContext>, BoxError> {
|
||||||
|
let Some(directory) = std::env::var_os(G09_EVIDENCE_DIR_ENV) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let directory = PathBuf::from(directory);
|
||||||
|
std::fs::create_dir_all(&directory)?;
|
||||||
|
let current_revision = current_source_revision()?;
|
||||||
|
let previous_revision = binary_source_revision(previous_binary).await?;
|
||||||
|
if current_revision == previous_revision {
|
||||||
|
return Err("G09 mixed-version evidence requires distinct current and previous source revisions".into());
|
||||||
|
}
|
||||||
|
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
|
||||||
|
Ok(Some(G09EvidenceContext {
|
||||||
|
directory,
|
||||||
|
current_revision,
|
||||||
|
previous_revision,
|
||||||
|
run_id: format!("g09-upgrade-{}-{now}", std::process::id()),
|
||||||
|
measurement_window_id: format!("g09-mixed-version-window-{now}"),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_g09_evidence(
|
||||||
|
context: &G09EvidenceContext,
|
||||||
|
field: &str,
|
||||||
|
role: &str,
|
||||||
|
cases: &[&str],
|
||||||
|
test: &str,
|
||||||
|
details: Value,
|
||||||
|
) -> TestResult {
|
||||||
|
let path = context.directory.join(format!("G09-{field}.json"));
|
||||||
|
let mut evidence = serde_json::json!({
|
||||||
|
"schema": 1,
|
||||||
|
"evidence_type": "measured",
|
||||||
|
"artifact_kind": "upgrade-compatibility-e2e",
|
||||||
|
"source_revision": context.current_revision,
|
||||||
|
"run_id": context.run_id,
|
||||||
|
"measurement_window_id": context.measurement_window_id,
|
||||||
|
"gate": "G09",
|
||||||
|
"field": field,
|
||||||
|
"versions": [context.previous_revision, context.current_revision],
|
||||||
|
"current_revision": context.current_revision,
|
||||||
|
"previous_revision": context.previous_revision,
|
||||||
|
"mixed_version_role": role,
|
||||||
|
"mixed_version_cases": cases,
|
||||||
|
"test": test,
|
||||||
|
"details": details,
|
||||||
|
});
|
||||||
|
if field == "rollback_payload_evidence" {
|
||||||
|
evidence["rollback_payload_replayed"] = Value::Bool(true);
|
||||||
|
}
|
||||||
|
let data = serde_json::to_vec_pretty(&evidence)?;
|
||||||
|
if data.len() > 1024 * 1024 {
|
||||||
|
return Err("G09 mixed-version evidence exceeds the 1 MiB artifact budget".into());
|
||||||
|
}
|
||||||
|
let mut output = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||||
|
output.write_all(&data)?;
|
||||||
|
output.write_all(b"\n")?;
|
||||||
|
output.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod g09_evidence_tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_revision_parser_requires_lowercase_forty_byte_sha() -> TestResult {
|
||||||
|
let binary = Path::new("rustfs");
|
||||||
|
assert_eq!(
|
||||||
|
source_revision_from_version_output("rustfs 1.0.0 abcdef0123456789abcdef0123456789abcdef01 clean", binary)?,
|
||||||
|
"abcdef0123456789abcdef0123456789abcdef01"
|
||||||
|
);
|
||||||
|
assert!(source_revision_from_version_output("rustfs ABCDEF0123456789ABCDEF0123456789ABCDEF01", binary).is_err());
|
||||||
|
assert!(source_revision_from_version_output("rustfs abcdef", binary).is_err());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn g09_evidence_writer_records_measured_role_and_refuses_overwrite() -> TestResult {
|
||||||
|
let directory = TempDir::new()?;
|
||||||
|
let context = G09EvidenceContext {
|
||||||
|
directory: directory.path().to_path_buf(),
|
||||||
|
current_revision: "b".repeat(40),
|
||||||
|
previous_revision: "a".repeat(40),
|
||||||
|
run_id: "g09-upgrade-test-run".to_string(),
|
||||||
|
measurement_window_id: "g09-upgrade-test-window".to_string(),
|
||||||
|
};
|
||||||
|
write_g09_evidence(
|
||||||
|
&context,
|
||||||
|
"mixed_version_reader_evidence",
|
||||||
|
"mixed-version-reader",
|
||||||
|
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||||
|
"unit",
|
||||||
|
serde_json::json!({"assertions": ["reader evidence"]}),
|
||||||
|
)?;
|
||||||
|
let path = directory.path().join("G09-mixed_version_reader_evidence.json");
|
||||||
|
let evidence: Value = serde_json::from_slice(&std::fs::read(path)?)?;
|
||||||
|
assert_eq!(evidence["evidence_type"], "measured");
|
||||||
|
assert_eq!(evidence["gate"], "G09");
|
||||||
|
assert_eq!(evidence["field"], "mixed_version_reader_evidence");
|
||||||
|
assert_eq!(evidence["mixed_version_role"], "mixed-version-reader");
|
||||||
|
assert_eq!(evidence["versions"], serde_json::json!(["a".repeat(40), "b".repeat(40)]));
|
||||||
|
|
||||||
|
let overwrite = write_g09_evidence(
|
||||||
|
&context,
|
||||||
|
"mixed_version_reader_evidence",
|
||||||
|
"mixed-version-reader",
|
||||||
|
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||||
|
"unit",
|
||||||
|
serde_json::json!({}),
|
||||||
|
);
|
||||||
|
assert!(overwrite.is_err(), "G09 evidence must not overwrite an existing artifact");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
|
async fn enable_versioning(client: &Client, bucket: &str) -> TestResult {
|
||||||
let configuration = VersioningConfiguration::builder()
|
let configuration = VersioningConfiguration::builder()
|
||||||
.status(BucketVersioningStatus::Enabled)
|
.status(BucketVersioningStatus::Enabled)
|
||||||
@@ -523,6 +683,7 @@ async fn direct_upgrade_from_rc2_preserves_object_contracts() -> TestResult {
|
|||||||
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
|
async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let previous_binary = source_binary()?;
|
let previous_binary = source_binary()?;
|
||||||
|
let evidence_context = g09_evidence_context(&previous_binary).await?;
|
||||||
let current_binary = rustfs_binary_path();
|
let current_binary = rustfs_binary_path();
|
||||||
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
|
let mut cluster = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
|
||||||
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
|
cluster.set_env("RUST_LOG", "rustfs=warn,rustfs_notify=warn");
|
||||||
@@ -555,6 +716,45 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(context) = evidence_context.as_ref() {
|
||||||
|
let phases = ["one-current-node", "one-previous-node"];
|
||||||
|
let objects_per_phase = MULTIPART_WORKERS * MULTIPART_UPLOADS_PER_WORKER + 2;
|
||||||
|
write_g09_evidence(
|
||||||
|
context,
|
||||||
|
"mixed_version_reader_evidence",
|
||||||
|
"mixed-version-reader",
|
||||||
|
&["old-writer-new-reader", "new-writer-old-reader"],
|
||||||
|
"upgrade_compatibility_test::rolling_upgrade_from_rc2_preserves_mixed_version_contracts",
|
||||||
|
serde_json::json!({
|
||||||
|
"bucket": MIXED_BUCKET,
|
||||||
|
"phases": phases,
|
||||||
|
"objects_per_phase": objects_per_phase,
|
||||||
|
"assertions": [
|
||||||
|
"current node reads objects written through previous-release client",
|
||||||
|
"previous-release node reads objects written through current client",
|
||||||
|
"all nodes list every mixed-version object after homogeneous-current convergence"
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
write_g09_evidence(
|
||||||
|
context,
|
||||||
|
"mixed_version_writer_evidence",
|
||||||
|
"mixed-version-writer",
|
||||||
|
&["old-reader-new-writer", "new-reader-old-writer"],
|
||||||
|
"upgrade_compatibility_test::rolling_upgrade_from_rc2_preserves_mixed_version_contracts",
|
||||||
|
serde_json::json!({
|
||||||
|
"bucket": MIXED_BUCKET,
|
||||||
|
"phases": phases,
|
||||||
|
"objects_per_phase": objects_per_phase,
|
||||||
|
"assertions": [
|
||||||
|
"current writer publishes objects readable by previous-release node",
|
||||||
|
"previous-release writer publishes objects readable by current node",
|
||||||
|
"multipart writers continue under one-current-node and one-previous-node layouts"
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,6 +1336,7 @@ async fn direct_upgrade_from_previous_release_preserves_bucket_configuration() -
|
|||||||
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
|
async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestResult {
|
||||||
init_logging();
|
init_logging();
|
||||||
let previous_binary = source_binary()?;
|
let previous_binary = source_binary()?;
|
||||||
|
let evidence_context = g09_evidence_context(&previous_binary).await?;
|
||||||
|
|
||||||
let replication_target = FakeS3Target::start().await?;
|
let replication_target = FakeS3Target::start().await?;
|
||||||
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
|
replication_target.create_bucket(ROLLBACK_REPLICA_BUCKET);
|
||||||
@@ -1204,6 +1405,54 @@ async fn rollback_to_previous_release_reads_current_bucket_metadata() -> TestRes
|
|||||||
);
|
);
|
||||||
assert_eq!(body, post_rollback_bytes);
|
assert_eq!(body, post_rollback_bytes);
|
||||||
|
|
||||||
|
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||||
|
let current_again = env.create_s3_client();
|
||||||
|
assert_versioning_enabled(¤t_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
|
||||||
|
assert_default_sse_s3_encryption(¤t_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
|
||||||
|
assert_bucket_tag(¤t_again, ROLLBACK_BUCKET, "after rolling forward again").await?;
|
||||||
|
assert_remote_target_preserved(&env, ROLLBACK_BUCKET, &target_arn, "after rolling forward again").await?;
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_again, ROLLBACK_BUCKET, single_key, Some(&single_version))
|
||||||
|
.await?
|
||||||
|
.1,
|
||||||
|
single_bytes
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_again, ROLLBACK_BUCKET, multipart_key, None).await?.1,
|
||||||
|
multipart_bytes
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
read_object(¤t_again, ROLLBACK_BUCKET, post_rollback_key, None).await?.1,
|
||||||
|
post_rollback_bytes
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(context) = evidence_context.as_ref() {
|
||||||
|
write_g09_evidence(
|
||||||
|
context,
|
||||||
|
"rollback_payload_evidence",
|
||||||
|
"rollback-payload",
|
||||||
|
&["rollback-to-old", "rollback-to-new", "unknown-field-retained"],
|
||||||
|
"upgrade_compatibility_test::rollback_to_previous_release_reads_current_bucket_metadata",
|
||||||
|
serde_json::json!({
|
||||||
|
"bucket": ROLLBACK_BUCKET,
|
||||||
|
"cases": {
|
||||||
|
"rollback-to-old": [
|
||||||
|
"previous-release binary reads current-build versioning, SSE-S3, tags, replication target and objects",
|
||||||
|
"previous-release writer honors the decoded current-build encryption configuration"
|
||||||
|
],
|
||||||
|
"rollback-to-new": [
|
||||||
|
"current build reads the object written by the rolled-back previous release",
|
||||||
|
"current build reads the current-build single-part and multipart objects after rolling forward again"
|
||||||
|
],
|
||||||
|
"unknown-field-retained": [
|
||||||
|
"previous release skips current-build bucket metadata extension fields without dropping known bucket configuration",
|
||||||
|
"current build reads the retained bucket configuration after the previous-release round trip"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
replication_target.shutdown().await;
|
replication_target.shutdown().await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1473,20 +1722,17 @@ fn layout_cases() -> Vec<LayoutCase> {
|
|||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
// SSE-C passthrough replicates the stored ciphertext part by part; a
|
// SSE-C passthrough replicates the stored ciphertext part by part; a
|
||||||
// compressible first part is stored well below 5 MiB and a standard
|
// compressible first part is stored well below 5 MiB, so the sender
|
||||||
// target rejects it with EntityTooSmall. rc.5 fails the same way (see
|
// declares each part's plaintext length and the target validates the
|
||||||
// `rc5_baseline_replicates_multipart_layouts`), so the outcome is
|
// 5 MiB minimum against it (rustfs/backlog#2363). rc.5 as the sender
|
||||||
// recorded rather than asserted here; tracked as rustfs/backlog#2363.
|
// still fails this layout (see `rc5_baseline_replicates_multipart_layouts`).
|
||||||
LayoutCase {
|
case(
|
||||||
assert_replication: false,
|
LAYOUT_PLAIN_BUCKET,
|
||||||
..case(
|
"plain/ssec-compressed-multipart-2.txt",
|
||||||
LAYOUT_PLAIN_BUCKET,
|
two.clone(),
|
||||||
"plain/ssec-compressed-multipart-2.txt",
|
layout_text(total(&two), 7),
|
||||||
two.clone(),
|
true,
|
||||||
layout_text(total(&two), 7),
|
),
|
||||||
true,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
case(
|
case(
|
||||||
LAYOUT_ENCRYPTED_BUCKET,
|
LAYOUT_ENCRYPTED_BUCKET,
|
||||||
"encrypted/single.bin",
|
"encrypted/single.bin",
|
||||||
@@ -1838,27 +2084,20 @@ async fn direct_upgrade_from_rc5_preserves_multipart_layouts() -> TestResult {
|
|||||||
transport.uploaded_parts, expected,
|
transport.uploaded_parts, expected,
|
||||||
"{label}: stored parts must replicate as the same multipart layout"
|
"{label}: stored parts must replicate as the same multipart layout"
|
||||||
);
|
);
|
||||||
// The current build can drive an existing object twice (two
|
// An object still PENDING when the next scanner cycle arrives is
|
||||||
// full CreateMultipartUpload/UploadPart/Complete rounds with
|
// not driven a second time (rustfs/backlog#2362); the journal is
|
||||||
// distinct upload ids) while its status is still PENDING; the
|
// logged so a duplicate round is visible if this ever regresses.
|
||||||
// rc.5 baseline drives once. That is a scheduling difference,
|
assert_eq!(
|
||||||
// not a layout one, tracked as rustfs/backlog#2362.
|
transport.completes, 1,
|
||||||
assert!(transport.completes >= 1, "{label}: at least one CompleteMultipartUpload");
|
"{label}: exactly one CompleteMultipartUpload; journal {:?}",
|
||||||
if transport.completes > 1 {
|
transport.journal
|
||||||
tracing::warn!(
|
);
|
||||||
target: "e2e_test::upgrade_compatibility_test",
|
|
||||||
object = %label,
|
|
||||||
completes = transport.completes,
|
|
||||||
journal = ?transport.journal,
|
|
||||||
"existing-object replication drove the same object more than once (rustfs/backlog#2362)"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
transport.single_puts, 0,
|
transport.single_puts, 0,
|
||||||
"{label}: a multipart layout must not go out as a single PutObject"
|
"{label}: a multipart layout must not go out as a single PutObject"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
assert!(transport.single_puts >= 1, "{label}: a single PUT replicates as PutObject");
|
assert_eq!(transport.single_puts, 1, "{label}: a single PUT replicates as exactly one PutObject");
|
||||||
assert!(transport.uploaded_parts.is_empty(), "{label}: a single PUT must not go out as multipart");
|
assert!(transport.uploaded_parts.is_empty(), "{label}: a single PUT must not go out as multipart");
|
||||||
}
|
}
|
||||||
if !case.ssec {
|
if !case.ssec {
|
||||||
@@ -1928,3 +2167,59 @@ async fn rc5_baseline_replicates_multipart_layouts() -> TestResult {
|
|||||||
tracing::info!(target: "e2e_test::upgrade_compatibility_test", ?summary, "rc.5 baseline replication outcomes");
|
tracing::info!(target: "e2e_test::upgrade_compatibility_test", ?summary, "rc.5 baseline replication outcomes");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// backlog#2362 under the same conditions that reproduced it with the rc.5
|
||||||
|
/// writer, but with the workspace build on both sides so it runs in the
|
||||||
|
/// ordinary lane: every pre-existing layout is driven through exactly one
|
||||||
|
/// upload round even though the scanner re-scans it every second while the
|
||||||
|
/// first round is still in flight.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn existing_object_replication_drives_each_layout_once() -> TestResult {
|
||||||
|
init_logging();
|
||||||
|
let server_env = layout_server_env();
|
||||||
|
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
|
env.start_rustfs_server_with_env(vec![], &server_env).await?;
|
||||||
|
let writer = env.create_s3_client();
|
||||||
|
env.create_test_bucket(LAYOUT_PLAIN_BUCKET).await?;
|
||||||
|
env.create_test_bucket(LAYOUT_ENCRYPTED_BUCKET).await?;
|
||||||
|
enable_versioning(&writer, LAYOUT_PLAIN_BUCKET).await?;
|
||||||
|
enable_versioning(&writer, LAYOUT_ENCRYPTED_BUCKET).await?;
|
||||||
|
put_default_sse_s3_encryption(&writer, LAYOUT_ENCRYPTED_BUCKET).await?;
|
||||||
|
|
||||||
|
let mut cases = layout_cases();
|
||||||
|
for case in cases.iter_mut() {
|
||||||
|
layout_write(&writer, case).await?;
|
||||||
|
let head = layout_head(&writer, case).await?;
|
||||||
|
case.rc5_etag = head
|
||||||
|
.e_tag()
|
||||||
|
.ok_or_else(|| format!("{}: HEAD omitted the ETag", case.label()))?
|
||||||
|
.trim_matches('"')
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The objects come from an earlier process lifetime: the scanner starts
|
||||||
|
// cold and every object is a candidate at once.
|
||||||
|
env.restart_server_preserving_data(vec![], &server_env).await?;
|
||||||
|
let client = env.create_s3_client();
|
||||||
|
let (_target, transports) = replicate_layouts(&env, &client, &cases).await?;
|
||||||
|
let mut duplicates = Vec::new();
|
||||||
|
for (case, transport) in cases.iter().zip(&transports) {
|
||||||
|
assert_eq!(
|
||||||
|
transport.status,
|
||||||
|
"COMPLETED",
|
||||||
|
"{}: existing-object replication must complete",
|
||||||
|
case.label()
|
||||||
|
);
|
||||||
|
let rounds = if case.is_multipart_layout() {
|
||||||
|
transport.completes
|
||||||
|
} else {
|
||||||
|
transport.single_puts
|
||||||
|
};
|
||||||
|
if rounds != 1 {
|
||||||
|
duplicates.push(format!("{}: {rounds} upload rounds; journal {:?}", case.label(), transport.journal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(duplicates.is_empty(), "each existing object must be driven exactly once: {duplicates:?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -118,6 +118,8 @@ hotpath-cpu = [
|
|||||||
# injection, xl.meta transition assertions) via `api::tier::test_util`.
|
# injection, xl.meta transition assertions) via `api::tier::test_util`.
|
||||||
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
|
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
|
||||||
test-util = []
|
test-util = []
|
||||||
|
# Observes real startup CAS only in the dedicated E2E binary.
|
||||||
|
e2e-test-hooks = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
hotpath.workspace = true
|
||||||
|
|||||||
@@ -33,10 +33,21 @@ pub mod bucket {
|
|||||||
pub use crate::bucket::bucket_target_sys::{
|
pub use crate::bucket::bucket_target_sys::{
|
||||||
AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError,
|
AdvancedPutOptions, BucketTargetError, BucketTargetSys, PutObjectOptions, RemoveObjectOptions, S3ClientError,
|
||||||
SsecPassthroughCapability, TargetClient, VersionIdentityCapability, append_version_id_query,
|
SsecPassthroughCapability, TargetClient, VersionIdentityCapability, append_version_id_query,
|
||||||
|
resolve_delete_api_version_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod lifecycle {
|
pub mod lifecycle {
|
||||||
|
pub mod legacy_transition_state_reconcile {
|
||||||
|
pub use crate::bucket::lifecycle::legacy_transition_state_reconcile::{
|
||||||
|
LegacyTransitionStateCopyRepresentation, LegacyTransitionStateMetadataAlias, LegacyTransitionStateReconcileError,
|
||||||
|
LegacyTransitionStateReconcileOutcome, LegacyTransitionStateReconcileReadiness,
|
||||||
|
LegacyTransitionStateReconcileRequest, LegacyTransitionStateReconcileResponse,
|
||||||
|
LegacyTransitionStateReconcileSelector, LegacyTransitionStateSetRepresentation, LegacyTransitionStateSource,
|
||||||
|
LegacyTransitionStateTarget,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
pub mod bucket_lifecycle_audit {
|
pub mod bucket_lifecycle_audit {
|
||||||
pub use crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc;
|
pub use crate::bucket::lifecycle::bucket_lifecycle_audit::LcEventSrc;
|
||||||
}
|
}
|
||||||
@@ -259,18 +270,19 @@ pub mod bucket {
|
|||||||
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
ReplicationDeleteStateSource, ReplicationHealQueueResult, ReplicationObjectBridge, ReplicationObjectIO,
|
||||||
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
ReplicationOperation, ReplicationPoolTrait, ReplicationPriority, ReplicationQueueAdmission, ReplicationScannerBridge,
|
||||||
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
ReplicationState, ReplicationStats, ReplicationStatusType, ReplicationStorage, ReplicationTargetValidationError,
|
||||||
ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, TargetReplicationResyncStatus,
|
ReplicationType, ResyncOpts, ResyncStatusType, RuntimeReplicationTargetBacklog, ScannerDirtyUsageMutationObserver,
|
||||||
VersionPurgeStatusType, XferStats, assign_site_replication_rule_priorities, commit_force_delete_intent,
|
ScannerDirtyUsageMutationSource, TargetReplicationResyncStatus, VersionPurgeStatusType, XferStats,
|
||||||
complete_force_delete_intent, delete_replication_state_from_config, delete_replication_version_id,
|
assign_site_replication_rule_priorities, commit_force_delete_intent, complete_force_delete_intent,
|
||||||
get_global_replication_pool, get_global_replication_stats, get_proxy_targets, init_background_replication,
|
delete_replication_state_from_config, delete_replication_version_id, get_global_replication_pool,
|
||||||
|
get_global_replication_stats, get_proxy_targets, init_background_replication,
|
||||||
invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule,
|
invalid_replication_config_status_field, is_site_replication_role, is_site_replication_rule,
|
||||||
merge_incoming_replication_config, merge_user_replication_config, persist_force_delete_intent,
|
merge_incoming_replication_config, merge_user_replication_config, persist_force_delete_intent,
|
||||||
read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
|
read_durable_mrf_backlog, replication_state_to_filemeta, replication_status_to_filemeta, replication_statuses_map,
|
||||||
replication_target_arn_deployment_id, replication_target_arns, resync_start_conflict_id,
|
replication_target_arn_deployment_id, replication_target_arns, resync_start_conflict_id,
|
||||||
should_remove_replication_target, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
set_scanner_dirty_usage_mutation_observer, should_remove_replication_target, should_schedule_delete_replication,
|
||||||
should_use_existing_delete_replication_source, site_replication_rule_deployment_id,
|
should_use_existing_delete_replication_info, should_use_existing_delete_replication_source,
|
||||||
unsupported_replication_config_field, validate_replication_config_structure, validate_replication_config_target_arns,
|
site_replication_rule_deployment_id, unsupported_replication_config_field, validate_replication_config_structure,
|
||||||
version_purge_status_to_filemeta,
|
validate_replication_config_target_arns, version_purge_status_to_filemeta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +377,14 @@ pub mod config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod data_usage {
|
pub mod data_usage {
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub use crate::data_movement::SourceCleanupDeleteBarrier;
|
||||||
|
#[cfg(feature = "test-util")]
|
||||||
|
pub use crate::data_movement::scanner_backlog::test_util::NativeScannerPauseBacklogWriteFault;
|
||||||
|
pub use crate::data_movement::scanner_backlog::{
|
||||||
|
MAX_SCANNER_PAUSE_BACKLOG_BYTES, ScannerPauseBacklogRetirementPlan, ScannerPauseBacklogRetirementPlanner,
|
||||||
|
ScannerPauseBacklogRetirementReplica, register_scanner_pause_backlog_retirement_planner,
|
||||||
|
};
|
||||||
pub use crate::data_usage::{
|
pub use crate::data_usage::{
|
||||||
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
DATA_USAGE_CACHE_NAME, apply_bucket_usage_memory_overlay, compute_bucket_usage,
|
||||||
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
init_compression_total_memory_from_backend, invalidate_admin_data_usage_snapshot_cache,
|
||||||
@@ -383,6 +403,8 @@ pub mod data_usage {
|
|||||||
pub mod disk {
|
pub mod disk {
|
||||||
pub use crate::disk::disk_store::get_object_disk_read_timeout;
|
pub use crate::disk::disk_store::get_object_disk_read_timeout;
|
||||||
pub use crate::disk::local::ScanGuard;
|
pub use crate::disk::local::ScanGuard;
|
||||||
|
#[cfg(all(feature = "test-util", not(windows)))]
|
||||||
|
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
|
||||||
pub use crate::disk::{
|
pub use crate::disk::{
|
||||||
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
|
||||||
CheckPartsResp, ConditionalFileUpdate, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
|
CheckPartsResp, ConditionalFileUpdate, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
|
||||||
@@ -415,8 +437,8 @@ pub mod disk {
|
|||||||
|
|
||||||
pub mod error {
|
pub mod error {
|
||||||
pub use crate::error::{
|
pub use crate::error::{
|
||||||
Error, Result, StorageError, classify_system_path_failure_reason, is_err_bucket_not_found, is_err_object_not_found,
|
Error, PoolMetadataError, PoolMetadataFailure, Result, StorageError, classify_system_path_failure_reason,
|
||||||
is_err_version_not_found,
|
is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,11 +582,11 @@ pub mod store_list {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub mod storage {
|
pub mod storage {
|
||||||
pub use crate::core::pools::HealLifecycleExpiryContext;
|
pub use crate::core::pools::{HealLifecycleExpiryContext, POOL_META_NAME};
|
||||||
pub use crate::store::HealWalkVersion;
|
pub use crate::store::HealWalkVersion;
|
||||||
pub use crate::store::{
|
pub use crate::store::{
|
||||||
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk, all_local_disk_path,
|
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk,
|
||||||
find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
|
all_local_disk_path, find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
|
||||||
prewarm_local_disk_id_map, prewarm_local_disk_id_map_with_instance_ctx,
|
prewarm_local_disk_id_map, prewarm_local_disk_id_map_with_instance_ctx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1425,7 +1425,12 @@ fn build_remove_object_headers(version_id: Option<&str>, opts: &RemoveObjectOpti
|
|||||||
/// and silently creates a delete marker instead of removing the version, while
|
/// and silently creates a delete marker instead of removing the version, while
|
||||||
/// the source stamps `VersionPurgeStatus=Complete` (backlog#799 B8 / #857).
|
/// the source stamps `VersionPurgeStatus=Complete` (backlog#799 B8 / #857).
|
||||||
/// Non-replication callers always pass the version through unchanged.
|
/// Non-replication callers always pass the version through unchanged.
|
||||||
fn resolve_delete_api_version_id(version_id: Option<String>, opts: &RemoveObjectOptions) -> Option<String> {
|
/// The `versionId` a replicated DELETE puts on the wire: none for a
|
||||||
|
/// delete-marker creation (the target mints the marker; the source version
|
||||||
|
/// travels in the internal headers for RustFS peers), the addressed version
|
||||||
|
/// otherwise. A generic S3 target given the version id on a marker-creation
|
||||||
|
/// DELETE would permanently delete that version instead.
|
||||||
|
pub fn resolve_delete_api_version_id(version_id: Option<String>, opts: &RemoveObjectOptions) -> Option<String> {
|
||||||
if opts.replication_request && opts.replication_delete_marker {
|
if opts.replication_request && opts.replication_delete_marker {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -966,6 +966,11 @@ async fn cleanup_free_version_exact(api: Arc<ECStore>, oi: &ObjectInfo, cancel:
|
|||||||
if let Some(err) = first_error {
|
if let Some(err) = first_error {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
|
runtime_sources::notify_scanner_dirty_usage_mutation(
|
||||||
|
&oi.bucket,
|
||||||
|
&oi.name,
|
||||||
|
runtime_sources::ScannerDirtyUsageMutationSource::TierExpiration,
|
||||||
|
);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4036,6 +4041,10 @@ impl ManualTransitionRunReport {
|
|||||||
|| self.skipped_queue_timeout > 0
|
|| self.skipped_queue_timeout > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_enqueue_backpressure(&self) -> bool {
|
||||||
|
self.skipped_queue_full > 0 || self.skipped_queue_closed > 0 || self.skipped_queue_timeout > 0
|
||||||
|
}
|
||||||
|
|
||||||
pub fn was_truncated(&self) -> bool {
|
pub fn was_truncated(&self) -> bool {
|
||||||
self.truncated_by_limit || self.truncated_by_duration || self.cancelled
|
self.truncated_by_limit || self.truncated_by_duration || self.cancelled
|
||||||
}
|
}
|
||||||
@@ -4251,7 +4260,7 @@ pub async fn enqueue_transition_for_existing_objects_scoped(
|
|||||||
}
|
}
|
||||||
report.scanned = report.scanned.saturating_add(1);
|
report.scanned = report.scanned.saturating_add(1);
|
||||||
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
|
enqueue_transition_with_lifecycle_report(Some(api.clone()), object, &lc, &src, &options, &mut report).await;
|
||||||
if report.has_partial_enqueue() {
|
if report.has_enqueue_backpressure() {
|
||||||
report.next_marker.clone_from(&previous_marker);
|
report.next_marker.clone_from(&previous_marker);
|
||||||
report.next_version_idmarker.clone_from(&previous_version_marker);
|
report.next_version_idmarker.clone_from(&previous_version_marker);
|
||||||
report.continuation_token =
|
report.continuation_token =
|
||||||
@@ -4745,6 +4754,11 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
|||||||
// Drop any cached restored-copy body so it does not sit resident
|
// Drop any cached restored-copy body so it does not sit resident
|
||||||
// until TTL after the copy is expired (ODC-26).
|
// until TTL after the copy is expired (ODC-26).
|
||||||
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
||||||
|
runtime_sources::notify_scanner_dirty_usage_mutation(
|
||||||
|
&oi.bucket,
|
||||||
|
&oi.name,
|
||||||
|
runtime_sources::ScannerDirtyUsageMutationSource::TierExpiration,
|
||||||
|
);
|
||||||
//audit_log_lifecycle(*oi, ILMExpiry, tags, traceFn);
|
//audit_log_lifecycle(*oi, ILMExpiry, tags, traceFn);
|
||||||
Ok(dobj)
|
Ok(dobj)
|
||||||
}
|
}
|
||||||
@@ -4780,6 +4794,11 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
|||||||
// The transitioned version is gone; evict any cached body for this object
|
// The transitioned version is gone; evict any cached body for this object
|
||||||
// so it does not linger until TTL (ODC-26).
|
// so it does not linger until TTL (ODC-26).
|
||||||
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
crate::object_api::notify_object_mutation(&oi.bucket, &oi.name).await;
|
||||||
|
runtime_sources::notify_scanner_dirty_usage_mutation(
|
||||||
|
&oi.bucket,
|
||||||
|
&oi.name,
|
||||||
|
runtime_sources::ScannerDirtyUsageMutationSource::TierExpiration,
|
||||||
|
);
|
||||||
|
|
||||||
//audit_log_lifecycle(oi, ILMExpiry, tags);
|
//audit_log_lifecycle(oi, ILMExpiry, tags);
|
||||||
|
|
||||||
@@ -5161,6 +5180,9 @@ pub async fn put_restore_opts(
|
|||||||
user_defined: meta,
|
user_defined: meta,
|
||||||
version_id: oi.version_id.map(|e| e.to_string()),
|
version_id: oi.version_id.map(|e| e.to_string()),
|
||||||
mod_time: oi.mod_time,
|
mod_time: oi.mod_time,
|
||||||
|
// Restore writes stored (possibly encrypted) bytes, so the writer's
|
||||||
|
// computed MD5 is not the object's public plaintext ETag.
|
||||||
|
preserve_etag: oi.etag.clone(),
|
||||||
//expires: oi.expires,
|
//expires: oi.expires,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
@@ -9950,6 +9972,18 @@ mod tests {
|
|||||||
assert_eq!(report.skipped_queue_closed, 0);
|
assert_eq!(report.skipped_queue_closed, 0);
|
||||||
assert_eq!(report.skipped_queue_timeout, 0);
|
assert_eq!(report.skipped_queue_timeout, 0);
|
||||||
assert!(report.has_partial_enqueue());
|
assert!(report.has_partial_enqueue());
|
||||||
|
assert!(report.has_enqueue_backpressure());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn manual_transition_in_flight_skip_does_not_stop_the_scan() {
|
||||||
|
let options = ManualTransitionRunOptions::default();
|
||||||
|
let mut report = ManualTransitionRunReport::new("bucket", &options);
|
||||||
|
|
||||||
|
report.record_enqueue_outcome(TransitionEnqueueOutcome::AlreadyInFlight);
|
||||||
|
|
||||||
|
assert!(report.has_partial_enqueue());
|
||||||
|
assert!(!report.has_enqueue_backpressure());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ mod config_boundary;
|
|||||||
pub mod core;
|
pub mod core;
|
||||||
mod durable_namespace;
|
mod durable_namespace;
|
||||||
pub mod evaluator;
|
pub mod evaluator;
|
||||||
|
pub mod legacy_transition_state_reconcile;
|
||||||
pub mod manual_transition_job;
|
pub mod manual_transition_job;
|
||||||
mod metadata_boundary;
|
mod metadata_boundary;
|
||||||
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs, get_lifecycle_config};
|
pub(crate) use metadata_boundary::{LifecycleExpiryConfigs, get_expiry_configs, get_lifecycle_config};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use tokio_util::sync::CancellationToken;
|
|||||||
|
|
||||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::{ExpiryState, TransitionState};
|
use crate::bucket::lifecycle::bucket_lifecycle_ops::{ExpiryState, TransitionState};
|
||||||
use crate::runtime::sources;
|
use crate::runtime::sources;
|
||||||
|
pub(crate) use crate::runtime::sources::ScannerDirtyUsageMutationSource;
|
||||||
use crate::services::tier::tier::TierConfigMgr;
|
use crate::services::tier::tier::TierConfigMgr;
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
|
|
||||||
@@ -54,3 +55,7 @@ pub(crate) fn deployment_id() -> Option<String> {
|
|||||||
pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<BucketLifecycleConfiguration> {
|
pub(crate) async fn bucket_lifecycle_config(bucket: &str) -> Option<BucketLifecycleConfiguration> {
|
||||||
sources::bucket_lifecycle_config(bucket).await
|
sources::bucket_lifecycle_config(bucket).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn notify_scanner_dirty_usage_mutation(bucket: &str, object: &str, source: ScannerDirtyUsageMutationSource) {
|
||||||
|
sources::notify_scanner_dirty_usage_mutation(bucket, object, source);
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ pub type BucketConfigPublishHook = Box<dyn Fn(&str, &str, Option<(&[u8], OffsetD
|
|||||||
pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new();
|
pub static BUCKET_CONFIG_PUBLISH_HOOK: std::sync::OnceLock<BucketConfigPublishHook> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
const BUCKET_METADATA_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
|
||||||
|
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||||
|
const LOG_SUBSYSTEM_BUCKET_METADATA: &str = "bucket_metadata";
|
||||||
|
const EVENT_BUCKET_METADATA_LOAD_FAILED: &str = "bucket_metadata_load_failed";
|
||||||
|
|
||||||
#[cfg(any(test, feature = "test-util"))]
|
#[cfg(any(test, feature = "test-util"))]
|
||||||
struct ConfigWriteLockProbeState {
|
struct ConfigWriteLockProbeState {
|
||||||
@@ -1614,13 +1617,20 @@ impl BucketMetadataSys {
|
|||||||
|
|
||||||
let results = join_all(futures).await;
|
let results = join_all(futures).await;
|
||||||
|
|
||||||
for (idx, res) in results.into_iter().enumerate() {
|
for (bucket, res) in buckets.iter().zip(results) {
|
||||||
match res {
|
match res {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Unable to load bucket metadata, will be retried: {:?}", e);
|
if failed_buckets.insert(bucket.clone()) {
|
||||||
if let Some(bucket) = buckets.get(idx) {
|
error!(
|
||||||
failed_buckets.insert(bucket.clone());
|
event = EVENT_BUCKET_METADATA_LOAD_FAILED,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_BUCKET_METADATA,
|
||||||
|
result = "retry_pending",
|
||||||
|
bucket = %bucket,
|
||||||
|
error_code = ?e.code(),
|
||||||
|
"Unable to load bucket metadata; retry scheduled"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1647,12 +1657,19 @@ impl BucketMetadataSys {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
let results = join_all(futures).await;
|
let results = join_all(futures).await;
|
||||||
for (idx, result) in results.into_iter().enumerate() {
|
for (bucket, result) in buckets.iter().zip(results) {
|
||||||
if let Err(err) = result {
|
if let Err(err) = result
|
||||||
error!("Unable to load bucket metadata, will be retried: {:?}", err);
|
&& failed_buckets.insert(bucket.clone())
|
||||||
if let Some(bucket) = buckets.get(idx) {
|
{
|
||||||
failed_buckets.insert(bucket.clone());
|
error!(
|
||||||
}
|
event = EVENT_BUCKET_METADATA_LOAD_FAILED,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_BUCKET_METADATA,
|
||||||
|
result = "retry_pending",
|
||||||
|
bucket = %bucket,
|
||||||
|
error_code = ?err.code(),
|
||||||
|
"Unable to load bucket metadata; retry scheduled"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1664,9 +1681,13 @@ impl BucketMetadataSys {
|
|||||||
expected: Option<&Arc<BucketMetadata>>,
|
expected: Option<&Arc<BucketMetadata>>,
|
||||||
namespace_guard: &rustfs_lock::NamespaceLockGuard,
|
namespace_guard: &rustfs_lock::NamespaceLockGuard,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if !self
|
if !await_bucket_namespace_operation(
|
||||||
.bucket_exists(bucket, namespace_guard, "bucket metadata existence check")
|
Some(namespace_guard),
|
||||||
.await?
|
bucket,
|
||||||
|
"bucket metadata heal existence check",
|
||||||
|
self.api.bucket_exists_for_heal(bucket),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
{
|
{
|
||||||
if matches!(mode, MetadataLoadMode::Refresh) {
|
if matches!(mode, MetadataLoadMode::Refresh) {
|
||||||
let _publish_guard = self
|
let _publish_guard = self
|
||||||
|
|||||||
@@ -92,3 +92,6 @@ pub use replication_target_boundary::SsecPassthroughCapability;
|
|||||||
pub use replication_target_boundary::VersionIdentityCapability;
|
pub use replication_target_boundary::VersionIdentityCapability;
|
||||||
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
|
pub use replication_target_boundary::{ObjectLockIntegrity, object_lock_put_integrity};
|
||||||
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
pub(crate) use replication_target_config_bridge::ReplicationTargetConfigBridge;
|
||||||
|
pub use runtime_boundary::{
|
||||||
|
ScannerDirtyUsageMutationObserver, ScannerDirtyUsageMutationSource, set_scanner_dirty_usage_mutation_observer,
|
||||||
|
};
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ use tracing::{debug, info, instrument, warn};
|
|||||||
const EVENT_REPLICATION_WORKER_RESIZE_SKIPPED: &str = "replication_worker_resize_skipped";
|
const EVENT_REPLICATION_WORKER_RESIZE_SKIPPED: &str = "replication_worker_resize_skipped";
|
||||||
const EVENT_REPLICATION_WORKER_RESIZED: &str = "replication_worker_resized";
|
const EVENT_REPLICATION_WORKER_RESIZED: &str = "replication_worker_resized";
|
||||||
const EVENT_REPLICATION_BACKPRESSURE: &str = "replication_backpressure";
|
const EVENT_REPLICATION_BACKPRESSURE: &str = "replication_backpressure";
|
||||||
|
const EVENT_REPLICATION_IN_FLIGHT_SKIPPED: &str = "replication_in_flight_skipped";
|
||||||
const EVENT_REPLICATION_RESYNC_LOAD_SKIPPED: &str = "replication_resync_load_skipped";
|
const EVENT_REPLICATION_RESYNC_LOAD_SKIPPED: &str = "replication_resync_load_skipped";
|
||||||
const EVENT_REPLICATION_RESYNC_RECOVERED: &str = "replication_resync_recovered";
|
const EVENT_REPLICATION_RESYNC_RECOVERED: &str = "replication_resync_recovered";
|
||||||
const EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE: &str = "replication_mrf_queue_unavailable";
|
const EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE: &str = "replication_mrf_queue_unavailable";
|
||||||
@@ -1089,6 +1090,9 @@ pub struct ReplicationPool<S: ReplicationStorage> {
|
|||||||
workers: RwLock<Vec<Sender<ReplicationOperation>>>,
|
workers: RwLock<Vec<Sender<ReplicationOperation>>>,
|
||||||
lrg_workers: RwLock<Vec<Sender<ReplicationOperation>>>,
|
lrg_workers: RwLock<Vec<Sender<ReplicationOperation>>>,
|
||||||
|
|
||||||
|
/// Object versions queued or being replicated right now (backlog#2362).
|
||||||
|
in_flight: Arc<ReplicationInFlight>,
|
||||||
|
|
||||||
// MRF (Most Recent Failures) channels
|
// MRF (Most Recent Failures) channels
|
||||||
mrf_replica_tx: Sender<ReplicationOperation>,
|
mrf_replica_tx: Sender<ReplicationOperation>,
|
||||||
// Shared among N MRF workers; Arc allows spawning more than one worker.
|
// Shared among N MRF workers; Arc allows spawning more than one worker.
|
||||||
@@ -1147,6 +1151,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
storage,
|
storage,
|
||||||
workers: RwLock::new(Vec::new()),
|
workers: RwLock::new(Vec::new()),
|
||||||
lrg_workers: RwLock::new(Vec::new()),
|
lrg_workers: RwLock::new(Vec::new()),
|
||||||
|
in_flight: Arc::new(ReplicationInFlight::default()),
|
||||||
mrf_replica_tx,
|
mrf_replica_tx,
|
||||||
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
|
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
|
||||||
mrf_save_tx,
|
mrf_save_tx,
|
||||||
@@ -1202,12 +1207,13 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
let active_counter = self.active_lrg_workers.clone();
|
let active_counter = self.active_lrg_workers.clone();
|
||||||
let storage = self.storage.clone();
|
let storage = self.storage.clone();
|
||||||
let stats = self.stats.clone();
|
let stats = self.stats.clone();
|
||||||
|
let in_flight = self.in_flight.clone();
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let mut rx = rx;
|
let mut rx = rx;
|
||||||
while let Some(operation) = rx.recv().await {
|
while let Some(operation) = rx.recv().await {
|
||||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||||
process_replication_operation(operation, stats.clone(), storage.clone()).await;
|
process_replication_operation(operation, stats.clone(), storage.clone(), in_flight.clone()).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1261,12 +1267,13 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
let active_counter = self.active_workers.clone();
|
let active_counter = self.active_workers.clone();
|
||||||
let stats = self.stats.clone();
|
let stats = self.stats.clone();
|
||||||
let storage = self.storage.clone();
|
let storage = self.storage.clone();
|
||||||
|
let in_flight = self.in_flight.clone();
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
let mut rx = rx;
|
let mut rx = rx;
|
||||||
while let Some(operation) = rx.recv().await {
|
while let Some(operation) = rx.recv().await {
|
||||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||||
process_replication_operation(operation, stats.clone(), storage.clone()).await;
|
process_replication_operation(operation, stats.clone(), storage.clone(), in_flight.clone()).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1305,6 +1312,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
let active_counter = self.active_mrf_workers.clone();
|
let active_counter = self.active_mrf_workers.clone();
|
||||||
let stats = self.stats.clone();
|
let stats = self.stats.clone();
|
||||||
let storage = self.storage.clone();
|
let storage = self.storage.clone();
|
||||||
|
let in_flight = self.in_flight.clone();
|
||||||
let mrf_rx = Arc::clone(&self.mrf_replica_rx);
|
let mrf_rx = Arc::clone(&self.mrf_replica_rx);
|
||||||
|
|
||||||
let handle = tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
@@ -1324,7 +1332,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
let Some(operation) = operation else { break };
|
let Some(operation) = operation else { break };
|
||||||
|
|
||||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||||
process_replication_operation(operation, stats.clone(), storage.clone()).await;
|
process_replication_operation(operation, stats.clone(), storage.clone(), in_flight.clone()).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
self.task_handles.lock().await.push(handle);
|
self.task_handles.lock().await.push(handle);
|
||||||
@@ -1454,6 +1462,24 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
|
|
||||||
/// Queues a replica task
|
/// Queues a replica task
|
||||||
pub async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission {
|
pub async fn queue_replica_task(&self, ri: ReplicateObjectInfo) -> ReplicationQueueAdmission {
|
||||||
|
// A version that is already queued or being uploaded is not driven a
|
||||||
|
// second time: the scanner heal pass sees it as PENDING until the
|
||||||
|
// first upload lands and would otherwise re-queue it every cycle
|
||||||
|
// (backlog#2362). The key is released when the worker finishes, or
|
||||||
|
// below when no worker accepts the task.
|
||||||
|
if !self.in_flight.try_begin(&ri) {
|
||||||
|
debug!(
|
||||||
|
event = EVENT_REPLICATION_IN_FLIGHT_SKIPPED,
|
||||||
|
component = LOG_COMPONENT_ECSTORE,
|
||||||
|
subsystem = LOG_SUBSYSTEM_REPLICATION,
|
||||||
|
bucket = %ri.bucket,
|
||||||
|
object = %ri.name,
|
||||||
|
version_id = ?ri.version_id,
|
||||||
|
op_type = ?ri.op_type,
|
||||||
|
"Replication task already in flight; not queued again"
|
||||||
|
);
|
||||||
|
return ReplicationQueueAdmission::Skipped;
|
||||||
|
}
|
||||||
let target_arns = ri.dsc.replicate_target_arns();
|
let target_arns = ri.dsc.replicate_target_arns();
|
||||||
// If object is large, queue it to a static set of large workers
|
// If object is large, queue it to a static set of large workers
|
||||||
if should_queue_large_object(ri.size) {
|
if should_queue_large_object(ri.size) {
|
||||||
@@ -1484,7 +1510,9 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
let resize = large_worker_backpressure_resize(existing, self.active_lrg_workers(), max_l_workers);
|
let resize = large_worker_backpressure_resize(existing, self.active_lrg_workers(), max_l_workers);
|
||||||
drop(lrg_workers);
|
drop(lrg_workers);
|
||||||
|
|
||||||
// Queue to MRF if worker is busy.
|
// Queue to MRF if worker is busy. The MRF replay re-enters
|
||||||
|
// this function, so the version is no longer in flight.
|
||||||
|
self.in_flight.finish(&ri);
|
||||||
let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "large_object").await;
|
let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "large_object").await;
|
||||||
|
|
||||||
if let Some(resize) = resize {
|
if let Some(resize) = resize {
|
||||||
@@ -1493,6 +1521,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
return admission;
|
return admission;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.in_flight.finish(&ri);
|
||||||
return ReplicationQueueAdmission::Missed;
|
return ReplicationQueueAdmission::Missed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1501,6 +1530,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
let ch = self.worker_queue_channel(&ri.op_type, &ri.bucket, &ri.name, ri.size).await;
|
let ch = self.worker_queue_channel(&ri.op_type, &ri.bucket, &ri.name, ri.size).await;
|
||||||
|
|
||||||
let Some(channel) = ch else {
|
let Some(channel) = ch else {
|
||||||
|
self.in_flight.finish(&ri);
|
||||||
return ReplicationQueueAdmission::Missed;
|
return ReplicationQueueAdmission::Missed;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1512,7 +1542,9 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
self.stats.dec_q(&ri.bucket, ri.size, ri.delete_marker, ri.op_type);
|
self.stats.dec_q(&ri.bucket, ri.size, ri.delete_marker, ri.op_type);
|
||||||
self.stats.dec_target_q(&ri.bucket, &target_arns, ri.size);
|
self.stats.dec_target_q(&ri.bucket, &target_arns, ri.size);
|
||||||
|
|
||||||
// Queue to MRF if all workers are busy.
|
// Queue to MRF if all workers are busy. The MRF replay re-enters this
|
||||||
|
// function, so the version is no longer in flight.
|
||||||
|
self.in_flight.finish(&ri);
|
||||||
let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "object").await;
|
let admission = self.queue_mrf_save_admission(ri.to_mrf_entry(), "object").await;
|
||||||
|
|
||||||
// Try to scale up workers based on priority
|
// Try to scale up workers based on priority
|
||||||
@@ -1811,7 +1843,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
) {
|
) {
|
||||||
while let Some(operation) = rx.recv().await {
|
while let Some(operation) = rx.recv().await {
|
||||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||||
process_replication_operation(operation, stats.clone(), self.storage.clone()).await;
|
process_replication_operation(operation, stats.clone(), self.storage.clone(), self.in_flight.clone()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1829,7 +1861,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
) {
|
) {
|
||||||
while let Some(operation) = rx.recv().await {
|
while let Some(operation) = rx.recv().await {
|
||||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||||
process_replication_operation(operation, stats.clone(), storage.clone()).await;
|
process_replication_operation(operation, stats.clone(), storage.clone(), self.in_flight.clone()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1846,7 +1878,7 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
) {
|
) {
|
||||||
while let Some(operation) = rx.recv().await {
|
while let Some(operation) = rx.recv().await {
|
||||||
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
let _active = ActiveWorkerGuard::new(active_counter.clone());
|
||||||
process_replication_operation(operation, stats.clone(), self.storage.clone()).await;
|
process_replication_operation(operation, stats.clone(), self.storage.clone(), self.in_flight.clone()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2281,6 +2313,64 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Object versions currently queued or being uploaded, keyed by bucket,
|
||||||
|
/// object name and version. `queue_replica_task` admits a version only once
|
||||||
|
/// while it is in flight; the scanner heal pass and MRF replays that arrive
|
||||||
|
/// in the meantime are `Skipped` instead of driving a second complete upload
|
||||||
|
/// (backlog#2362). Entries are removed when the worker finishes the task or
|
||||||
|
/// when no worker accepted it.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub(crate) struct ReplicationInFlight {
|
||||||
|
keys: std::sync::Mutex<std::collections::HashSet<(String, String, Option<uuid::Uuid>)>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplicationInFlight {
|
||||||
|
fn lock(&self) -> std::sync::MutexGuard<'_, std::collections::HashSet<(String, String, Option<uuid::Uuid>)>> {
|
||||||
|
self.keys.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claim `ri`; `false` when the same version is already in flight.
|
||||||
|
fn try_begin(&self, ri: &ReplicateObjectInfo) -> bool {
|
||||||
|
self.lock().insert((ri.bucket.clone(), ri.name.clone(), ri.version_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(&self, ri: &ReplicateObjectInfo) {
|
||||||
|
self.lock().remove(&(ri.bucket.clone(), ri.name.clone(), ri.version_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn len(&self) -> usize {
|
||||||
|
self.lock().len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases the in-flight claim when the worker is done with the task,
|
||||||
|
/// including when replication panics.
|
||||||
|
struct ReplicationInFlightGuard {
|
||||||
|
in_flight: Arc<ReplicationInFlight>,
|
||||||
|
key: ReplicateObjectInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReplicationInFlightGuard {
|
||||||
|
fn new(in_flight: Arc<ReplicationInFlight>, ri: &ReplicateObjectInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
in_flight,
|
||||||
|
key: ReplicateObjectInfo {
|
||||||
|
bucket: ri.bucket.clone(),
|
||||||
|
name: ri.name.clone(),
|
||||||
|
version_id: ri.version_id,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for ReplicationInFlightGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.in_flight.finish(&self.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ActiveWorkerGuard {
|
struct ActiveWorkerGuard {
|
||||||
counter: Arc<AtomicI32>,
|
counter: Arc<AtomicI32>,
|
||||||
}
|
}
|
||||||
@@ -2342,10 +2432,12 @@ async fn process_replication_operation<S: ReplicationStorage>(
|
|||||||
operation: ReplicationOperation,
|
operation: ReplicationOperation,
|
||||||
stats: Arc<ReplicationStats>,
|
stats: Arc<ReplicationStats>,
|
||||||
storage: Arc<S>,
|
storage: Arc<S>,
|
||||||
|
in_flight: Arc<ReplicationInFlight>,
|
||||||
) {
|
) {
|
||||||
match operation {
|
match operation {
|
||||||
ReplicationOperation::Object(obj_info) => {
|
ReplicationOperation::Object(obj_info) => {
|
||||||
let _backlog = ReplicationBacklogGuard::for_object(stats, obj_info.as_ref());
|
let _backlog = ReplicationBacklogGuard::for_object(stats, obj_info.as_ref());
|
||||||
|
let _in_flight = ReplicationInFlightGuard::new(in_flight, obj_info.as_ref());
|
||||||
replicate_object(*obj_info, storage).await;
|
replicate_object(*obj_info, storage).await;
|
||||||
}
|
}
|
||||||
ReplicationOperation::Delete(del_info) => {
|
ReplicationOperation::Delete(del_info) => {
|
||||||
@@ -3083,7 +3175,7 @@ pub async fn queue_replication_heal(bucket: &str, oi: ObjectInfo, retry_count: u
|
|||||||
// A bucket without a configuration still owes its pending purges an
|
// A bucket without a configuration still owes its pending purges an
|
||||||
// answer: the delete worker finishes them locally as abandoned, which
|
// answer: the delete worker finishes them locally as abandoned, which
|
||||||
// is what makes the bucket deletable again (rustfs/backlog#2340).
|
// is what makes the bucket deletable again (rustfs/backlog#2340).
|
||||||
Ok(None) if !oi.version_purge_status.is_empty() => None,
|
Ok(None) if owes_version_purge(&oi) => None,
|
||||||
Ok(None) => return ReplicationQueueAdmission::Skipped,
|
Ok(None) => return ReplicationQueueAdmission::Skipped,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -3161,6 +3253,17 @@ pub async fn queue_replication_metadata(bucket: &str, oi: ObjectInfo, retry_coun
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A version purge the persisted state still owes to named targets. Without
|
||||||
|
/// the target list nothing can be settled, so such a version keeps the
|
||||||
|
/// ordinary "no configuration, nothing to heal" skip.
|
||||||
|
fn owes_version_purge(oi: &ObjectInfo) -> bool {
|
||||||
|
!oi.version_purge_status.is_empty()
|
||||||
|
&& oi
|
||||||
|
.version_purge_status_internal
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|statuses| !statuses.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// queue_replication_heal_internal enqueues objects that failed replication OR eligible for resyncing through
|
/// queue_replication_heal_internal enqueues objects that failed replication OR eligible for resyncing through
|
||||||
/// an ongoing resync operation or via existing objects replication configuration setting.
|
/// an ongoing resync operation or via existing objects replication configuration setting.
|
||||||
pub(crate) async fn queue_replication_heal_internal(
|
pub(crate) async fn queue_replication_heal_internal(
|
||||||
@@ -3183,7 +3286,7 @@ pub(crate) async fn queue_replication_heal_internal(
|
|||||||
// except a version purge the bucket still owes: its stored decision names
|
// except a version purge the bucket still owes: its stored decision names
|
||||||
// the targets, and the delete worker settles the ones no longer
|
// the targets, and the delete worker settles the ones no longer
|
||||||
// configured as abandoned (rustfs/backlog#2340).
|
// configured as abandoned (rustfs/backlog#2340).
|
||||||
if (rcfg.config.is_none() || rcfg.remotes.is_none()) && oi.version_purge_status.is_empty() {
|
if (rcfg.config.is_none() || rcfg.remotes.is_none()) && !owes_version_purge(&oi) {
|
||||||
return ReplicationHealQueueResult {
|
return ReplicationHealQueueResult {
|
||||||
object_info: roi,
|
object_info: roi,
|
||||||
admission: ReplicationQueueAdmission::Skipped,
|
admission: ReplicationQueueAdmission::Skipped,
|
||||||
@@ -3718,6 +3821,7 @@ mod tests {
|
|||||||
stats: Arc::new(ReplicationStats::new()),
|
stats: Arc::new(ReplicationStats::new()),
|
||||||
workers: RwLock::new(Vec::new()),
|
workers: RwLock::new(Vec::new()),
|
||||||
lrg_workers: RwLock::new(Vec::new()),
|
lrg_workers: RwLock::new(Vec::new()),
|
||||||
|
in_flight: Arc::new(ReplicationInFlight::default()),
|
||||||
mrf_replica_tx,
|
mrf_replica_tx,
|
||||||
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
|
mrf_replica_rx: Arc::new(Mutex::new(mrf_replica_rx)),
|
||||||
mrf_save_tx,
|
mrf_save_tx,
|
||||||
@@ -3784,6 +3888,90 @@ mod tests {
|
|||||||
assert_eq!(current_queue(&pool, "admission-bucket").await, (1, 4096));
|
assert_eq!(current_queue(&pool, "admission-bucket").await, (1, 4096));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_replica_task_admits_a_version_once_while_it_is_in_flight() {
|
||||||
|
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
|
||||||
|
let (tx, _rx) = mpsc::channel(4);
|
||||||
|
pool.workers.write().await.push(tx);
|
||||||
|
let ri = ReplicateObjectInfo {
|
||||||
|
bucket: "in-flight-bucket".to_string(),
|
||||||
|
name: "object".to_string(),
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
size: 4096,
|
||||||
|
op_type: ReplicationType::Object,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Queued);
|
||||||
|
// backlog#2362: the scanner heal pass sees the version as PENDING
|
||||||
|
// until the worker lands it; a second request must not drive it again.
|
||||||
|
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Skipped);
|
||||||
|
assert_eq!(current_queue(&pool, "in-flight-bucket").await, (1, 4096));
|
||||||
|
|
||||||
|
// Another version of the same key is independent work.
|
||||||
|
let newer = ReplicateObjectInfo {
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..ri.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(pool.queue_replica_task(newer).await, ReplicationQueueAdmission::Queued);
|
||||||
|
assert_eq!(pool.in_flight.len(), 2);
|
||||||
|
|
||||||
|
// Once the worker finishes, the same version may be queued again
|
||||||
|
// (for example after a FAILED status).
|
||||||
|
pool.in_flight.finish(&ri);
|
||||||
|
assert_eq!(pool.queue_replica_task(ri).await, ReplicationQueueAdmission::Queued);
|
||||||
|
assert_eq!(current_queue(&pool, "in-flight-bucket").await, (3, 3 * 4096));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn queue_replica_task_releases_the_version_when_no_worker_accepts_it() {
|
||||||
|
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
|
||||||
|
let ri = ReplicateObjectInfo {
|
||||||
|
bucket: "no-worker-bucket".to_string(),
|
||||||
|
name: "object".to_string(),
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
size: 4096,
|
||||||
|
op_type: ReplicationType::Object,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// No worker channel: the task is missed and must not stay claimed.
|
||||||
|
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Missed);
|
||||||
|
assert_eq!(pool.in_flight.len(), 0);
|
||||||
|
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Missed);
|
||||||
|
|
||||||
|
// A full worker channel hands the task to the MRF save path; the MRF
|
||||||
|
// replay re-enters the queue, so the claim is released here too.
|
||||||
|
let (tx, _rx) = mpsc::channel(1);
|
||||||
|
pool.workers.write().await.push(tx);
|
||||||
|
assert_eq!(pool.queue_replica_task(ri.clone()).await, ReplicationQueueAdmission::Queued);
|
||||||
|
let overflow = ReplicateObjectInfo {
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..ri
|
||||||
|
};
|
||||||
|
assert_eq!(pool.queue_replica_task(overflow).await, ReplicationQueueAdmission::Queued);
|
||||||
|
assert_eq!(pool.in_flight.len(), 1, "only the version held by the worker channel stays in flight");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn in_flight_guard_releases_the_version_on_drop() {
|
||||||
|
let in_flight = Arc::new(ReplicationInFlight::default());
|
||||||
|
let ri = ReplicateObjectInfo {
|
||||||
|
bucket: "guard-bucket".to_string(),
|
||||||
|
name: "object".to_string(),
|
||||||
|
version_id: Some(uuid::Uuid::new_v4()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(in_flight.try_begin(&ri));
|
||||||
|
assert!(!in_flight.try_begin(&ri));
|
||||||
|
{
|
||||||
|
let _guard = ReplicationInFlightGuard::new(in_flight.clone(), &ri);
|
||||||
|
assert_eq!(in_flight.len(), 1);
|
||||||
|
}
|
||||||
|
assert_eq!(in_flight.len(), 0);
|
||||||
|
assert!(in_flight.try_begin(&ri));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn regular_worker_admission_counts_target_backlog_before_receive() {
|
async fn regular_worker_admission_counts_target_backlog_before_receive() {
|
||||||
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
|
let pool = new_test_replication_pool(Arc::new(LoadResyncNodeStore::new("node-a", empty_resync_shared_state()))).await;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user