mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-27 14:27:10 +00:00
Compare commits
120 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53245be9f4 | |||
| c47432e353 | |||
| f622c1bce0 | |||
| a99d8f4f89 | |||
| 4d15b81623 | |||
| 836d30192f | |||
| 860c61ee6d | |||
| f600af4704 | |||
| 9e747e8475 | |||
| 96fc067765 | |||
| 5972a09cc3 | |||
| e809b4899e | |||
| 584f03f300 | |||
| 8a5e730bc9 | |||
| d52d6fade7 | |||
| 3623d6db2d | |||
| 1df874b724 | |||
| 68dcad8387 | |||
| 8474feaa26 | |||
| 484f1254ca | |||
| c9c16efbb6 | |||
| d386f7741d | |||
| 5b4432620e | |||
| 1a450bf228 | |||
| 371241afcb | |||
| 61b31e7b45 | |||
| 0d1d1487f3 | |||
| 3824779520 | |||
| a9670ebb4d | |||
| f2a59073ca | |||
| 99ae115731 | |||
| c1af94d1d1 | |||
| 545e794945 | |||
| 855586255f | |||
| bf47a919ac | |||
| bdd23da034 | |||
| 89a277c3d9 | |||
| 3e87004032 | |||
| 76386572f6 | |||
| 34917c7947 | |||
| f0b2080e0c | |||
| f84bbfe28f | |||
| 378a2ff7f8 | |||
| 9c6b0e182b | |||
| ab9af5523c | |||
| b18c5afc8d | |||
| b88b53aa6a | |||
| df988cfd86 | |||
| 04bc5dffd6 | |||
| 681c95f359 | |||
| 32d98ff8b2 | |||
| 6b1554003e | |||
| 19978b2cb6 | |||
| db2abcf0a6 | |||
| 14eb28dcd6 | |||
| 2f0143ef15 | |||
| 3b92708011 | |||
| 8fea8ef600 | |||
| 477f768f7e | |||
| 47ed6717de | |||
| 480312e5ad | |||
| 1c5bbc0c47 | |||
| 14adc5ed3f | |||
| 714706328b | |||
| cd0b895ad7 | |||
| 073b4d838e | |||
| 95268a3b9e | |||
| f7c4ac11b2 | |||
| 9d9e92bab8 | |||
| 0ad0a6d8de | |||
| 051a54f129 | |||
| c71e4f72ea | |||
| 003570b61e | |||
| 0c3efda7bd | |||
| 2c7e7d195c | |||
| 0764785754 | |||
| 182b2bfe98 | |||
| 00a549504c | |||
| 83fa2611f4 | |||
| e22414fe68 | |||
| 45f97bccf9 | |||
| 046fb21d86 | |||
| 4783adff36 | |||
| 68ad1a1d7b | |||
| 7c289001ed | |||
| 7b1ffbfdc1 | |||
| 562e03670e | |||
| 5e1bd498ce | |||
| 5aca750a6e | |||
| a760d567b6 | |||
| 47d208325e | |||
| d087416ff9 | |||
| 309128f034 | |||
| 7a0f8561b6 | |||
| e77c76272a | |||
| c0e63ee543 | |||
| 8452910895 | |||
| 5efe4c5703 | |||
| afa367d220 | |||
| c6914f4132 | |||
| 48a6104aad | |||
| 4804162be8 | |||
| 6c376413bb | |||
| f5956999a7 | |||
| 94105a1044 | |||
| 8df8360bc2 | |||
| 753e8aa657 | |||
| 641c4b3493 | |||
| f07142910c | |||
| 5efa6254eb | |||
| 5c99417073 | |||
| 4aa3cde1d9 | |||
| 87c1c851c2 | |||
| 370b517b4f | |||
| 791af1888f | |||
| 50e6c907f9 | |||
| 1efa86b8a3 | |||
| a33dd896e2 | |||
| 6796382cee | |||
| 99a2af8687 |
@@ -58,6 +58,13 @@ are repository-relative. Read only the relevant sections during read-only review
|
||||
- Attach error context once where it is actionable. Do not erase typed errors
|
||||
below aggregation or quorum layers.
|
||||
|
||||
## Rust Boundaries
|
||||
|
||||
- Production `unwrap`/`expect` must follow a type guarantee or checked invariant; explain only non-obvious guarantees.
|
||||
- Public APIs do not return `Result<_, String>`. Library APIs use domain errors unless intentional erasure is part of the boundary contract; expose a stored inner error through `Error::source()`.
|
||||
- Numeric conversions must not silently truncate. Use fallible conversions for untrusted values; validate finiteness, sign, and range before float-to-integer conversion. Clamp/saturate only when required by the domain.
|
||||
- Do not add crate-root `#![allow(dead_code)]`.
|
||||
|
||||
## Naming
|
||||
|
||||
Use Rust API naming: `SCREAMING_SNAKE_CASE` constants/statics, `snake_case`
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
- For every changed lock, enumerate overlapping lock sets and construct the
|
||||
ABBA interleaving. Multiple-lock order must be documented and consistent.
|
||||
- Mark guard lifetimes and every `.await`, disk, and RPC call inside them.
|
||||
Estimate contention and timeout behavior under concurrent requests.
|
||||
- Mark guard lifetimes and every `.await`, disk,
|
||||
and RPC call; estimate contention/timeouts. Tokio read/write guards across
|
||||
`.await` need bounded hold time; long-lived reads can wedge writers (#4195).
|
||||
Keep `std::sync::Mutex` holds brief and never across `.await`.
|
||||
- Use direct `fetch_*` for unconditional atomic read-modify-write and
|
||||
`compare_exchange` for conditional updates.
|
||||
- Object commits remain fenced if the distributed lock is lost after shard
|
||||
writes and before metadata rename.
|
||||
- For write/rename changes, trace `write tmp -> sync tmp -> rename -> sync parent
|
||||
|
||||
@@ -15,6 +15,8 @@ Attack the changed behavior, not every subsystem in the repository.
|
||||
bytes and length.
|
||||
- For multipart/object commits, fail before/after rename and cleanup; committed
|
||||
data must remain readable and pre-commit cleanup must not destroy parts.
|
||||
- Bound recursion over untrusted or persisted input; handle corrupted/cyclic
|
||||
tree and cache traversals safely.
|
||||
- For version/index ordering, test `len - 1`, `len`, equal timestamps, missing
|
||||
versions, and deterministic tie-breaking.
|
||||
- For directory-object behavior, trace `__XLDIR__` at the store layer; branches
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
serialization is required.
|
||||
- Internal metadata tests assert both RustFS and MinIO keys, not only read-back
|
||||
through a helper that prefers one key.
|
||||
- Boundary companions are distinct coverage: `n == max` vs `max + 1`, and
|
||||
- Avoid duplicate tests of the same production path and poison-value class.
|
||||
Boundary companions are distinct coverage: `n == max` vs `max + 1`, and
|
||||
absent vs empty vs nil UUID.
|
||||
- A focused test proves only the targets/features it builds. Add compilation or
|
||||
Clippy only for uncovered changed targets.
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
name: code-change-verification
|
||||
description: Review a commit, PR, or merged patch when the user requests ordinary code-change verification. Do not combine with adversarial-validation; use that skill instead for explicitly adversarial, substantial, or high-risk RustFS reviews.
|
||||
---
|
||||
|
||||
# Code Change Verification
|
||||
|
||||
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.
|
||||
|
||||
## Core Workflow
|
||||
|
||||
### 1) Scope and assumptions
|
||||
- Derive the change source, target branch, and relevant runtime/version from the
|
||||
supplied diff and metadata. Ask only when missing context could change the verdict.
|
||||
- Focus only on requested scope; avoid reviewing unrelated files.
|
||||
|
||||
### 2) Risk map
|
||||
- Prioritize in this order:
|
||||
- Data correctness and user-visible behavior
|
||||
- API/contract compatibility
|
||||
- Security and authz/authn boundaries
|
||||
- Concurrency and lifecycle correctness
|
||||
- Performance and resource usage
|
||||
- Give higher priority to stateful paths, migration logic, defaults, and error handling.
|
||||
|
||||
### 3) Evidence-based inspection
|
||||
- Read each modified hunk with neighboring context.
|
||||
- Trace call paths and call-site expectations.
|
||||
- Check for:
|
||||
- invariant breaks and missing guards
|
||||
- unchecked assumptions and null/empty/error-path handling
|
||||
- stale tests, fixtures, and configs
|
||||
- hidden coupling to shared helpers/constants/features
|
||||
- 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
|
||||
|
||||
For changed Rust behavior, use the matching sections of [rust-code-quality](../rust-code-quality/SKILL.md). Reuse checks already performed by the selected review workflow. Comment-only or formatting-only Rust diffs do not require the full Rust checklist. Carry its P0–P3 ratings over unchanged and use this skill's output format.
|
||||
|
||||
### 4) Findings-first output
|
||||
- Order supported findings by P0–P3 severity; preserve the Rust ratings above.
|
||||
Include `path:line`, the failure and impact, a focused fix, and its validation.
|
||||
- If no supported issues remain, state `No findings` with the reviewed scope and
|
||||
any material verification limitation. Do not append optional improvements to
|
||||
make a clean review look productive.
|
||||
|
||||
Close after the required review. Recommend additional verification only for an
|
||||
identified unresolved risk or required gate; reuse evidence for unchanged code.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Code Change Verification"
|
||||
short_description: "Prioritize risks and verify code changes before merge."
|
||||
default_prompt: "Use $code-change-verification for an ordinary requested diff review with prioritized findings."
|
||||
@@ -1,112 +1,13 @@
|
||||
---
|
||||
name: issue-triage
|
||||
description: Assess whether a GitHub issue is fixed, needs implementation, or can be closed by checking related work and current code. Use for issue completion/triage requests. Status questions are read-only; comment, close, or change labels only when the conversation authorizes that action.
|
||||
description: Verify issue completion against current implementation and merged work. Use for issue triage; comment, close, or change labels only when authorized.
|
||||
---
|
||||
|
||||
# Issue Triage
|
||||
|
||||
Use this skill when the user provides a GitHub issue URL and asks "can this be closed?", "is this already implemented?", "check completion status", or similar triage questions.
|
||||
- Resolve the issue repository separately from the implementation repository: `rustfs/backlog` tracks work in `rustfs/rustfs`. Pass `--repo` explicitly to GitHub queries.
|
||||
- Read the issue body, relevant comments, linked PRs, checklist, and sub-issues with `gh issue view`. Search explicit links, the full issue URL, qualified references, and subject keywords; an empty search result does not prove implementation is absent.
|
||||
- Fetch the implementation base and inspect its current code. For each merged candidate, verify `git merge-base --is-ancestor <merge-commit> <remote>/<base>`; a title, commit message, or merged state alone is insufficient. A local checkout may be stale or on another branch.
|
||||
- Verify every checklist/sub-issue before declaring completion. For batches, paginate the entire requested issue scope, exclude PR entries, and filter by author only when requested.
|
||||
- Recommend closing only when all requested behavior is present or evidence shows the issue is superseded. Otherwise name what remains. A status request is read-only; reuse existing authority for comments/closing/labels, and follow [Git and PR rules](../../references/pull-requests.md) before posting. Use Chinese for `rustfs/backlog`, existing repository labels only, and a closing comment naming the verified behavior and PRs.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Fetch issue context
|
||||
|
||||
```bash
|
||||
gh issue view <N> --repo <owner/repo> --json title,body,state,comments,labels,updatedAt
|
||||
```
|
||||
|
||||
Read the issue body to understand what was requested. Extract:
|
||||
- The specific feature/fix/behavior described.
|
||||
- Any linked PRs or commits mentioned in the body or comments.
|
||||
- Any checklist items or sub-issues.
|
||||
|
||||
Resolve the issue repository and implementation repository separately (for example, `rustfs/backlog` tracks work in `rustfs/rustfs`). Pass the implementation repository explicitly to PR queries; the current checkout may belong to another repository.
|
||||
|
||||
### 2. Search for related work
|
||||
|
||||
Search git history for commits referencing the issue:
|
||||
```bash
|
||||
git log --oneline --all --grep="<N>" | head -30
|
||||
```
|
||||
|
||||
Search for related PRs:
|
||||
```bash
|
||||
gh pr list --repo <implementation-repo> --search "<issue-url>" --state all --json number,title,state,mergedAt
|
||||
```
|
||||
|
||||
Also search qualified issue references and subject keywords; for same-repository
|
||||
issues, include `#<N>`. Follow explicit links even without a text match. A search
|
||||
page with no match does not prove the work is absent.
|
||||
|
||||
If the issue mentions specific PRs, check their status:
|
||||
```bash
|
||||
gh pr view <PR_N> --repo <implementation-repo> --json state,mergedAt,title,mergeCommit,baseRefName
|
||||
```
|
||||
|
||||
### 3. Verify implementation
|
||||
|
||||
Fetch the implementation repository's current base branch. For each merged candidate, verify its merge commit is present and inspect the current code for the claimed behavior; a commit message match alone is not proof:
|
||||
```bash
|
||||
git fetch <implementation-remote> <base-branch>
|
||||
git merge-base --is-ancestor <merge-commit> <implementation-remote>/<base-branch>
|
||||
```
|
||||
|
||||
If the issue describes a specific defect, inspect the fetched base's code rather than assuming the current checkout contains it:
|
||||
```bash
|
||||
git show <implementation-remote>/<base-branch>:crates/<relevant>/src/<file>.rs
|
||||
```
|
||||
|
||||
For issues with checklists, verify each item individually. If sub-items are tracked as separate issues, check those too:
|
||||
```bash
|
||||
gh issue view <SUB_N> --repo <owner/repo> --json state
|
||||
```
|
||||
|
||||
### 4. Determine verdict
|
||||
|
||||
- **All items fixed and merged**: Recommend closing; name the verified PRs and behavior.
|
||||
- **Some items fixed, some remaining**: Keep open; report each remaining item.
|
||||
- **Not yet implemented**: Keep open; report what remains.
|
||||
- **Superseded or no longer relevant**: Recommend closing with evidence.
|
||||
|
||||
### 5. Take action
|
||||
|
||||
For a status-only request, return the assessment without GitHub writes. If commenting, closing, or label edits are authorized, perform only those actions; do not ask again for authority already given. Prepare the final assessment before asking for any missing authority. Write `rustfs/backlog` issue content in Chinese.
|
||||
|
||||
Close with comment:
|
||||
```bash
|
||||
gh issue close <N> --repo <owner/repo> --comment "<body>"
|
||||
```
|
||||
|
||||
Comment without closing:
|
||||
```bash
|
||||
gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md
|
||||
```
|
||||
|
||||
Update labels only when label changes are authorized, using existing repository labels; never add tool-specific labels:
|
||||
```bash
|
||||
gh issue edit <N> --repo <owner/repo> --add-label "<existing-label>"
|
||||
```
|
||||
|
||||
Always use `--body-file` for multiline content, never inline `--body`.
|
||||
|
||||
### 6. Handle multi-issue batches
|
||||
|
||||
When the user asks to check multiple issues (e.g., "check all issues by user X" or "scan backlog for closable issues"):
|
||||
1. List the full requested scope with pagination (for example `gh api --paginate 'repos/<repo>/issues?state=open&per_page=100'`, excluding entries with `pull_request`). Add an author filter only when the user requested one; the default page/limit is not evidence that all issues were checked.
|
||||
2. For each issue, run steps 1-5 above.
|
||||
3. Report a summary table of all triaged issues with verdicts.
|
||||
|
||||
## Report
|
||||
|
||||
Identify the issue and current state, verified implementation/PR evidence,
|
||||
remaining items, verdict, and action actually taken. Use a table for batches;
|
||||
a single issue does not require a heading for each field. Follow step 4's
|
||||
verdicts without repeating the assessment in another template.
|
||||
|
||||
## Notes
|
||||
|
||||
- The user may ask in Chinese ("是否可以关闭", "检查完成情况"); respond in the same language.
|
||||
- When closing, always include a summary of what was fixed and which PRs resolved it — this creates a useful audit trail.
|
||||
- For issues in `rustfs/backlog`, use `--repo rustfs/backlog`.
|
||||
- For issues in `rustfs/rustfs`, use `--repo rustfs/rustfs`.
|
||||
- If the issue has sub-issues (GitHub sub-issues API), check each one's state before declaring the parent complete.
|
||||
Report the issue, current state, implementation evidence, remaining work, verdict, and action actually taken. Use a table for batches. Write multiline comments via `--body-file`; when closing, post the prepared comment before closing without an inline multiline body.
|
||||
|
||||
@@ -1,93 +1,20 @@
|
||||
---
|
||||
name: pr-review
|
||||
description: Review a GitHub PR from a URL or number using its actual base/head and risk-appropriate code review. Use when the user asks for a PR review, not a status lookup or PR wording edit. Publish a review only when authorized; delegation and monitoring follow the requested scope and root AGENTS.md.
|
||||
description: Review a GitHub PR using its exact base/head and repository risk policy. Use for requested PR reviews; publish or fix only within conversation authorization.
|
||||
---
|
||||
|
||||
# PR Review
|
||||
|
||||
Use this skill for PR context and review delivery. An ordinary review request is read-only unless the conversation also authorizes posting or fixes. Reuse that authorization without asking again; prepare the review before requesting any missing publication approval.
|
||||
1. Resolve the PR repository and remote; the current checkout may belong elsewhere. Read the PR purpose and linked issues, then fetch the actual base and PR head:
|
||||
|
||||
## Prerequisites
|
||||
```bash
|
||||
gh pr view <N> --repo <owner/repo> --json title,author,state,body,baseRefName,headRefName,baseRefOid,headRefOid
|
||||
git fetch <repo-remote> <baseRefName> refs/pull/<N>/head
|
||||
git diff <baseRefOid>...<headRefOid>
|
||||
```
|
||||
|
||||
- Follow root `AGENTS.md`; classify risk with the [review policy](../../references/adversarial-validation.md) and consult relevant [change-style and boundary rules](../../references/implementation.md).
|
||||
- Select `code-change-verification` for ordinary review or `adversarial-validation` for explicitly adversarial, substantial, or high-risk review; do not run both on the same diff.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Gather PR context
|
||||
|
||||
```bash
|
||||
gh pr view <N> --repo <owner/repo> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName,baseRefOid,headRefOid
|
||||
gh pr diff <N> --repo <owner/repo> --name-only
|
||||
```
|
||||
|
||||
Read the PR body and linked issues to understand the change's purpose. If the PR references an issue, fetch that too:
|
||||
```bash
|
||||
gh issue view <ISSUE> --repo <issue-owner/repo> --json title,body,state
|
||||
```
|
||||
|
||||
### 2. Fetch the diff and classify the change
|
||||
|
||||
```bash
|
||||
git fetch <repo-remote> <baseRefName> refs/pull/<N>/head
|
||||
git diff <baseRefOid>...<headRefOid> --stat
|
||||
```
|
||||
|
||||
Resolve `<repo-remote>` to the PR repository; do not assume the current checkout's `origin` or `main` matches. Record the exact base/head used. If either moved during fetching, refresh the snapshot before reviewing. Classify using the repository review policy; instruction changes that affect agent execution are mechanical, not exempt.
|
||||
|
||||
### 3. Review the changed behavior
|
||||
|
||||
Group files by functional area to trace callers and invariants. Use the root risk tier's review shape and only matching lenses. File count does not authorize delegation. When delegation is explicitly authorized, high-risk/substantial reviews use exactly two independent reviewers with the applicable lenses split between them; otherwise use two fresh sequential passes. Reviewers do not spawn further agents.
|
||||
|
||||
Findings need a concrete failure scenario with `file:line`; a null verdict briefly names the relevant probes. Reuse existing evidence and choose local checks from the final diff under the root verification policy.
|
||||
|
||||
### 4. Check CI status
|
||||
|
||||
```bash
|
||||
gh pr checks <N> --repo <owner/repo>
|
||||
```
|
||||
|
||||
Investigate a failed check when it bears on a finding or the user requested CI diagnosis/merge readiness:
|
||||
```bash
|
||||
gh run view --repo <owner/repo> --log-failed --job=<JOB_ID>
|
||||
```
|
||||
|
||||
Use current evidence to distinguish pre-existing, flaky, and PR-caused failures. Do not classify them by guesswork or turn a code-only review into unrelated CI repair.
|
||||
|
||||
### 5. Synthesize findings
|
||||
|
||||
Report the PR, reviewed base/head, and risk tier, then summarize the assessment.
|
||||
Use the selected review's P0–P3 ratings and root finding standard: supported
|
||||
findings with `file:line`, failure scenario, and fix, or `No findings`.
|
||||
State the observed check status, including pending or unavailable checks, and
|
||||
the verdict (`APPROVE`, `REQUEST_CHANGES`, or `COMMENT`). Do not infer a pass
|
||||
from missing checks or add style nits to populate a clean review.
|
||||
|
||||
### 6. Post the review
|
||||
|
||||
Only when posting is authorized, write the review body to a temp file and post via CLI. Refresh the PR head first; if it changed, review the delta and update the verdict before posting:
|
||||
```bash
|
||||
# Request changes
|
||||
gh pr review <N> --repo <owner/repo> --request-changes --body-file /tmp/pr_review.md
|
||||
|
||||
# Approve
|
||||
gh pr review <N> --repo <owner/repo> --approve --body-file /tmp/pr_review.md
|
||||
|
||||
# Comment only (no verdict)
|
||||
gh pr review <N> --repo <owner/repo> --comment --body-file /tmp/pr_review.md
|
||||
```
|
||||
|
||||
For authorized inline comments, use [the submission example](references/posting.md).
|
||||
|
||||
Always use `--body-file` or `--input`, never inline multiline `--body`.
|
||||
|
||||
### 7. Handle follow-up
|
||||
|
||||
Follow the [PR lifecycle](../../references/pull-requests.md) and any explicit monitoring request. For follow-up, fetch the new head and compare the recorded reviewed SHA with the new SHA; revisit affected callers and findings. Never use an unfetched `origin/pull/<N>/head` ref as evidence. Update the posted review or resolve addressed threads only within existing authorization.
|
||||
|
||||
## Notes
|
||||
|
||||
- The user may ask for review in Chinese; respond in the same language but keep the review body in English per AGENTS.md rules.
|
||||
- When the user asks for "多角色对抗 review", run the full adversarial validation protocol — this skill's step 3 covers that.
|
||||
- If the PR is from a fork, check `maintainerCanModify` before attempting to push fixes.
|
||||
- For very large PRs, batch the review by functional area while keeping the same bounded review shape.
|
||||
Record both SHAs; refresh if either moved during fetching. Follow relevant [implementation boundaries](../../references/implementation.md).
|
||||
2. Apply the [risk policy](../../references/adversarial-validation.md). Ordinary reviews use the root finding standard directly. For explicit adversarial, substantial, or high-risk reviews, use [the domain probes](../adversarial-validation/SKILL.md). File count does not authorize delegation; use exactly two independent reviewers only when authorized, otherwise two fresh sequential passes for that risk tier. Reviewers do not spawn further agents.
|
||||
3. Check `gh pr checks <N> --repo <owner/repo>`. Investigate failures when relevant to a finding or a requested CI/readiness diagnosis. Distinguish pending, unavailable, pre-existing, flaky, and PR-caused states using evidence; a code review does not authorize unrelated repairs.
|
||||
4. Report the reviewed SHAs, risk tier, supported findings or `No findings`, material verification limits, check state, and verdict. P0/P1 findings block approval. Severity: P0 = demonstrated data loss, security breach, remote crash, or deadlock; P1 = correctness, compatibility, or material hot-path regression; P2 = a concrete maintainability defect; P3 = optional style, only when requested.
|
||||
5. When posting is authorized, follow [Git and PR rules](../../references/pull-requests.md), recheck the remote head, and review any delta before submitting. Use `gh pr review` with `--approve`, `--request-changes`, or `--comment` and `--body-file`; use [the API example](references/posting.md) for authorized inline comments. Reuse existing authorization.
|
||||
6. Follow the repository PR lifecycle and explicit monitoring scope. Fetch new heads before comparing to the reviewed SHA; never rely on an unfetched `origin/pull/<N>/head`. Push fixes or resolve threads only when authorized; check `maintainerCanModify` before a fork push.
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
name: rust-code-quality
|
||||
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
|
||||
|
||||
Use this skill for a dedicated Rust review to cover rules that `cargo clippy`
|
||||
does not catch.
|
||||
|
||||
Search matches and checklist items are candidates, not findings. Apply the root
|
||||
finding standard; distinguish a demonstrated bug, an explicit rule violation,
|
||||
and an optional preference. P2/P3 suggestions do not need to be invented or
|
||||
included in an otherwise clean correctness review.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Identify changed `.rs` files.
|
||||
2. Run the matching candidate searches on changed files.
|
||||
3. Apply the manual checklist sections whose behavior the diff touches.
|
||||
4. Report or rebut every finding with evidence; P0/P1 findings block approval.
|
||||
Fix them when implementation is authorized; a read-only review reports them.
|
||||
|
||||
## Automated Checks
|
||||
|
||||
Use these searches to find candidates in changed `.rs` files. Inspect syntax,
|
||||
`#[cfg(test)]` scope, and the changed hunk before reporting a finding; text
|
||||
filters do not reliably distinguish production code from tests.
|
||||
|
||||
```bash
|
||||
# 1. unwrap/expect candidates
|
||||
rg -n '\.unwrap\(\)|\.expect\(' <changed-files>
|
||||
|
||||
# 2. Silent type truncation via `as` cast
|
||||
rg -n ' as (u8|u16|u32|u64|usize|i8|i16|i32|i64|isize)\b' <changed-files>
|
||||
|
||||
# 3. String as error type
|
||||
rg -n 'Result<.*String>' <changed-files>
|
||||
|
||||
# 4. Box<dyn Error> in public APIs
|
||||
rg -n 'Box<dyn.*Error' <changed-files>
|
||||
|
||||
# 5. println/eprintln in production
|
||||
rg -n 'println!|eprintln!' <changed-files>
|
||||
|
||||
# 6. Ordering::Relaxed usage (verify each is intentional)
|
||||
rg -n 'Ordering::Relaxed' <changed-files>
|
||||
|
||||
# 7. Default substituted for a possibly-required value (judge each: is the value optional by domain?)
|
||||
rg -n 'unwrap_or_default\(\)|unwrap_or\(' <changed-files>
|
||||
```
|
||||
|
||||
## Manual Review Checklist
|
||||
|
||||
For the Rust diff under review, verify:
|
||||
|
||||
### Error Handling
|
||||
- [ ] Every production `unwrap()` or `expect()` is infallible by type or a checked invariant; explain only non-obvious invariants, using an existing type, a useful `expect` message, or a concise comment
|
||||
- [ ] No `Result<_, String>` in public API signatures
|
||||
- [ ] Public library APIs use domain errors unless deliberate error erasure at a boundary is part of the contract
|
||||
- [ ] `Error::source()` is overridden when inner error is stored
|
||||
- [ ] Error messages are actionable without exposing secret input
|
||||
|
||||
### Type Safety
|
||||
- [ ] No silent `as` truncation (negative→unsigned, large→small)
|
||||
- [ ] Fallible numeric conversions use `TryFrom`/`try_into()` and return a typed error; clamp or saturate only when the domain explicitly requires it
|
||||
- [ ] Floating-point to integer conversion validates finiteness, sign, and range before conversion
|
||||
|
||||
### Concurrency
|
||||
- [ ] Lock acquisition order is documented when multiple locks are used, and matches every other call site taking any overlapping subset (ABBA check)
|
||||
- [ ] No `tokio::sync` lock guard (read or write) held across `.await` without bounded hold time — long-lived read guards wedge writers (#4195)
|
||||
- [ ] Atomic read-modify-write uses the direct `fetch_*` operation when possible; use `compare_exchange` only for conditional updates
|
||||
- [ ] `std::sync::Mutex` in async context is held only briefly, never across `.await`
|
||||
|
||||
### Memory and Performance
|
||||
- [ ] On an identified hot path, report cloning or allocation only with a concrete per-request/per-object cost or benchmark signal
|
||||
- [ ] Prefer borrowing, moving, `Bytes`/`Arc`, or capacity reservation only when it reduces that cost without obscuring ownership or APIs
|
||||
|
||||
### Recursion Safety
|
||||
- [ ] Recursion over untrusted, persisted, or otherwise unbounded input has a depth limit or uses iterative traversal
|
||||
- [ ] Tree/cache traversals handle corrupted/cyclic input safely
|
||||
|
||||
### Testing
|
||||
- [ ] Tests have an observable failure criterion; delegated assertions, `#[should_panic]`, snapshot/property checks, and meaningful `Result` failures do not need a redundant `assert!`
|
||||
- [ ] Use `expect` only when its message improves failure diagnosis; do not add boilerplate to self-evident test setup
|
||||
- [ ] Test volume and line count are never treated as production-code growth
|
||||
|
||||
### Serde
|
||||
- [ ] Structs from untrusted input reject unknown fields where the compatibility contract permits; otherwise validate security-critical fields explicitly and test the supported input shape
|
||||
- [ ] `#[serde(default)]` not used on security-critical fields without validation
|
||||
|
||||
### Code Hygiene
|
||||
- [ ] No `#![allow(dead_code)]` at crate root
|
||||
- [ ] No camelCase statics or Hungarian notation
|
||||
- [ ] New string literals don't duplicate existing constants
|
||||
|
||||
### Reuse and Necessity
|
||||
- [ ] No new helper duplicates `crates/utils`, `crates/common`, the touched crate, the likely domain-owning crate, a relevant direct dependency, or plain std/tokio behavior; reused helpers match the call site's semantics
|
||||
- [ ] No branch without a nameable concrete trigger; no re-validation of what a validated upstream layer on the same path already guarantees (Cross-Cutting Domain Invariant patterns and pre-destructive-action re-checks are load-bearing — keep them)
|
||||
- [ ] Error context attached once where actionable, not re-wrapped at every hop; no typed→generic error conversion below aggregation/quorum layers
|
||||
- [ ] Comments avoid narration and change history while completely stating non-obvious lock, `SAFETY`, durability, compatibility, and unwrap invariants
|
||||
- [ ] No near-duplicate test pinning the same code path and poison-value class as an existing test (boundary companions — n==max vs max+1, absent/empty/nil UUID — are never near-duplicates)
|
||||
|
||||
## Severity Classification
|
||||
|
||||
- **P0 (Block merge)**: demonstrated data loss, security breach, remote crash, or deadlock
|
||||
- **P1 (Must fix)**: concrete correctness, compatibility, or material hot-path regression
|
||||
- **P2 (Should fix)**: avoidable duplication or maintainability issue with a concrete simpler replacement
|
||||
- **P3 (Nice to fix)**: local style or clarity issue with no behavioral risk
|
||||
|
||||
## Output Template
|
||||
|
||||
Use the calling review's output format. For a standalone review, report supported
|
||||
findings with severity, location, impact, fix, and validation, or `No findings`.
|
||||
Include only material unverified checks. Candidate counts are not a quality
|
||||
metric and do not need a separate scan report.
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: rustfs-logging-governance
|
||||
description: Add or review RustFS `tracing` events with the repository field shape, level policy, privacy boundaries, and guardrails. Use when a change adds or edits a tracing macro/instrumentation site or the logging guardrail script.
|
||||
---
|
||||
|
||||
# RustFS Logging Governance
|
||||
|
||||
Apply this skill only to changed logging sites; do not turn a local log edit into
|
||||
a broad logging cleanup.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Read the changed function/module context and classify the site as lifecycle,
|
||||
request/hot path, fallback, external fetch, or summary.
|
||||
2. Match neighboring structured events and reuse existing `EVENT_*`,
|
||||
`LOG_COMPONENT_*`, and `LOG_SUBSYSTEM_*` constants.
|
||||
3. Put stable fields first (`event`, `component`, `subsystem`, `state`/`result`,
|
||||
then context) and a short label last.
|
||||
4. Select the level by operational meaning:
|
||||
- `error`: behavior/security-affecting failure;
|
||||
- `warn`: degraded/fallback/operator-actionable state;
|
||||
- `info`: low-frequency lifecycle/mode change;
|
||||
- `debug`: targeted diagnostics;
|
||||
- `trace`: repetitive request/object/shard success paths.
|
||||
5. Never log secrets, tokens, auth headers, credential payloads, raw
|
||||
attacker-controlled bodies, or merged config dumps. Error strings and
|
||||
`Debug` output are log surfaces too.
|
||||
6. Prefer one aggregate summary over inventories or startup banners.
|
||||
7. Run `./scripts/check_logging_guardrails.sh` and the checks selected by root
|
||||
`AGENTS.md`.
|
||||
|
||||
Read [logging-governance.md](references/logging-governance.md) only for a broad
|
||||
logging audit, event-model migration, or guardrail expansion. Ordinary single-
|
||||
site edits do not require the full workspace scope map.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "RustFS Logging Governance"
|
||||
short_description: "Standardize RustFS logs with structured events and guardrails."
|
||||
default_prompt: "Use $rustfs-logging-governance to standardize or review RustFS logging, reduce noise, and update guardrails."
|
||||
@@ -1,165 +1,42 @@
|
||||
---
|
||||
name: rustfs-release-publish
|
||||
description: "Run the RustFS console gate, source version bump, preview validation, human confirmation, final-tag publication, and post-release installation and website updates. Use only when the user explicitly asks to release or publish a RustFS version (发版/发布)."
|
||||
description: Publish a RustFS version through Console, release-branch CI, preview acceptance, human confirmation, and final publication. Use only for an explicit release request.
|
||||
---
|
||||
# RustFS Release Publish (preview-validated pipeline)
|
||||
# RustFS Release
|
||||
|
||||
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.
|
||||
Require the exact final SemVer target before version edits or publication. If unspecified, inspect current releases/tags and ask with concrete candidates; do not guess the channel or bump. A named bump type resolves to an exact version that still needs confirmation. Report the confirmed target verbatim; resolve any later mismatch before proceeding. Independent read-only preflight may continue.
|
||||
|
||||
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.
|
||||
## Invariants
|
||||
|
||||
Pipeline shape:
|
||||
- Cargo stores `<target>`, never `-preview.N`. Before publication, installation defaults retain the previous available deliverable. Final and preview binaries embed their own tag names, so version/asset names differ despite identical source.
|
||||
- Use annotated tags without `v`. Preview names are exactly `<target>-preview.<digits>`; choose the next unused iteration after fetching tags. Preview Releases are prereleases, never Latest; publish only versioned assets/exact Docker tags, never latest assets, R2, Helm, moving Docker channels, or installation/banner updates.
|
||||
- `release/<target>` owns the candidate. Retain its history, backport only reviewed fixes, and never merge main wholesale or force-push it. Pin `PREVIEW_HASH` to its exact commit with complete passing CI. Preview and final tags both point there; never tag a later main/branch HEAD or insert another bump.
|
||||
- Phase failures block dependents. Keep preview Releases until final publication and notes generation finish; CI removes Releases while retaining tags. Never move an existing preview or final tag.
|
||||
|
||||
```
|
||||
check console main against its latest Release
|
||||
-> if ahead: publish console -> wait for Release asset + latest API
|
||||
-> bump Cargo.toml and Cargo.lock to <target> -> merge
|
||||
-> tag <preview-tag> at that commit -> CI green
|
||||
-> verify preview Release assets -> run binary locally + console checks
|
||||
-> validate with latest rc client
|
||||
-> report preview acceptance results -> STOP for explicit human confirmation
|
||||
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
|
||||
-> CI deletes the <target>-preview.N Releases (tags kept)
|
||||
-> verify published images -> publish Helm chart from the validated source
|
||||
-> update installation references on main -> update rustfs.com announcement
|
||||
```
|
||||
## Read by Phase
|
||||
|
||||
On preview validation failure: fix lands on main via normal PR (Cargo versions are already at `<target>`, no new source bump PR), then tag `<preview-tag N+1>` at the new main commit and restart from Phase 2. Installation references remain on the previous published deliverable throughout preview validation.
|
||||
Read only the current phase's procedure; retain completed evidence across turns. Follow root verification, Git/PR, and authorization rules.
|
||||
|
||||
## Required inputs
|
||||
| Phase | Procedure |
|
||||
|---|---|
|
||||
| 0 | Clean tree, fetch main/tags, verify GitHub access, then complete the mandatory [Console release gate](references/console-gate.md). A green Console build alone is insufficient. |
|
||||
| 1–2 | [Prepare/reuse the release candidate and run exact-SHA branch CI](references/release-candidate.md). Source version changes use `rustfs-release-version-bump` with authorized delivery scope. |
|
||||
| 3 | [Verify the preview build, assets, channels, and notes](references/preview-release.md). |
|
||||
| 4–5 | [Run the downloaded binary, Console CRUD, and latest-rc matrix](references/preview-acceptance.md). Every check must pass. |
|
||||
| Confirmation | Complete the gate below and end the turn. |
|
||||
| 6 | After fresh confirmation, [publish and verify the final tag](references/final-release.md) at `PREVIEW_HASH`. |
|
||||
| 7 | [Maintain milestones, installation references, and the website](references/post-release-updates.md) only after complete publication. |
|
||||
|
||||
- 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`).
|
||||
## Human Confirmation
|
||||
|
||||
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).
|
||||
After Phases 3–5 pass, recheck that the release branch still equals `PREVIEW_HASH`. Report the target, branch, preview tag/hash, branch CI run, preview Release URL, Console result, and rc matrix. Explicitly ask whether to publish the final tag and end the turn without creating/pushing it.
|
||||
|
||||
## Semver gate — resolve the target before version edits or publication
|
||||
Only a new affirmative user reply to that exact report authorizes Phase 6. The original release request, earlier confirmation, silence, or automated follow-up does not count. Confirmation binds the target, preview tag, and hash; a changed value or repeated/failed acceptance cycle invalidates it. Revalidate and obtain fresh confirmation before continuing.
|
||||
|
||||
Versions follow [SemVer 2.0.0](https://semver.org/). Precedence reminder:
|
||||
## Recovery
|
||||
|
||||
```
|
||||
1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta.2 < 1.0.0-beta.11 < 1.0.0-rc.1 < 1.0.0 < 1.0.1 < 1.1.0 < 2.0.0
|
||||
```
|
||||
- Before the final tag exists, land fixes on main, backport to the candidate, verify target versions, and restart branch CI/acceptance. Use a new preview iteration once a preview was tagged; main may advance independently.
|
||||
- Once the final tag exists, retry publication jobs for that exact tag. A source fix requires a newly confirmed target, not retagging or a newer preview. Phase 7 retries recheck artifact availability and superseding releases.
|
||||
- If abandoned after the bump merged, report the unpublished version on main and record whether to revert or let the next release overwrite it. Deleting candidate branches or preview tags requires an explicit decision.
|
||||
|
||||
Numeric prerelease identifiers compare numerically (`beta.9 < beta.10`), not lexically — see [semver.org spec item 11](https://semver.org/#spec-item-11). Preview tags are internal validation tags layered on top of the target's prerelease channel — they are never themselves a deliverable version and never appear in version files.
|
||||
|
||||
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 with concrete candidates, e.g. from `1.0.0-beta.10`: next prerelease `1.0.0-beta.11`, promote to `1.0.0-rc.1`, promote to stable `1.0.0`. Never guess between these — they have very different meanings (channel promotion vs. iteration) and different CI classification consequences.
|
||||
- After a stable `X.Y.Z` exists, the next version must state which component bumps: patch `X.Y.(Z+1)` for fixes only, minor `X.(Y+1).0` for backward-compatible features, major `(X+1).0.0` for breaking changes. If the user names a bump type but not a number, compute it from the latest stable tag and echo the exact resulting version back for confirmation.
|
||||
- Echo the final confirmed version string verbatim in your first status report; every later phase must use exactly that string. If at any point the user's wording and the confirmed version diverge, stop and re-confirm.
|
||||
|
||||
## Preview tag naming
|
||||
|
||||
- Use `<target>-preview.N` for every target, e.g. `1.0.0-beta.10-preview.3` or `1.1.0-preview.1`.
|
||||
- The canonical suffix is exactly `-preview.<digits>`. `build.yml` recognizes it before alpha/beta/rc classification and routes it to the preview-only path; any other tag containing `-preview` fails closed instead of being treated as a release.
|
||||
- A preview Release MUST be published with `isPrerelease=true` and `isLatest=false`. Any `*-latest` preview asset or preview-triggered `latest.json`, R2, Helm, or moving Docker channel tag is a pipeline failure. Docker images may use the exact preview version tag, including its existing variant suffixes.
|
||||
- Preview Releases are cleaned up by the `cleanup-preview-releases` job after `publish-release` succeeds for the deliverable tag. It deletes every Release whose tag is exactly `<target>-preview.<digits>` and never passes `--cleanup-tag`, so the tags survive.
|
||||
|
||||
## Hard rules
|
||||
|
||||
- Before preview, bump only Cargo.toml (workspace package and internal dependency versions) and the corresponding workspace members in Cargo.lock, directly to `<target>`. Keep README installation examples, flake.nix, Chart.yaml, and rustfs.spec on the previous published deliverable until Phase 7. Never write a `-preview.N` suffix into these files; preview identifiers belong to validation tags and artifacts.
|
||||
- Publishing a tag alone does not make an installation target available. Verify the exact image manifest, Release assets, and source archive before advancing references that consume them. Helm packaging derives its versions from the final tag and waits for that image; it does not require an early Chart.yaml bump on main.
|
||||
- Preview Release assets are versioned and intentionally visible on the Releases page for the duration of validation. Do not label them Latest or use them to update any latest distribution channel.
|
||||
- Never delete a preview Release by hand before Phase 6 finishes — Phase 4 downloads its assets and the final Release notes are generated while it still exists. Cleanup is CI's job; only step in manually (`gh release delete "<preview-tag>" --yes`, never `--cleanup-tag`) if `cleanup-preview-releases` failed.
|
||||
- Tags have no `v` prefix. Always annotated: `git tag -a <tag> -m "Release <tag>"`.
|
||||
- The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final. Phase 7 updates installation references on main after publication; neither tag moves to that follow-up commit.
|
||||
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag — cleanup runs after the notes are generated, so the preview Release is still present and would otherwise be picked as the baseline. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
|
||||
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
|
||||
- Phases run in order; a failure blocks dependent steps. For a preview/source acceptance failure before the final tag exists, land the fix, verify Cargo still matches the confirmed target, and restart from Phase 2 with the next preview at the fixed commit. If main has advanced to another target, resolve that source/version mismatch before another preview.
|
||||
- Once the final tag exists, preserve its commit. Retry failed publication jobs for that exact tag; do not recreate the tag or restart preview on newer main. If a source change is required after final-tag publication, report the failure and obtain a new target version. Phase 7 failures resume only the failed follow-up after rechecking artifact availability and whether a newer release has superseded it.
|
||||
- Completing preview acceptance does not authorize the final tag. After Phases 3–5 pass, report the acceptance evidence and stop until the user explicitly confirms continuation. The original release request, an earlier confirmation, silence, or an automated follow-up does not satisfy this gate.
|
||||
- Confirmation is scoped to the reported `<target>`, `<preview-tag>`, and `PREVIEW_HASH`. A failed or repeated acceptance cycle, including any new preview iteration, invalidates prior confirmation and requires a new one.
|
||||
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
|
||||
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
|
||||
|
||||
## Phase 0 — Preflight
|
||||
|
||||
- `git status --short` clean; `git fetch origin main --tags`.
|
||||
- `gh auth status` works; confirm you can view `gh release list -L 3`.
|
||||
- Confirm the exact final target version with the user if not explicit.
|
||||
|
||||
### Console release gate
|
||||
|
||||
Read and complete [the Console gate](references/console-gate.md) before Phase 1. Verify the latest published Console asset and exact commit; if Console main is ahead, complete its release and asset verification first. A successful build alone does not satisfy this gate.
|
||||
|
||||
## Phase 1 — Source version bump to the final target (once)
|
||||
|
||||
- If Cargo.toml and all workspace members in Cargo.lock already read `<target>` (e.g. this is a restart after a failed preview), verify both and skip the source bump. Installation versions intentionally differ during preview; do not treat them as leftovers. If a previous preparation advanced installation references to an unavailable target, restore those references to the verified published baseline before selecting the next preview commit.
|
||||
- Otherwise invoke the `rustfs-release-version-bump` skill with stage `prepare`, the final `<target>` (NOT a preview version), and full GitHub flow (commit/push/PR).
|
||||
- Get the PR merged into main. Record the resulting main commit:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
PREVIEW_HASH=$(git rev-parse origin/main) # must contain the bump PR
|
||||
```
|
||||
|
||||
`PREVIEW_HASH` is the single source of truth for the rest of the pipeline — report it to the user and reuse it verbatim in Phases 2 and 6. Both the preview tag and the final tag will point at it.
|
||||
|
||||
## Phase 2 — Publish the preview tag
|
||||
|
||||
```bash
|
||||
git tag -a "<preview-tag>" -m "Release <preview-tag>" "$PREVIEW_HASH"
|
||||
git push origin "<preview-tag>"
|
||||
```
|
||||
|
||||
Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`.
|
||||
|
||||
The preview run builds versioned artifacts and publishes them in a GitHub prerelease. Docker may publish exact preview image tags. Latest-channel, R2, and Helm publication must be skipped; installation references and the website announcement stay unchanged.
|
||||
|
||||
On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first — it must contain the fix — and re-report it.
|
||||
|
||||
## Phase 3 — CI and preview Release verification
|
||||
|
||||
- Find and watch the tag build: `gh run list --workflow build.yml --branch "<preview-tag>" --limit 1` then `gh run watch <run-id>`. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64).
|
||||
- Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped.
|
||||
- Verify `gh release view "<preview-tag>" --json isPrerelease,assets,url`: `isPrerelease` must be `true`, and the Release must contain all 6 versioned platform zips, checksums, SBOM, and provenance with no `-latest` assets. Confirm `gh api repos/{owner}/{repo}/releases/latest --jq .tag_name` does not return `<preview-tag>`.
|
||||
- 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 Helm is skipped. If preview Docker images are published, confirm they use only exact preview tags (and variant suffixes), with no `latest`, `alpha`, `beta`, or `rc` channel updates. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; it does not authorize advancing any installation default.
|
||||
|
||||
## Phases 4–5 — Local artifact, Console, and rc acceptance
|
||||
|
||||
Read and complete [preview acceptance](references/preview-acceptance.md): verify the downloaded binary's tag/SHA and readiness, exercise Console CRUD with byte-identical download, and pass the full latest-rc command matrix. Any failure blocks final publication. Retain the results for the confirmation gate below.
|
||||
|
||||
### Manual confirmation gate
|
||||
|
||||
After every Phase 3–5 check passes, report the target, preview tag, `PREVIEW_HASH`, preview Release URL, console result, and rc matrix, then explicitly ask the user whether to publish the final tag. End the turn without creating or pushing `<target>`.
|
||||
|
||||
Continue to Phase 6 only after a new user reply explicitly confirms the reported target, preview tag, and commit. A clear affirmative reply to that exact report, such as `确认继续`, is sufficient; if the reply is ambiguous or any reported value changed, ask again.
|
||||
|
||||
## Phase 6 — Publish the final tag on the validated commit
|
||||
|
||||
No second source version bump. The final tag goes on the exact commit the preview validated:
|
||||
|
||||
```bash
|
||||
git fetch origin --tags
|
||||
git rev-parse "<preview-tag>^{commit}" # must equal PREVIEW_HASH — abort if not
|
||||
git tag -a "<target>" -m "Release <target>" "$PREVIEW_HASH"
|
||||
git push origin "<target>"
|
||||
```
|
||||
|
||||
- CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports `<target>`.
|
||||
- Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view "<target>"` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to `<target>`. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target.
|
||||
- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<target>` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`.
|
||||
- Verify the preview cleanup: `cleanup-preview-releases` must succeed, `gh release view "<preview-tag>"` must then report `release not found` for every preview iteration of this target, and `git rev-parse "<preview-tag>^{commit}"` must still resolve to `PREVIEW_HASH` (the tag is kept). If the job failed, delete the leftover Releases manually with `gh release delete "<preview-tag>" --yes` and report it.
|
||||
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
|
||||
|
||||
## Phase 7 — Installation references and website announcement
|
||||
|
||||
Only after Phase 6 succeeds, complete [post-release updates](references/post-release-updates.md): align installation references on main, then update the existing top banner in `rustfs/rustfs.com`. A failed or incomplete publication leaves both on the previous available version. These follow-up commits never change `PREVIEW_HASH` or either release tag. Track their PR, merge, and deployment states separately from artifact publication; an open PR is not a live website update.
|
||||
|
||||
## Output contract
|
||||
|
||||
Always report:
|
||||
|
||||
- Console gate result: previous/latest Console tags, whether merged changes required a release, `CONSOLE_HASH`, and Console run/Release URLs when a release was published.
|
||||
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
|
||||
- Manual confirmation gate status (`WAITING_FOR_CONFIRMATION` or `CONFIRMED`) and its exact target, preview tag, and `PREVIEW_HASH`.
|
||||
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: preview and final Release URLs, preview `isPrerelease`/`isLatest` state, final latest-channel state, console check results, the rc command matrix, and the preview-Release cleanup result (deleted Releases plus surviving tags).
|
||||
- Post-release installation PR and verification results; website banner target, text, link, PR, and observed deployment state. Report any remaining merge authorization or failed deployment explicitly rather than claiming the banner is live.
|
||||
- Any deviation from this pipeline and why the user approved it.
|
||||
Report completed phase results and incomplete work with evidence: Console previous/latest tags, release need, hash and run/Release URLs; target, branch CI/SHA, previews and hash, confirmation state; preview/final assets, latest-channel state, Console/rc results and cleanup; both milestone states, installation PR checks/merge state, and website text/link/PR/deployment state. Keep artifact publication separate from follow-up delivery; do not call an open PR a live update. Report deviations and their authorization.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Final Release Publication
|
||||
|
||||
Read only after fresh user confirmation of the preview acceptance report. The [parent confirmation gate](../SKILL.md#human-confirmation) applies.
|
||||
|
||||
## Phase 6 — Publish the final tag on the validated commit
|
||||
|
||||
No second source version bump. Before creating the final tag, recheck the remote `release/<target>` HEAD equals `PREVIEW_HASH`. If it moved after confirmation, the approval no longer applies; validate and obtain confirmation for a new preview. The final tag goes on the exact commit the preview validated:
|
||||
|
||||
```bash
|
||||
git fetch origin --tags
|
||||
git rev-parse "<preview-tag>^{commit}" # must equal PREVIEW_HASH — abort if not
|
||||
git tag -a "<target>" -m "Release <target>" "$PREVIEW_HASH"
|
||||
git push origin "<target>"
|
||||
```
|
||||
|
||||
- CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports `<target>`.
|
||||
- Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view "<target>"` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to `<target>`. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target.
|
||||
- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<target>` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`.
|
||||
- Verify the preview cleanup: `cleanup-preview-releases` must succeed, `gh release view "<preview-tag>"` must then report `release not found` for every preview iteration of this target, and `git rev-parse "<preview-tag>^{commit}"` must still resolve to `PREVIEW_HASH` (the tag is kept). If the job failed, delete the leftover Releases manually with `gh release delete "<preview-tag>" --yes` and report it.
|
||||
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
|
||||
|
||||
Never pass `--cleanup-tag` when removing preview Releases. Remove them manually only if `cleanup-preview-releases` failed, after final publication and notes generation. Record deleted Releases and surviving tags.
|
||||
@@ -1,7 +1,15 @@
|
||||
# Post-release Installation and Website Updates
|
||||
# Post-release Milestone, Installation, and Website Updates
|
||||
|
||||
Read only after the final non-preview tag has passed Phase 6. Tag creation or a green binary build alone is insufficient: the release assets, source archive, container images, Helm package, and applicable latest channels must be available. Preview tags never enter this phase.
|
||||
|
||||
## Version milestones
|
||||
|
||||
1. Query all pages of both open and closed milestones in the RustFS release repository, matching titles exactly to version strings without a `v` prefix. A failed or incomplete query is not evidence that a milestone is absent; resolve the lookup before making changes.
|
||||
2. Close the milestone matching the published `<target>` if it is open; an already closed milestone needs no change. If it is missing, report the gap and continue checking the next version. Do not create a substitute for the released milestone or close/reassign its issues or PRs.
|
||||
3. Use an explicitly planned next version when provided; otherwise, for a stable `X.Y.Z`, use `X.Y.(Z+1)`. For example, after `1.0.1` is published, close milestone `1.0.1` and ensure milestone `1.0.2` exists. For an alpha/beta/rc target with a trailing numeric counter, increment that counter within the same channel unless a different next version was specified. If the next version cannot be determined, ask for that version and report this follow-up as blocked. Creating a planning milestone does not confirm the target of a future release or bypass the semver gate.
|
||||
4. Reuse an existing next-version milestone, whether open or closed; do not reopen it or create a duplicate. Only when the complete query confirms it is absent, create an open milestone with the exact next-version title. Do not invent a due date or description. After an uncertain creation result, query again before retrying.
|
||||
5. Read back both milestones and report their titles, URLs, and actual states, including whether the next milestone was reused or created. A failed close, create, or verification leaves milestone maintenance incomplete; retry only the unfinished operation after refreshing live state. Continue independent installation and website work without moving or republishing the release tag.
|
||||
|
||||
## Installation references
|
||||
|
||||
1. Re-read the published deliverable state before edits or a retry. If a newer release has superseded this target, do not downgrade installation references or the website announcement. Report the superseding release and leave those defaults intact.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Preview Release Verification
|
||||
|
||||
Read for Phase 3 after the candidate passed exact-SHA CI. The [parent release invariants](../SKILL.md) apply.
|
||||
|
||||
## Preview channels
|
||||
|
||||
- Use `<target>-preview.N` for every target, e.g. `1.0.0-beta.10-preview.3` or `1.1.0-preview.1`.
|
||||
- The canonical suffix is exactly `-preview.<digits>`. `build.yml` recognizes it before alpha/beta/rc classification and routes it to the preview-only path; any other tag containing `-preview` fails closed instead of being treated as a release.
|
||||
- A preview Release MUST be published with `isPrerelease=true` and `isLatest=false`. Any `*-latest` preview asset or preview-triggered `latest.json`, R2, Helm, or moving Docker channel tag is a pipeline failure. Docker images may use the exact preview version tag, including its existing variant suffixes.
|
||||
- Preview Releases are cleaned up by the `cleanup-preview-releases` job after `publish-release` succeeds for the deliverable tag. It deletes every Release whose tag is exactly `<target>-preview.<digits>` and never passes `--cleanup-tag`, so the tags survive.
|
||||
|
||||
## Phase 3 — CI and preview Release verification
|
||||
|
||||
- Find and watch the tag build: `gh run list --workflow build.yml --branch "<preview-tag>" --limit 1` then `gh run watch <run-id>`. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64).
|
||||
- Confirm the preview tag resolves to the Phase 2 CI-validated `PREVIEW_HASH`. If the release branch advances during acceptance, repeat Phases 2–5 with a new preview tag.
|
||||
- Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped.
|
||||
- Verify `gh release view "<preview-tag>" --json isPrerelease,assets,url`: `isPrerelease` must be `true`, and the Release must contain all 6 versioned platform zips, checksums, SBOM, and provenance with no `-latest` assets. Confirm `gh api repos/{owner}/{repo}/releases/latest --jq .tag_name` does not return `<preview-tag>`.
|
||||
- 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 Helm is skipped. If preview Docker images are published, confirm they use only exact preview tags (and variant suffixes), with no `latest`, `alpha`, `beta`, or `rc` channel updates. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; it does not authorize advancing any installation default.
|
||||
|
||||
## Release notes
|
||||
|
||||
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag — cleanup runs after the notes are generated, so the preview Release is still present and would otherwise be picked as the baseline. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
|
||||
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Release Candidate Preparation
|
||||
|
||||
Read for Phases 1–2. The [parent release invariants](../SKILL.md) and Console gate apply.
|
||||
|
||||
## Candidate Resume Preflight
|
||||
|
||||
- `git status --short` clean; `git fetch origin main --tags`.
|
||||
- `gh auth status` works; confirm you can view `gh release list -L 3`.
|
||||
- Confirm the exact final target version with the user if not explicit.
|
||||
- Check whether `release/<target>` already exists before creating it; a resumed release reuses the existing branch and its history. For an in-flight release that already has a preview tag but no branch, anchor the new branch to that tag's commit only after verifying its target versions and prior acceptance evidence.
|
||||
|
||||
## Phase 1 — Source version bump to the final target (once)
|
||||
|
||||
- If `release/<target>` exists, verify its Cargo.toml and workspace members in Cargo.lock read `<target>` and skip the source bump, even if main has advanced. If the branch versions differ, stop and resolve the mismatch without downgrading main.
|
||||
- If no branch exists but an unfinished preview tag does, verify its commit has `<target>` in both version files and skip the bump; Phase 2 anchors the branch to that commit. Stop if those files disagree. Otherwise, if main already has `<target>` in both files, skip the bump. Installation references intentionally retain the previous published deliverable during preview; if they were advanced to an unavailable target, restore that baseline before choosing the next candidate.
|
||||
- Only when neither the release branch, an unfinished preview tag, nor current main already has `<target>`: if main is at a later version, select and verify a historical commit containing `<target>` rather than downgrading main; block if none exists. Otherwise invoke the `rustfs-release-version-bump` skill with stage `prepare`, the final `<target>` (NOT a preview version), and full GitHub flow (commit/push/PR).
|
||||
- If a bump PR was needed, get it merged into main and record its merge commit. For a new release branch when main already has `<target>`, identify the main commit that introduced those versions. Phase 2 chooses a release candidate containing that commit; an existing release branch keeps its own history and a newer main HEAD is not required.
|
||||
|
||||
Do not wait for the moving latest main HEAD to become green. The release-branch CI in Phase 2 validates the chosen commit before a preview tag is created.
|
||||
|
||||
## Phase 2 — Pin and validate the release candidate, then publish the preview tag
|
||||
|
||||
- Use `release/<target>`. On the first attempt, create it from a selected main commit containing the version bump and intended changes; it need not be the latest main HEAD. On a restart, retain the existing branch and backport only reviewed fixes from main. Verify the branch's Cargo versions still equal `<target>`. If adopting an existing preview, run this branch CI before continuing its acceptance; reuse the tag only when it points to the validated SHA.
|
||||
- The current `ci.yml` runs automatically on main pushes, not release-branch pushes. Dispatch it explicitly with `gh workflow run ci.yml --ref "release/<target>"`. Locate that dispatch run, verify its `head_sha` equals the release branch's remote HEAD, and require the full expected CI job set to pass, including `Quick Checks`, `Test and Lint`, and `End-to-End Tests (full merge gate)`. Treat a missing or skipped required lane as incomplete. A successful PR check or CI run for a different SHA does not qualify. If the branch advances or a run is cancelled, validate the new SHA again. Do not tag a candidate with failing or incomplete CI.
|
||||
- Prefer protecting `release/*` against force pushes and deletion and requiring reviewed backport PRs. Check live rulesets rather than assuming the main ruleset covers release branches; report a missing release-branch rule as a gap.
|
||||
- Set `PREVIEW_HASH` to the CI-validated release-branch commit and recheck the remote branch HEAD immediately before tagging. Report the branch, commit, and CI run URL; do not derive `PREVIEW_HASH` from a later `origin/main` fetch. When adopting an existing preview tag at this SHA, skip tag creation and continue Phase 3.
|
||||
|
||||
```bash
|
||||
git tag -a "<preview-tag>" -m "Release <preview-tag>" "$PREVIEW_HASH"
|
||||
git push origin "<preview-tag>"
|
||||
```
|
||||
|
||||
Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`.
|
||||
|
||||
The preview run builds versioned artifacts and publishes them in a GitHub prerelease. Docker may publish exact preview image tags. Latest-channel, R2, and Helm publication must be skipped; installation references and the website announcement stay unchanged.
|
||||
|
||||
On a restart after a backport, re-run release-branch CI before setting the new `PREVIEW_HASH`. Use the next unused preview iteration when the validated commit differs from an existing preview tag; never move an existing tag.
|
||||
@@ -1,118 +1,24 @@
|
||||
---
|
||||
name: rustfs-release-version-bump
|
||||
description: "Prepare Cargo versions for an exact RustFS target, or align installation references after its artifacts are published, with verification and optional commit/push/PR delivery. Use for an explicit version bump or when invoked by the release-publish workflow."
|
||||
description: Prepare an exact RustFS Cargo version or align installation references after publication. Use for requested version bumps or the release workflow.
|
||||
---
|
||||
# RustFS Release Version Bump
|
||||
# Release Version Files
|
||||
|
||||
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`.
|
||||
Require an exact target; ask if missing or ambiguous. Reject targets containing `-preview`: those are tag-only. Infer `post-release` only from an explicit request or parent workflow; otherwise use `prepare`. Delivery defaults to local edit/verify; commit, push, PR, and merge follow existing authorization and [Git rules](../../references/pull-requests.md).
|
||||
|
||||
## Required inputs
|
||||
| Stage | Files and constraints |
|
||||
|---|---|
|
||||
| `prepare` | Only `Cargo.toml` and `Cargo.lock`: workspace package, internal dependency, and workspace member versions become `<target>`. External dependencies stay unchanged. |
|
||||
| `post-release` | Only `README.md`, `README_ZH.md`, `flake.nix`, `helm/rustfs/Chart.yaml`, and `rustfs.spec`. Cargo may already target the next release; leave it untouched. |
|
||||
|
||||
- Exact target version, for example `1.0.0-beta.4`.
|
||||
- Stage: `prepare` (default) or `post-release`. Infer `post-release` only when the user or parent release workflow explicitly requests installation updates after publication; a normal version bump means `prepare`.
|
||||
- Delivery scope: local (`edit/verify`), git (`commit/push`), or GitHub
|
||||
(`commit/push/PR`). Derive it from the conversation; when unspecified, prepare
|
||||
and verify locally without blocking on a delivery question.
|
||||
Before installation edits, verify the exact non-preview Release, downloadable assets/source archive, pullable `rustfs/rustfs:<target>` image manifest, and published Helm chart. Block on missing artifacts. Do not downgrade defaults if a newer deliverable superseded the target. Preview preparation retains the previous published installation references; follow-up commits never replace either validated tag.
|
||||
|
||||
If target version is missing or ambiguous, stop and ask before editing.
|
||||
Packaging policy:
|
||||
|
||||
Reject any target version containing `-preview`: preview identifiers are tag-only (see `rustfs-release-publish`) and must never be written into version files. If asked for one, stop and point to the release pipeline instead of editing.
|
||||
- Docker image tags have no `v` prefix. Derive chart/app versions with `scripts/helm_chart_version.sh <target>` (`beta.N -> 0.N.0`; other versions unchanged). Helm CI derives versions from the final tag, so no early chart bump is required.
|
||||
- RPM `Version` is numeric; `Release` is the prerelease suffix or `1` for stable. Expanded `Source0` and the unpack directory must resolve to the exact target including its suffix. Add the changelog entry using current git identity, date, and target; never copy an example identity/date.
|
||||
- Changing these mappings requires explicit confirmation.
|
||||
|
||||
## Read before editing
|
||||
Verification follows root tiers: `prepare` validates Cargo metadata with the updated lockfile and checks every workspace/internal version with no unrelated lockfile updates. `post-release` renders Helm with the verified image, runs `scripts/test_helm_chart_version.sh` for chart version edits, and checks README, Nix, and RPM references. Run `git diff --check`; report unresolved required checks as `BLOCKED` without unrelated fixes.
|
||||
|
||||
- `AGENTS.md` (root and nearest path-specific files).
|
||||
- `.github/pull_request_template.md` only when preparing a PR.
|
||||
- Current branch status and diff against `origin/main`.
|
||||
|
||||
## Stage boundaries
|
||||
|
||||
`prepare` updates only:
|
||||
|
||||
- `Cargo.toml`
|
||||
- `Cargo.lock`
|
||||
|
||||
`post-release` updates installation and packaging references only:
|
||||
|
||||
- `README.md`
|
||||
- `README_ZH.md`
|
||||
- `flake.nix`
|
||||
- `helm/rustfs/Chart.yaml`
|
||||
- `rustfs.spec`
|
||||
|
||||
During preview, installation references intentionally retain the previous published deliverable. Do not sweep them into a source bump to make every version string match. `flake.nix` builds local source, but its package label is aligned with the other packaging references after publication.
|
||||
|
||||
Before `post-release` edits, verify that the exact non-preview target has a published GitHub Release, downloadable assets and source archive, and a pullable `rustfs/rustfs:<target>` image manifest. If any prerequisite is missing, report `BLOCKED` and leave installation references unchanged. If a newer deliverable has already superseded this target, do not downgrade installation defaults during a retry.
|
||||
|
||||
Helm CI derives chart versions from the triggering tag, so the final tag can retain the previous Chart.yaml values. Update the repository copy only after the target image and published chart are available. Neither post-release changes nor their merge commit may replace the preview-validated final tag.
|
||||
|
||||
## Hard release policy
|
||||
|
||||
- Docker doc tags use `<version>` (for example `rustfs/rustfs:1.0.0-beta.4`), not `v<version>`.
|
||||
- Derive Helm chart and app versions with `scripts/helm_chart_version.sh <target>`; the existing mapping is `beta.N -> 0.N.0`, with other target versions retained.
|
||||
- `rustfs.spec` `Release` uses the prerelease suffix (for example `beta.4`), or `1` for a stable release.
|
||||
- Do not change these rules without explicit confirmation.
|
||||
|
||||
## Step-by-step workflow
|
||||
|
||||
1. Confirm intent and isolate scope
|
||||
- Use the exact target and delivery scope already supplied; ask only for a missing or ambiguous target or a material release-policy choice.
|
||||
- Inspect current branch and ensure only release-related files are touched for this task.
|
||||
|
||||
2. Update workspace versions (`prepare` only)
|
||||
- Bump `[workspace.package].version` in `Cargo.toml`.
|
||||
- Bump internal workspace crate dependency versions in `Cargo.toml`.
|
||||
- Update `Cargo.lock` so workspace package versions match target version.
|
||||
- Re-scan Cargo.toml and workspace members in Cargo.lock for partial leftovers; leave external dependency versions unchanged.
|
||||
|
||||
3. Update installation references (`post-release` only)
|
||||
- `README.md` and `README_ZH.md`: update versioned Docker examples to target version.
|
||||
- `flake.nix`: update package version to target version.
|
||||
- `helm/rustfs/Chart.yaml`: use the app and chart versions returned by `scripts/helm_chart_version.sh <target>`.
|
||||
- `rustfs.spec`:
|
||||
- Set `Version` to the numeric version and `Release` to the prerelease suffix (example `beta.4`), or `1` for a stable release. Verify that `Source0` and the unpacked source directory resolve to the exact published target, including its prerelease suffix when present.
|
||||
- Add/update top changelog entry with exact format:
|
||||
- `* Thu May 20 2026 houseme <housemecn@gmail.com>`
|
||||
- `- Update RPM package to RustFS 1.0.0-beta.4`
|
||||
- Changelog identity and time must come from current environment:
|
||||
- `git config --get user.name`
|
||||
- `git config --get user.email`
|
||||
- `date '+%a %b %d %Y'`
|
||||
- Changelog version text must match target release version exactly.
|
||||
|
||||
4. Verify before shipping
|
||||
- Follow the root verification tiers for the final diff instead of running a full-workspace gate for version strings.
|
||||
- For `prepare`, validate Cargo metadata with the updated lockfile and confirm workspace package/internal dependency versions agree. Do not accept a lockfile containing unrelated dependency updates.
|
||||
- For `post-release`, render the Helm chart and confirm its default image is the verified target; run `scripts/test_helm_chart_version.sh` when chart versions change. Check the README image tags, Nix package version, and expanded RPM source URL against that same target.
|
||||
- Run `git diff --check` for either stage. Report unresolved required checks as `BLOCKED`; do not silently widen scope to fix unrelated issues.
|
||||
|
||||
5. Commit strategy (only when committing is authorized)
|
||||
- Use `chore(release): prepare <version>` for `prepare` and `chore(release): align installation references for <version>` for `post-release`.
|
||||
- These stages happen on opposite sides of publication; do not combine them in a preview-preparation commit or PR.
|
||||
- Stage only intended release files; do not include unrelated working tree changes.
|
||||
|
||||
6. Push and PR (only for the authorized delivery scope)
|
||||
- Push branch:
|
||||
- Use the user-requested or configured push remote: `git push -u <push-remote> <branch>` (first push), or `git push` when tracking is already configured.
|
||||
- Create PR with template headings unchanged:
|
||||
- `gh pr create --base main --head <branch> --title ... --body-file ...`
|
||||
- PR title/body must be English.
|
||||
- Use `N/A` for non-applicable template sections.
|
||||
- Include verification commands and any `BLOCKED` reason clearly.
|
||||
|
||||
## Recommended check commands
|
||||
|
||||
- `git status --short --branch`
|
||||
- `git diff --name-only origin/main...HEAD`
|
||||
- `git diff --stat origin/main...HEAD`
|
||||
- `rg -n "<old_version>|<new_version>" Cargo.toml Cargo.lock README.md README_ZH.md flake.nix helm/rustfs/Chart.yaml rustfs.spec`
|
||||
|
||||
## Output contract
|
||||
|
||||
When using this skill, always report:
|
||||
|
||||
- Target version and stage.
|
||||
- Files changed.
|
||||
- Any assumptions or uncertainties requiring confirmation.
|
||||
- Verification result (`PASSED` or `BLOCKED`) with key evidence.
|
||||
- Commit message(s) used.
|
||||
- Push status and PR URL when GitHub flow is requested.
|
||||
For authorized commits, keep stages separate: `chore(release): prepare <target>` or `chore(release): align installation references for <target>`. Report target/stage, changed files, verification, material uncertainty, and actual commit/push/PR state.
|
||||
|
||||
@@ -50,6 +50,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
|
||||
- `GHSA-g8w9-qw9q-fghr` and `GHSA-xm99-m3gq-83g8`: a valid presigned `PutObject` accepted extra `x-amz-*` headers omitted from `SignedHeaders`; `x-amz-copy-source` could turn that upload capability into a cross-bucket read performed as the signer. Lesson: a presigned URL is a bounded capability; inspect all received security-sensitive headers and reject unsigned ones before selecting the S3 operation or reaching storage.
|
||||
- `GHSA-3ppv-fx5m-m749`: explicit `versionId` reads and copy sources authorized `s3:GetObject` instead of `s3:GetObjectVersion`. Lesson: version-specific object access must select version-specific actions for direct reads, `CopyObject`, and `UploadPartCopy`, with tests proving the backend is not reached on denial.
|
||||
- `GHSA-gjph-xcgw-4p7w`: `PutObject` and browser POST accepted a caller-supplied `versionId` and replaced an unlocked historical version with only `s3:PutObject`. Lesson: only replication-authorized writes may choose an existing version ID; ordinary uploads must not rewrite version history.
|
||||
- `GHSA-x298-9x87-fvjq`: anonymous `ListObjectVersions` fell back to `ListBucket` and returned before public-access-block gates. Lesson: compatibility fallbacks must converge on the same post-authorization checks as direct grants, especially `RestrictPublicBuckets` and anonymous data-plane denies.
|
||||
- `GHSA-mx42-j6wv-px98`: `UploadPartCopy` missed source authorization and allowed cross-bucket object exfiltration. Lesson: multipart copy must enforce the same source and destination contract as `CopyObject`.
|
||||
- `GHSA-wfxj-ph3v-7mjf`: `UploadPartCopy` checked source and destination independently but missed destination copy-source policy constraints. Lesson: source read and destination write checks are not sufficient when policy constrains allowed copy sources.
|
||||
@@ -60,12 +61,14 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
- `GHSA-3g29-xff2-92vp`: FTP `RETR` and `SIZE`/`MDTM` read paths authenticated the user but skipped IAM before calling storage. Lesson: non-HTTP protocol frontends must enforce the same per-operation authorization as the S3 API before backend access.
|
||||
- `GHSA-g3vq-vv42-f647`: FTPS `MKD` called `create_bucket` without checking `s3:CreateBucket`. Lesson: protocol command handlers need action-specific checks even when sibling handlers already authorize correctly.
|
||||
- `GHSA-3p3x-734c-h5vx`: FTPS and WebDAV compared secret keys with early-return string equality, while FTPS also returned distinguishable invalid-user and invalid-password failures. Lesson: password-style protocol auth needs constant-time secret comparison, indistinguishable failures where practical, and rate limiting.
|
||||
- `GHSA-rhg6-jrf2-29c4`: WebDAV, FTP/FTPS, and SFTP used identity-policy checks with empty conditions and bypassed S3 bucket-policy explicit denies and condition-key denies. Lesson: alternate listeners must evaluate the same effective policy and trustworthy request context before storage access.
|
||||
|
||||
### Filesystem paths and object key traversal
|
||||
|
||||
- `GHSA-pq29-69jg-9mxc`: RPC `read_file_stream` joined untrusted paths under a volume directory without canonical boundary checks. Lesson: `PathBuf::join` plus length checks are not path security.
|
||||
- `GHSA-8r6f-hmq2-28rg`: object keys containing traversal sequences bypassed bucket/object authorization when mapped to filesystem paths. Lesson: reject traversal at object-key parsing and verify final storage paths remain under the expected bucket/key root.
|
||||
- `GHSA-f4vq-9ffr-m8m3`: Snowball auto-extract accepted archive entries such as `../victim-bucket/object`, authorized the raw attacker-bucket path, then storage path cleaning crossed bucket boundaries. Lesson: archive entries become object keys and need traversal rejection plus consistent authz/storage normalization before writes.
|
||||
- `GHSA-g7fq-6xjm-mxf4`: versioned `DeleteObjects` accepted dot-segment keys, authorized the literal prefix, then wrote delete markers under a resolved sibling prefix. Lesson: validate each batch key before authorization and storage normalization, using the same object-name rules as single delete.
|
||||
|
||||
### Secrets, defaults, and cryptographic misuse
|
||||
|
||||
@@ -106,6 +109,7 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
|
||||
### Object Lock and retention invariants
|
||||
|
||||
- `GHSA-j548-9grx-fh4f`: Object Lock enforcement treated unreadable, fabricated, or unparsable bucket metadata as absent configuration and allowed retained objects to be deleted or expired. Lesson: retention must fail closed unless Object Lock absence is authoritative, and every delete, lifecycle, scanner, force-delete, and default-retention path needs the same state distinction.
|
||||
- `GHSA-28w4-rp2p-636w` and `GHSA-c7hx-vr97-45rg`: an authorized `DeleteBucket` with `x-rustfs-force-delete: true` removed active COMPLIANCE-retained versions even though ordinary version deletion was denied. Lesson: bucket force-delete must enforce retention across every version, including against root/owner callers.
|
||||
|
||||
### Serde deserialization and input validation
|
||||
|
||||
@@ -135,16 +139,17 @@ rg -n "deny_unknown_fields|serde.default|as u32|as usize|as i32" rustfs crates
|
||||
## Minimum Regression Test Expectations
|
||||
|
||||
- Authz fixes: include unauthenticated, valid low-privilege, wrong-action, correct-action, owner, non-owner, and root/admin cases as applicable.
|
||||
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases, and assert denied paths do not reach the storage backend.
|
||||
- Protocol frontend authz fixes: include denied `RETR`, `SIZE`/`MDTM`, `MKD`, bucket probe, and sibling allowed-operation cases; test bucket-policy explicit denies and condition-key denies on each enabled listener against an S3 control, and assert denied paths do not reach storage.
|
||||
- IAM fixes: include import/update/list service-account cases with attacker-controlled parent, claims, access key, secret key, and policy.
|
||||
- Copy/upload fixes: include cross-bucket, cross-user, source-denied, destination-denied, copy-source-condition, and multipart completion cases.
|
||||
- Presigned upload fixes: include a valid presign with extra unsigned tagging, redirect, storage-class, and cross-bucket copy-source headers; require rejection before source or destination storage access, and verify explicitly signed equivalents still work.
|
||||
- Version-action fixes: include historical UUID, explicit current version, `null`, range, partNumber, presigned, STS/session, service-account, anonymous bucket-policy, copy source, and multipart-copy source cases.
|
||||
- Version-write fixes: a `PutObject`-only principal must not replace an existing version through `PutObject` or browser POST `?versionId=`; verify replication-authorized writes and versioned, suspended, and unversioned buckets separately.
|
||||
- Policy-condition fixes: include reserved-key header collisions, missing keys, partially overlapping multi-value sets, plugin mode, and built-in policy mode.
|
||||
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks.
|
||||
- Path fixes: include encoded traversal, absolute path, nested traversal, archive entries with `..`, valid object keys that resemble traversal text but should be rejected, and canonical bucket/prefix boundary checks; test per-key `DeleteObjects` errors and prefix-scoped denials on versioned buckets.
|
||||
- Logging fixes: assert redacted output for structs and response bodies that may contain credentials.
|
||||
- IAM export fixes: assert exported archives omit plaintext user and service-account secrets unless the format deliberately encrypts or seals them.
|
||||
- RPC auth fixes: include captured metadata replay across two concrete methods, stale timestamps, wrong path, wrong method surrogate, wrong secret, and valid same-method calls.
|
||||
- Browser/CORS fixes: assert no credentials on reflected/default origins, correct behavior for explicit allowlists, and no same-origin script execution for previewed object content.
|
||||
- SSE fixes: inspect stored bytes and verify API metadata, read-back behavior, and on-disk ciphertext together; for ETags, upload equal plaintext under different keys and assert list-only callers cannot derive or correlate the plaintext digest, including multipart objects.
|
||||
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths.
|
||||
- Object Lock fixes: include unreadable metadata, fabricated metadata defaults, unparsable config, confirmed absent config, COMPLIANCE/GOVERNANCE retention, lifecycle expiry, scanner sweeps, and force-delete paths; an authorized force-delete must preserve active COMPLIANCE versions.
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
name: test-coverage-improver
|
||||
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
|
||||
|
||||
Use this skill when you need a prioritized, risk-aware plan to improve tests from coverage results.
|
||||
|
||||
## Usage assumptions
|
||||
- Focus scope is either changed lines/files, a module, or the whole repository.
|
||||
- Reuse a supplied coverage artifact when its revision, scope, and format match.
|
||||
- If required context is missing, call out assumptions explicitly before proposing work.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Define scope and baseline
|
||||
- Derive the revision and scope from the request, diff, or supplied report.
|
||||
- Default to the affected files/module; whole-workspace coverage requires that
|
||||
scope in the request. Ask only if a wrong scope would change the result.
|
||||
|
||||
2. Obtain coverage evidence
|
||||
- First inspect a matching existing artifact; do not regenerate it merely
|
||||
because this skill was selected.
|
||||
- If measurement is needed, read the Coverage section of
|
||||
[the testing guide](../../../docs/testing/README.md#coverage), check disk
|
||||
space/tool availability, and select package/test-scoped `cargo llvm-cov`
|
||||
using the repository's nextest configuration. `make coverage` measures the
|
||||
whole workspace (excluding E2E) and is only for that requested scope.
|
||||
- Collect only metrics the report supports. Missing branch/changed-line
|
||||
coverage is unknown, not zero.
|
||||
- If measurement cannot run, continue with code-based test proposals and
|
||||
mark measured coverage unverified; do not invent a coverage percentage.
|
||||
|
||||
3. Rank highest-risk gaps
|
||||
- Prioritize changed code, branch coverage gaps, and low-confidence boundaries.
|
||||
- Apply the risk rubric in [coverage-prioritization.md](references/coverage-prioritization.md).
|
||||
- Report up to 5–8 evidenced gaps; do not pad a small scope.
|
||||
- For each gap, capture: file, lines, uncovered branches, and estimated risk score.
|
||||
|
||||
4. Propose high-impact tests
|
||||
- For each gap, name the behavior and regression, distinguishing assertions,
|
||||
relevant normal/edge/failure cases, necessary setup, and estimated effort.
|
||||
- Include only scenarios and setup that apply; reuse shared fixture details.
|
||||
|
||||
5. Close with validation plan
|
||||
- State which gaps remain after proposals.
|
||||
- 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).
|
||||
|
||||
## Report
|
||||
|
||||
Summarize the supported metrics, then combine each ranked gap with its proposed
|
||||
test and validation. Include source lines only when supplied or inspected;
|
||||
mark missing metrics or locations as unknown. Do not duplicate gaps and tests
|
||||
in separate templates or fill empty categories for an otherwise small report.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Test Coverage Improver"
|
||||
short_description: "Find top uncovered risk areas and propose high-impact tests."
|
||||
default_prompt: "Use $test-coverage-improver to analyze coverage for the requested scope, reuse matching reports, and propose tests for evidenced risks."
|
||||
@@ -1,25 +0,0 @@
|
||||
# Coverage Gap Prioritization Guide
|
||||
|
||||
Use this rubric for each uncovered area.
|
||||
|
||||
Score = (Criticality × 2) + CoverageDebt + (Volatility × 0.5)
|
||||
|
||||
- Criticality:
|
||||
- 5: authz/authn, data-loss, payment/consistency path
|
||||
- 4: state mutation, cache invalidation, scheduling
|
||||
- 3: error handling + fallbacks in user-visible flows
|
||||
- 2: parsing/format conversion paths
|
||||
- 1: logging-only or low-impact utilities
|
||||
|
||||
- CoverageDebt:
|
||||
- 0: 0–5 uncovered lines
|
||||
- 1: 6–20 uncovered lines
|
||||
- 2: 21–40 uncovered lines
|
||||
- 3: 41+ uncovered lines
|
||||
|
||||
- Volatility:
|
||||
- 1: stable legacy code with few recent edits
|
||||
- 2: changed in last 2 releases
|
||||
- 3: touched in last 30 days or currently in active PR
|
||||
|
||||
Sort by score descending, then by business impact.
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-linux=6fc377fa1f9f06e065f077c7bd53d04efbccf4453f95dde67afaa47778185377
|
||||
sha256-darwin=6fc377fa1f9f06e065f077c7bd53d04efbccf4453f95dde67afaa47778185377
|
||||
sha256-linux=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
|
||||
sha256-darwin=233bd7a68777eda1f044b6cb77128056be2b6492583c0dfd114b21e98a6102ef
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=3b6ff112ff16acbaa4148c4736095a1b6b71cc11af0dd3513ff28c6bdc334ca8
|
||||
sha256-linux=4e8bfc01870565a0a608a1634e6919fc593ce120a970aa04d974f7edf2675c21
|
||||
sha256-darwin=b9438fd1b82558f1fcd7e5a19dc0ceb0b34b5357d4ce99b7033de3ef019333ab
|
||||
sha256-linux=d4622517c13283247a022f49962c2cdb69e5ed0e13874fb55e0ab34fb923c044
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
sha256-darwin=55534a97fbd376f64c8f6c341d319017d11ff77cad6da8629a1a7f6a874e0315
|
||||
sha256-linux=c06fb8c19aed6f388b9dc61cb8251b7a44f8561a9bf764ad2b9e635598f8dc17
|
||||
sha256-darwin=a2cb67d91eb0d093928371d6e84a84b2dd41b2ba975a28094e81879bd65838ad
|
||||
sha256-linux=82db0561cf9b111f11782d647d0859c9d2a44c5c68481b5e39c48505828bd392
|
||||
|
||||
@@ -1 +1 @@
|
||||
sha256=476cb92aa9e385c9bb509ee5df3e5bd07e8c929730040b60d16791b6c0616d00
|
||||
sha256=58df0d240cd74b916ce8f094ed1d8ad3e136f2f9bac157a155192a8786dae65c
|
||||
|
||||
@@ -55,6 +55,281 @@
|
||||
"invariant": "odm-source-contract-gcs",
|
||||
"suite": "rustfs",
|
||||
"name": "on_demand_migration::gcs::tests::gcs_native_backend_satisfies_the_shared_backend_contract"
|
||||
},
|
||||
{
|
||||
"invariant": "rollback-inspection",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::rename_rollback_incomplete_preserves_overwrite_data_dirs_and_staging"
|
||||
},
|
||||
{
|
||||
"invariant": "rollback-closed-inspection-channel",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::rename_rollback_closed_heal_receiver_preserves_recovery_material"
|
||||
},
|
||||
{
|
||||
"invariant": "rollback-cancellation",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::rename_rollback_incomplete_cancelled_task_is_not_success"
|
||||
},
|
||||
{
|
||||
"invariant": "rollback-completion-equivalence",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::rename_rollback_incomplete_matches_early_ack_and_full_wait_after_reopen"
|
||||
},
|
||||
{
|
||||
"invariant": "rollback-physical-owner",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::rename_rollback_children_keep_namespace_ownership_after_coordinator_panic"
|
||||
},
|
||||
{
|
||||
"invariant": "late-ack-retains-commit",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::rename_data_early_ack_post_mutation_tail_error_never_rolls_back_commit"
|
||||
},
|
||||
{
|
||||
"invariant": "tail-drained-cas",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::tail_drained_put_waits_for_tail_and_allows_immediate_cas"
|
||||
},
|
||||
{
|
||||
"invariant": "seeded-put-tail-pressure",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::seeded_put_completion_pressure_drains_every_real_tail"
|
||||
},
|
||||
{
|
||||
"invariant": "borrowed-lock-tail",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::no_lock_put_waits_for_rename_tail_under_outer_guard"
|
||||
},
|
||||
{
|
||||
"invariant": "cancelled-writer-owner",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::tail_drained_put_owned_commit_survives_waiter_cancellation"
|
||||
},
|
||||
{
|
||||
"invariant": "get-body-reconstruction",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "ecstore_validation_blackbox::blackbox_get_restores_body_after_one_shard_file_is_removed"
|
||||
},
|
||||
{
|
||||
"invariant": "range-reconstruction",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "ecstore_validation_blackbox::blackbox_range_read_restores_exact_slice_with_one_offline_disk"
|
||||
},
|
||||
{
|
||||
"invariant": "multipart-abort-linearization",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::multipart::tests::abort_and_complete_linearize_for_plain_sse_and_legacy_layouts"
|
||||
},
|
||||
{
|
||||
"invariant": "multipart-write-quorum",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::multipart::tests::abort_enforces_delete_write_quorum_boundary"
|
||||
},
|
||||
{
|
||||
"invariant": "multipart-physical-owner",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::multipart::tests::complete_multipart_timeout_keeps_namespace_owner_until_physical_publication"
|
||||
},
|
||||
{
|
||||
"invariant": "restore-abort-cleanup",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::transition_commit_failure_tests::multipart_restore_aborts_every_post_create_failure"
|
||||
},
|
||||
{
|
||||
"invariant": "legacy-metadata",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "filemeta::test::test_issue_2288_legacy_xlmeta_compatibility"
|
||||
},
|
||||
{
|
||||
"invariant": "legacy-nil-version",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "filemeta::test::test_into_fileinfo_reads_legacy_nil_uuid_inline_key"
|
||||
},
|
||||
{
|
||||
"invariant": "commit-phase-schedule",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::commit_fault_schedule_keeps_multiple_disk_phases_independent"
|
||||
},
|
||||
{
|
||||
"invariant": "commit-phase-ownership",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::io_primitives::tests::commit_fault_schedule_rejects_duplicate_checkpoint_without_disarming_owner"
|
||||
},
|
||||
{
|
||||
"invariant": "metadata-observation-all-four-slot-states-and-arrival-orders-preserve-reduction",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::metadata_quorum::tests::metadata_observation_all_four_slot_states_and_arrival_orders_preserve_reduction"
|
||||
},
|
||||
{
|
||||
"invariant": "metadata-observation-mixed-current-identity-arrivals-preserve-selection",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::metadata_quorum::tests::metadata_observation_mixed_current_identity_arrivals_preserve_selection"
|
||||
},
|
||||
{
|
||||
"invariant": "metadata-observation-pending-newer-version-and-same-time-different-directory-force-full-wait",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::metadata_quorum::tests::metadata_observation_pending_newer_version_and_same_time_different_directory_force_full_wait"
|
||||
},
|
||||
{
|
||||
"invariant": "metadata-observation-duplicate-shard-cannot-supply-a-read-reserve",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::metadata_quorum::tests::metadata_observation_duplicate_shard_cannot_supply_a_read_reserve"
|
||||
},
|
||||
{
|
||||
"invariant": "metadata-observation-null-versions-markers-and-invalid-success-keep-their-meaning",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::core::metadata_quorum::tests::metadata_observation_null_versions_markers_and_invalid_success_keep_their_meaning"
|
||||
},
|
||||
{
|
||||
"invariant": "metadata-cache-real-get-and-range-preserve-body-across-read-modes",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::read::metadata_cache_tests::metadata_cache_real_get_and_range_preserve_body_across_read_modes"
|
||||
},
|
||||
{
|
||||
"invariant": "late-metadata-refresh-rejects-changed-generation-and-mapped-shards",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::read::metadata_cache_tests::late_metadata_refresh_rejects_changed_generation_and_mapped_shards"
|
||||
},
|
||||
{
|
||||
"invariant": "late-full-get-rejects-changed-object-generation",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::prepared_get_object_metadata_tests::non_inline_two_phase_get_rejects_generation_change_before_late_refresh"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-static-namespace",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_static_namespace"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-cross-subdirectory-limit",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_cross_subdirectory_limit"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-max-and-max-plus-one",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_max_and_max_plus_one"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-fixed-seed-marker-and-prefix",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_fixed_seed_marker_and_prefix"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-offline-corrupt-and-minority-stale",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_offline_corrupt_and_minority_stale"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-versions-delete-markers-and-free-versions",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_versions_delete_markers_and_free_versions"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-orphan-cleanup-requires-authoritative-evidence",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_orphan_cleanup_requires_authoritative_evidence"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-consumer-drop-joins-real-disk-producer",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_consumer_drop_joins_real_disk_producer"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-cancelled-quorum-walk-releases-senders-and-disk-lock",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_cancelled_quorum_walk_releases_senders_and_disk_lock"
|
||||
},
|
||||
{
|
||||
"invariant": "list-objects-shared-corpus-enabled-index-contracts",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::differential_tests::list_objects_shared_corpus_enabled_index_contracts"
|
||||
},
|
||||
{
|
||||
"invariant": "borrowed-context-boundary",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::borrowed_write_context_rejects_wrong_namespace_and_cache_flag"
|
||||
},
|
||||
{
|
||||
"invariant": "borrowed-put-cancellation",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::put_object_tmp_cleanup_tests::borrowed_tail_drained_put_keeps_owner_after_caller_cancel"
|
||||
},
|
||||
{
|
||||
"invariant": "borrowed-mpu-cancellation",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::multipart::tests::borrowed_complete_keeps_namespace_owner_after_caller_cancel"
|
||||
},
|
||||
{
|
||||
"invariant": "pool-cas-owner",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "core::pools::pools_tests::pool_meta_cas_context_retains_actual_owner_for_exact_target"
|
||||
},
|
||||
{
|
||||
"invariant": "pool-cas-released-owner",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "core::pools::pools_tests::pool_meta_cas_rejects_released_owner"
|
||||
},
|
||||
{
|
||||
"invariant": "copy-tail-drained",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::object::tests::borrowed_copy_tail_drained_waits_for_rename_tail"
|
||||
},
|
||||
{
|
||||
"invariant": "catalog-borrowed-publication",
|
||||
"suite": "rustfs",
|
||||
"name": "admin::handlers::table_catalog::tests::commit_backend_borrowed_put_preserves_owner_authorization_and_precondition"
|
||||
},
|
||||
{
|
||||
"invariant": "directory-marker-quorum",
|
||||
"suite": "rustfs-filemeta",
|
||||
"name": "metacache::tests::resolve_directory_marker_and_prefix_preserve_quorum_in_both_orders"
|
||||
},
|
||||
{
|
||||
"invariant": "logical-prefix-limit",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "store::list_objects::test::list_path_gather_results_counts_common_prefixes_before_page_limit"
|
||||
},
|
||||
{
|
||||
"invariant": "quota-same-name-owner-and-disk-authority",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "bucket::metadata_sys::tests::quota_snapshot_reuses_same_name_owner_and_rereads_disk_authority"
|
||||
},
|
||||
{
|
||||
"invariant": "quota-snapshot-scope-and-owner",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "bucket::metadata_sys::tests::quota_snapshot_rejects_wrong_scope_incarnation_and_transaction_guard"
|
||||
},
|
||||
{
|
||||
"invariant": "quota-snapshot-lifecycle-and-incarnation",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "bucket::metadata_sys::tests::quota_snapshot_rejects_lifecycle_loss_during_read_and_changed_disk_incarnation"
|
||||
},
|
||||
{
|
||||
"invariant": "marker-purge-receipt-reopen",
|
||||
"suite": "rustfs-ecstore",
|
||||
"name": "set_disk::ops::object::marker_purge_receipt_tests::quorum_marker_purge_receipt_survives_reopen_with_stale_member"
|
||||
},
|
||||
{
|
||||
"invariant": "external-minio-shards",
|
||||
"suite": "rustfs-ecstore::pinned_erasure_fixtures_test",
|
||||
"name": "pinned_minio_shards_decode_and_reconstruct_plaintext"
|
||||
},
|
||||
{
|
||||
"invariant": "external-legacy-shards",
|
||||
"suite": "rustfs-ecstore::pinned_erasure_fixtures_test",
|
||||
"name": "pinned_legacy_shards_decode_and_reconstruct_plaintext"
|
||||
},
|
||||
{
|
||||
"invariant": "external-shard-bitrot",
|
||||
"suite": "rustfs-ecstore::pinned_erasure_fixtures_test",
|
||||
"name": "pinned_erasure_shards_reject_bitrot_corruption"
|
||||
},
|
||||
{
|
||||
"invariant": "external-shard-quorum-codec",
|
||||
"suite": "rustfs-ecstore::pinned_erasure_fixtures_test",
|
||||
"name": "pinned_erasure_shards_reject_insufficient_quorum_and_wrong_codec"
|
||||
}
|
||||
],
|
||||
"fixtures": [
|
||||
@@ -82,6 +357,36 @@
|
||||
"path": "crates/ecstore/tests/fixtures/minio/bucket_metadata_full.xlmeta.hex",
|
||||
"sha256": "3b6de589519c08a1614c8bd409bb8199c17d42043861b07bce513075e6fbfc12",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/minio/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/filemeta/tests/fixtures/issue_2288_legacy_xlmeta.hex",
|
||||
"sha256": "b3e8d32f9950a38b5dc71f679519cc830a2b2a807e8b0b39f78840b238d8eacf",
|
||||
"source": "Legacy xl.meta captured for rustfs/backlog#2288; filemeta::test::test_issue_2288_legacy_xlmeta_compatibility"
|
||||
},
|
||||
{
|
||||
"path": "crates/filemeta/tests/fixtures/issue_2265_legacy_meta_v2_object.hex",
|
||||
"sha256": "89dd7ec92d16e54b5d31e0c49c9edffff8a7aa58890aaeff59bc2ff84da0a62f",
|
||||
"source": "Legacy metadata v2 object captured for rustfs/backlog#2265; crates/filemeta/src/test_data.rs"
|
||||
},
|
||||
{
|
||||
"path": "crates/ecstore/tests/fixtures/list_namespace_keys.json",
|
||||
"sha256": "5afddb60565deba0938e617470255563f0e15a15fab699645c59bb7ccc84898e",
|
||||
"source": "Shared static LIST namespace corpus; ECStore differential tests and ODM list_through_static_namespace_boundary_matrix"
|
||||
},
|
||||
{
|
||||
"path": "crates/ecstore/tests/fixtures/erasure-shards/minio/manifest.json",
|
||||
"sha256": "c398f8dcb76903f6cf8eee07931c76592895ced201ff9d59ed4ff9b0ead2b13c",
|
||||
"source": "MinIO RELEASE.2025-07-23T15-54-02Z; crates/ecstore/tests/fixtures/erasure-shards/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/ecstore/tests/fixtures/erasure-shards/legacy/manifest.json",
|
||||
"sha256": "dce397d5b2f42748eb5091b4355bb9d133a50cbba003626fafac28095dbe540b",
|
||||
"source": "RustFS 1.0.0-alpha.37; crates/ecstore/tests/fixtures/erasure-shards/README.md"
|
||||
},
|
||||
{
|
||||
"path": "crates/ecstore/tests/fixtures/erasure-shards/plaintext.bin",
|
||||
"sha256": "b5a83332327961a31b65a8731c64559fd04ff8ac9f6124022201dbbdba5ffa01",
|
||||
"source": "Independent fixed SHA256 counter-stream oracle; crates/ecstore/tests/fixtures/erasure-shards/README.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ s3s-footprint-check: ## Check the s3s dependency footprint ratchet stays frozen
|
||||
@echo "📦 Checking s3s footprint ratchet..."
|
||||
./scripts/check_s3s_footprint.sh
|
||||
|
||||
.PHONY: test-count-ratchet-check
|
||||
test-count-ratchet-check: ## Check no workspace crate lost tests or gained #[ignore] against the committed baseline
|
||||
@echo "🧪 Checking per-crate test-count ratchet..."
|
||||
./scripts/check_test_count_ratchet.sh
|
||||
|
||||
.PHONY: fips-wording-check
|
||||
fips-wording-check: ## Check docs and crates/kms do not over-claim crypto capabilities
|
||||
@echo "📣 Checking cryptographic capability wording guard..."
|
||||
|
||||
@@ -20,11 +20,11 @@ planning-docs-check: ## Check that no planning-type documents are committed
|
||||
./scripts/check_no_planning_docs.sh
|
||||
|
||||
.PHONY: pre-commit
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
pre-commit: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check quick-check ## Run fast pre-commit checks without clippy/full tests
|
||||
@echo "✅ All pre-commit checks passed!"
|
||||
|
||||
.PHONY: pre-pr
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
pre-pr: fmt-check unsafe-code-check architecture-migration-check logging-guardrails-check error-other-ratchet-check tokio-io-uring-check extension-schema-check body-cache-whitelist-check s3s-footprint-check test-count-ratchet-check fips-wording-check embedded-secrets-check test-wiring-check doc-paths-check planning-docs-check log-analyzer-rules-check offline-enrollment-e2e-check clippy-check test ## Run full pre-PR checks with clippy and tests
|
||||
@echo "✅ All pre-PR checks passed!"
|
||||
|
||||
.PHONY: dev-check
|
||||
|
||||
@@ -50,7 +50,9 @@ script-tests: ## Run shell script tests
|
||||
./scripts/test_fuzz_runner.sh
|
||||
./scripts/test_python_bin.sh
|
||||
./scripts/check_embedded_secrets.sh --self-test
|
||||
./scripts/check_s3s_footprint.sh --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/check_test_wiring.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/codemods/s3_error_to_s3_types.py --self-test
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_e2e_binary.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/test_migration_gate_evidence.py
|
||||
$(RUSTFS_PYTHON_BIN) ./scripts/ci_gate.py --self-test
|
||||
|
||||
+68
-5
@@ -64,6 +64,26 @@ command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||
command = ['sh', '-c', 'echo RUST_MIN_STACK=33554432 >> "$NEXTEST_ENV"']
|
||||
|
||||
# --- default profile (local): serialize the flaky groups, never retry --------
|
||||
# These real-disk progress oracles exercise fsync, quorum IO, and crash/replay
|
||||
# boundaries. Reserve the run's capacity so unrelated fixtures cannot starve
|
||||
# their existing deadlines and typed-failure assertions (rustfs/backlog#2706).
|
||||
# Keep each test's internal concurrency and zero-retry policy unchanged.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
@@ -82,6 +102,13 @@ setup = 'lifecycle-large-stack'
|
||||
filter = 'package(rustfs-heal) & test(=replacement_pool_metadata_follows_real_two_set_placement)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These real-disk scanner fixtures assert quiet-workload publication and
|
||||
# durable MRF admission. Reserve the pool while their bounded CAS/fsync
|
||||
# handoffs run so unrelated disk fanouts cannot consume that fixture budget.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(=scanner_io::tests::multi_pool_scanner_cycle_publishes_combined_usage) | test(=scanner_io::tests::pending_put_commit_keeps_scanner_walk_live_without_authoritative_usage) | test(=scanner_folder::tests::mrf_ownership::checkpoint_fixture_metadata_heal_reaches_budgeted_tail))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(concurrent_resend_same_part_commits_one_generation) | test(concurrent_config_writes_from_separate_nodes_do_not_lose_writes) | test(/^store::bucket::tests::bucket_delete_(mark_delete|purge_removes|default_s3_delete)/))'
|
||||
test-group = 'ecstore-serial-flaky'
|
||||
@@ -143,10 +170,11 @@ filter = 'package(rustfs) & (binary(/^embedded.*_test$/) | binary(admin_diagnost
|
||||
test-group = 'embedded-test-ports'
|
||||
|
||||
# Registration, inventory delivery, and real drive/object probes include
|
||||
# durable filesystem IO in short deadlines. Reserve capacity so sibling test
|
||||
# filesystem or HTTP IO in short deadlines. Reserve capacity so sibling test
|
||||
# processes cannot exhaust those budgets. Keep deadlines and assertions intact.
|
||||
# The short HTTP fixtures are tracked in rustfs/backlog#2714.
|
||||
[[profile.default.overrides]]
|
||||
filter = 'package(rustfs) & (binary(/^connect_registration$/) | binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_object) & test(=real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put)))'
|
||||
filter = 'package(rustfs) & (binary(/^connect_registration$/) | binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_client) & test(=configured_proxy_and_direct_endpoint_failures_are_distinct)) | (binary(connect_perf_object) & test(/^(real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put|cleanup_uses_a_budget_independent_of_the_measurement_window|oversized_response_is_rejected_without_buffering_it)$/)))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# This cancellation probe hashes the serving executable before acquiring its
|
||||
@@ -283,6 +311,24 @@ fail-fast = false
|
||||
# marker is the observable signal the flake policy is built around.
|
||||
path = "junit.xml"
|
||||
|
||||
# Match the local durable-progress reservations. All members, deadlines,
|
||||
# assertions, and internal concurrency remain enabled without retries.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & test(/^connect::diagnostics::schedule::tests::/)'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-ecstore) & (test(/^set_disk::tests::streaming_get_blocks_concurrent_/) | test(=store::object::tests::retired_marker_converges_after_bucket_recreation_on_sixteen_disks))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-heal) & (binary(mrf_partial_write_test) | (binary(mrf_pipeline_test) & test(/^journal_replay_survives_/)) | test(=admin_selector_survives_sigkill_with_original_scope_and_receipts))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(/^scanner::backlog::tests::native_(writer|retirement)_/) | test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(/^scanner_io::tests::service_cohort::/))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.ci.scripts]]
|
||||
filter = 'package(rustfs-ecstore) & test(/^(bucket::lifecycle::bucket_lifecycle_ops::tests::manual_transition_worker_result_recovery_marks_unknown_for_corrupt_marker|services::rebalance::entry::tests::real_rebalance_run_fence_loss_blocks_multipart_publication|store::init::tests::(batch_transitioned_delete_uses_free_version_per_item|decommission_entry_(allows_free_version_consumed_before_source_lock|rejects_subquorum_free_version_conflict_and_retains_source|skips_cleanup_only_marker_when_free_version_is_present)|dispatched_tier_delete_recovery_(checks_later_pool_then_commits_after_source_removal|finds_directory_source_on_encoded_set|retains_journal_on_source_metadata_error)|force_tier_remove_blocks_on_physical_free_version_hidden_by_other_pool|legacy_unknown_transition_delete_falls_back_for_single_batch_and_blocks_prefix|multi_pool_(recursive_prefix_rejects_legacy_or_hidden_merge_loser_before_delete|same_remote_tuple_(batch|single)_delete_waits_for_all_sources|same_tuple_recursive_prefix_uses_one_journal_owner|transitioned_delete_persists_one_free_version_per_remote_tuple)|recursive_prefix_partial_(pool|set)_failure_keeps_prepared_cleanup_owners|restored_transitioned_delete_uses_free_version_as_cleanup_owner|stable_transitioned_recursive_prefix_delete_uses_journal_owners|suspended_null_transition_delete_uses_free_version_as_sole_owner|tier_mutation_peer_handler_applies_prepare_commit_and_abort_idempotently|transition_response_loss_persists_unknown_outcome_for_provider_recovery|transition_transaction_recovery_(drops_record_after_confirmed_local_commit|keeps_cleanup_pending_local_commit)|transitioned_delete_(free_version_replays_after_store_restart|local_quorum_failure_rolls_back_without_cleanup_owner|uses_free_version_as_cleanup_owner)|versioned_delete_marker_keeps_transitioned_source_and_remote_object|versioned_explicit_transition_delete_preserves_other_version_then_allows_bucket_delete))$/)'
|
||||
setup = 'ecstore-large-stack'
|
||||
@@ -301,6 +347,13 @@ setup = 'lifecycle-large-stack'
|
||||
filter = 'package(rustfs-heal) & test(=replacement_pool_metadata_follows_real_two_set_placement)'
|
||||
setup = 'ecstore-large-stack'
|
||||
|
||||
# These real-disk scanner fixtures assert quiet-workload publication and
|
||||
# durable MRF admission. Reserve the pool while their bounded CAS/fsync
|
||||
# handoffs run so unrelated disk fanouts cannot consume that fixture budget.
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs-scanner) & (test(=scanner_folder::tests::mrf_ownership::mrf_ownership_manager_completion_preserves_scanner_pending) | test(=scanner_io::tests::multi_pool_scanner_cycle_publishes_combined_usage) | test(=scanner_io::tests::pending_put_commit_keeps_scanner_walk_live_without_authoritative_usage) | test(=scanner_folder::tests::mrf_ownership::checkpoint_fixture_metadata_heal_reaches_budgeted_tail))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# ===========================================================================
|
||||
# QUARANTINE — flaky tests granted retries = 2 under the ci profile ONLY.
|
||||
#
|
||||
@@ -376,7 +429,7 @@ filter = 'package(rustfs) & (binary(/^embedded.*_test$/) | binary(admin_diagnost
|
||||
test-group = 'embedded-test-ports'
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = 'package(rustfs) & (binary(/^connect_registration$/) | binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_object) & test(=real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put)))'
|
||||
filter = 'package(rustfs) & (binary(/^connect_registration$/) | binary(connect_inventory) | binary(connect_perf_drive) | (binary(connect_perf_client) & test(=configured_proxy_and_direct_endpoint_failures_are_distinct)) | (binary(connect_perf_object) & test(/^(real_rustfs_endpoint_and_production_cli_support_bounded_get_and_put|cleanup_uses_a_budget_independent_of_the_measurement_window|oversized_response_is_rejected_without_buffering_it)$/)))'
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
# Preserve the cancellation probe's startup budget and assertions in CI too.
|
||||
@@ -526,6 +579,14 @@ test-group = 'ecstore-serial-flaky'
|
||||
# the PR-lane e2e coverage for scanner usage snapshots consumed by quota and
|
||||
# admin surfaces. It uses the same single-node, random-port, isolated-temp-dir
|
||||
# fixture as the existing smoke modules.
|
||||
#
|
||||
# Dual-stack differential (rustfs/backlog#2740): `dual_stack_differential::`
|
||||
# starts two single-node servers from the same binary, one per
|
||||
# RUSTFS_S3_STACK value, on random ports with isolated temp dirs, and runs one
|
||||
# SDK script covering every operation of `impl S3 for FS` against both. Its
|
||||
# comparator unit tests need no server. It meets the admission criteria
|
||||
# unchanged; the module clause admits a new differential test without another
|
||||
# filter edit.
|
||||
[profile.e2e-smoke]
|
||||
default-filter = """
|
||||
package(e2e_test) & (
|
||||
@@ -533,6 +594,7 @@ default-filter = """
|
||||
| test(/^replication_extension_test::(test_replication_check_succeeds_with_remote_target|test_replication_check_rejects_target_without_object_lock|test_set_remote_target_rejects_unversioned_source_bucket|test_replication_check_rejects_unversioned_source_bucket|test_replication_check_rejects_missing_replication_config|test_replication_check_rejects_invalid_bucket|test_set_remote_target_rejects_same_bucket_on_same_deployment|test_set_remote_target_rejects_unversioned_target_bucket|test_set_remote_target_update_requires_arn|test_set_remote_target_update_rejects_missing_target|test_set_remote_target_rejects_invalid_target_url|test_set_remote_target_rejects_self_signed_https_target_without_skip_tls_verify|test_set_remote_target_rejects_private_ca_https_target_without_ca_cert_pem|test_list_remote_targets_rejects_empty_bucket|test_list_remote_targets_rejects_invalid_bucket|test_remove_remote_target_rejects_missing_target|test_remove_remote_target_rejects_missing_arn|test_remove_remote_target_rejects_invalid_bucket|test_remove_remote_target_rejects_target_used_by_replication|test_delete_bucket_replication_removes_remote_target)$/)
|
||||
| test(/^reliant::lifecycle::/)
|
||||
| test(/^reliant::tiering::/)
|
||||
| test(/^dual_stack_differential::/)
|
||||
| test(/^upgrade_compatibility_test::upgrade_write_readiness_tests::/)
|
||||
| test(/^on_demand_migration::(get_basic_test::(get_miss_pulls_inline_and_serves_locally_afterwards|head_miss_answers_from_the_source_without_persisting)|interaction_test::test_odm_admin_config_is_redacted_and_status_counts_match_the_source)$/)
|
||||
)
|
||||
@@ -542,10 +604,11 @@ fail-fast = false
|
||||
[profile.e2e-smoke.junit]
|
||||
path = "junit.xml"
|
||||
|
||||
# These storage-heavy fixtures reserve capacity. Pagination tests bound listing
|
||||
# requests separately, after their seed objects have been written.
|
||||
# Bound the whole pagination fixture, including startup, seeding, and cleanup;
|
||||
# its LIST deadline begins only after the seed objects have been written.
|
||||
[[profile.e2e-smoke.overrides]]
|
||||
filter = 'package(e2e_test) & test(/^list_objects_v2_pagination_test::tests::(test_list_objects_v2_delimiter_small_page_traverses_all|test_list_objects_v2_max_keys_above_limit_returns_token|test_list_objects_v2_maxkeys_above_limit_with_delimiter)$/)'
|
||||
slow-timeout = { period = "60s", terminate-after = 2, grace-period = "10s" }
|
||||
threads-required = "num-test-threads"
|
||||
|
||||
[[profile.e2e-smoke.overrides]]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Files that may still reference s3s (`s3s::` or `s3s_sigv4`) when Phase 1 of
|
||||
# the gateway migration ends (rustfs/backlog#2734 task T0.6, rustfs/backlog#2741).
|
||||
# Consumed only by `scripts/check_s3s_footprint.sh --mode allowlist`.
|
||||
#
|
||||
# One entry per line: a repository-relative file path or a git `:(glob)`
|
||||
# pathspec. `**` matches across directories, `*` stays within one path
|
||||
# component, and a directory entry matches everything under it. Blank lines
|
||||
# and lines starting with `#` are ignored; an entry may not contain whitespace,
|
||||
# so a note belongs on its own comment line.
|
||||
#
|
||||
# Every Rust file listed by `git grep -l '\bs3s\(::\|_sigv4\)' -- '*.rs'` that
|
||||
# no entry matches fails the allowlist mode. Task T1.9 switches CI to that
|
||||
# mode; until then `--dry-run` prints the files still outside this list, which
|
||||
# is the Phase 1 tracking list.
|
||||
#
|
||||
# Never add crates/** other than crates/e2e_test: the engine crates leave s3s
|
||||
# entirely, and the script rejects such an entry. Only the s3s-facing edge of
|
||||
# the rustfs binary and the e2e oracle keep it.
|
||||
|
||||
# rustfs binary: the s3s-facing edge that survives Phase 1
|
||||
rustfs/src/storage/ecfs.rs
|
||||
rustfs/src/storage/s3_compat/**
|
||||
rustfs/src/storage/access.rs
|
||||
rustfs/src/auth.rs
|
||||
rustfs/src/error.rs
|
||||
rustfs/src/storage_api.rs
|
||||
rustfs/src/app/storage_api.rs
|
||||
rustfs/src/admin/router.rs
|
||||
rustfs/src/app/metadata_route.rs
|
||||
rustfs/src/server/http.rs
|
||||
rustfs/src/server/layer.rs
|
||||
rustfs/src/server/hybrid.rs
|
||||
rustfs/src/protocols/client.rs
|
||||
|
||||
# e2e oracle: the test target that verifies S3 behaviour against s3s
|
||||
crates/e2e_test/src/fake_s3_target/**
|
||||
crates/e2e_test/src/**
|
||||
@@ -0,0 +1,17 @@
|
||||
# Exact baseline for the direct s3s footprint, consumed only by
|
||||
# scripts/check_s3s_footprint.sh (rustfs/backlog#1677; rustfs/backlog#2734 T0.4).
|
||||
#
|
||||
# Each value is the exact measurement of the tree, not a ceiling. CI fails when a
|
||||
# measured counter differs from its line here in either direction:
|
||||
# - a larger measurement means new code widened the s3s surface: route it
|
||||
# through the gateway abstractions instead; never raise a value here;
|
||||
# - a smaller measurement means the PR shrank the surface: lower the matching
|
||||
# value to the measured number in the same PR.
|
||||
#
|
||||
# files Rust files referencing s3s paths, repo-wide minus crates/e2e_test
|
||||
# s3_error_lines lines holding s3_error!, same scope, minus those the script
|
||||
# header's rule clears (rustfs_s3_types's macro); fails closed
|
||||
# ecstore_files Rust files referencing s3s paths under crates/ecstore/src
|
||||
files=195
|
||||
s3_error_lines=1584
|
||||
ecstore_files=36
|
||||
@@ -0,0 +1,63 @@
|
||||
# Per-crate test-count baseline consumed by scripts/check_test_count_ratchet.sh
|
||||
# (rustfs/backlog#2734 section 1 rule 3; T0.3 in rustfs/backlog#2738).
|
||||
#
|
||||
# One row per workspace member: <package>, tests, ignored, tab-separated.
|
||||
# tests counts #[test], #[tokio::test], #[tokio::test(...)] and #[test_case(...)]
|
||||
# attribute lines under the package directory; ignored counts #[ignore] and
|
||||
# #[ignore = "..."]. Both columns are exact: CI fails when a crate's tests fall
|
||||
# below or rise above its row, or when its ignored count changes. Raise a row in
|
||||
# the PR that adds tests; lowering one is a reviewed statement that tests were
|
||||
# intentionally removed. Regenerate with:
|
||||
# scripts/check_test_count_ratchet.sh --update
|
||||
e2e_test 890 18
|
||||
rustfs 5532 62
|
||||
rustfs-audit 70 0
|
||||
rustfs-checksums 26 0
|
||||
rustfs-common 23 0
|
||||
rustfs-concurrency 27 0
|
||||
rustfs-config 43 0
|
||||
rustfs-connect-symbol-catalog 6 0
|
||||
rustfs-credentials 24 0
|
||||
rustfs-crypto 67 0
|
||||
rustfs-data-usage 54 0
|
||||
rustfs-ecstore 6270 1
|
||||
rustfs-extension-schema 23 0
|
||||
rustfs-filemeta 306 0
|
||||
rustfs-heal 839 3
|
||||
rustfs-heal-contracts 6 0
|
||||
rustfs-iam 371 0
|
||||
rustfs-io-core 49 0
|
||||
rustfs-io-metrics 125 0
|
||||
rustfs-keystone 29 0
|
||||
rustfs-kms 715 25
|
||||
rustfs-license 2 0
|
||||
rustfs-lifecycle 152 0
|
||||
rustfs-lock 135 0
|
||||
rustfs-log-analyzer 87 0
|
||||
rustfs-madmin 209 0
|
||||
rustfs-notify 136 0
|
||||
rustfs-object-capacity 92 0
|
||||
rustfs-object-data-cache 140 0
|
||||
rustfs-obs 365 0
|
||||
rustfs-policy 677 0
|
||||
rustfs-protocols 736 1
|
||||
rustfs-protos 52 0
|
||||
rustfs-replication 200 0
|
||||
rustfs-rio 198 0
|
||||
rustfs-rio-v2 34 6
|
||||
rustfs-s3-client 65 0
|
||||
rustfs-s3-ops 8 0
|
||||
rustfs-s3-types 97 0
|
||||
rustfs-s3select-api 206 0
|
||||
rustfs-s3select-query 188 2
|
||||
rustfs-scanner 971 20
|
||||
rustfs-scanner-metrics 75 0
|
||||
rustfs-security-governance 20 0
|
||||
rustfs-signer 31 0
|
||||
rustfs-storage-api 31 0
|
||||
rustfs-targets 603 26
|
||||
rustfs-test-utils 1 0
|
||||
rustfs-tls-runtime 20 0
|
||||
rustfs-trusted-proxies 68 0
|
||||
rustfs-utils 216 0
|
||||
rustfs-zip 30 0
|
||||
@@ -86,6 +86,10 @@ runs:
|
||||
shell: bash
|
||||
run: ./scripts/check_s3s_footprint.sh
|
||||
|
||||
- name: Check per-crate test-count ratchet
|
||||
shell: bash
|
||||
run: ./scripts/check_test_count_ratchet.sh
|
||||
|
||||
- name: Check cryptographic capability wording
|
||||
shell: bash
|
||||
run: ./scripts/check_fips_wording.sh
|
||||
@@ -98,6 +102,10 @@ runs:
|
||||
shell: bash
|
||||
run: make script-tests
|
||||
|
||||
- name: Check object generation protocol model
|
||||
shell: bash
|
||||
run: python3 ./scripts/test_object_generation_protocol_model.py
|
||||
|
||||
- name: Check test wiring
|
||||
shell: bash
|
||||
run: python3 ./scripts/check_test_wiring.py
|
||||
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
# database lookup with a second ignore list to maintain.
|
||||
cargo-deny:
|
||||
name: Cargo Deny
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
|
||||
workflow-pin-report:
|
||||
name: Workflow Pin Report
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
if: github.repository == 'rustfs/rustfs' && (github.event_name != 'pull_request' || github.event.action != 'closed')
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
# workflow produces, and until now it was only visible to whoever happened to
|
||||
# open the Actions tab. Same ci-8 mechanism coverage.yml and
|
||||
# e2e-replication-nightly.yml already use.
|
||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
+102
-7
@@ -16,7 +16,9 @@ name: Continuous Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
# gateway/integration receives the daily merge of main as a direct push
|
||||
# (rustfs/backlog#2734), so its pushes must prove the result like main's.
|
||||
branches: [ main, gateway/integration ]
|
||||
paths-ignore:
|
||||
- "**.md"
|
||||
- "docs/**"
|
||||
@@ -36,7 +38,9 @@ on:
|
||||
- "flake.lock"
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened, closed ]
|
||||
branches: [ main ]
|
||||
# gateway/integration carries main's ruleset (rustfs/backlog#2734), so its
|
||||
# pull requests must produce the same required checks.
|
||||
branches: [ main, gateway/integration ]
|
||||
merge_group:
|
||||
types: [ checks_requested ]
|
||||
schedule:
|
||||
@@ -509,7 +513,7 @@ jobs:
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
strategy:
|
||||
# Preserve both independent results when one protocol fails, so diagnosis
|
||||
# Preserve independent results when one protocol fails, so diagnosis
|
||||
# and a focused fix do not require rebuilding an interrupted sibling.
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -518,6 +522,8 @@ jobs:
|
||||
flags: "--features swift"
|
||||
- name: sftp
|
||||
flags: "--features sftp"
|
||||
- name: http3
|
||||
flags: "--features http3,swift"
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
steps:
|
||||
@@ -776,6 +782,8 @@ jobs:
|
||||
needs: [ build-rustfs-debug-binary ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
RUSTFS_S3_STACK: legacy
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
@@ -894,18 +902,105 @@ jobs:
|
||||
path: ${{ runner.temp }}/rustfs-e2e-*/rustfs.log
|
||||
retention-days: 3
|
||||
|
||||
# The same e2e-smoke suite against the same debug binary with the gateway
|
||||
# S3 stack selected (rustfs/backlog#2734). Report-only: it is outside the
|
||||
# Test and Lint aggregate and becomes required at T2.10. The archive and run
|
||||
# steps must stay identical to e2e-tests; scripts/ci_gate.py enforces that.
|
||||
e2e-tests-gateway:
|
||||
name: e2e-smoke (gateway, report-only until T2.10)
|
||||
needs: [ build-rustfs-debug-binary ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 30
|
||||
continue-on-error: true
|
||||
env:
|
||||
RUSTFS_S3_STACK: gateway
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Rust environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
rust-version: stable
|
||||
cache-shared-key: ci-dev
|
||||
cache-save-if: 'false'
|
||||
install-build-packaging-tools: 'false'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install awscurl
|
||||
run: |
|
||||
python3 -m pip install --user --upgrade pip "awscurl==0.44"
|
||||
echo "AWSCURL_PATH=$HOME/.local/bin/awscurl" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Verify awscurl
|
||||
run: test -x "$AWSCURL_PATH"
|
||||
|
||||
- name: Download debug binary
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: rustfs-debug-binary
|
||||
path: target/debug
|
||||
|
||||
- name: Make binary executable
|
||||
run: chmod +x ./target/debug/rustfs
|
||||
|
||||
- name: Archive e2e smoke test binaries
|
||||
env:
|
||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-smoke-list.json
|
||||
run: |
|
||||
cargo nextest archive --profile e2e-smoke -p e2e_test --archive-file "${NEXTEST_ARCHIVE}"
|
||||
cargo nextest list --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" --message-format json > "${NEXTEST_LISTING}"
|
||||
python3 ./scripts/check_test_wiring.py --check-profile e2e-smoke "${NEXTEST_LISTING}"
|
||||
./scripts/check_security_smoke_count.sh check "${NEXTEST_LISTING}"
|
||||
|
||||
- name: Run e2e smoke suite
|
||||
env:
|
||||
NEXTEST_ARCHIVE: ${{ runner.temp }}/rustfs-e2e-smoke.tar.zst
|
||||
RUSTFS_E2E_LOG_DIR: ${{ runner.temp }}/rustfs-e2e-smoke-logs
|
||||
SAMPLER_INTERVAL_SECS: "15"
|
||||
run: |
|
||||
./scripts/ci/resource_sampler.sh start e2e-smoke
|
||||
trap './scripts/ci/resource_sampler.sh stop' EXIT
|
||||
python3 scripts/e2e_binary.py run --features e2e-test-hooks -- cargo nextest run --profile e2e-smoke --archive-file "${NEXTEST_ARCHIVE}" \
|
||||
--status-level all --final-status-level all --failure-output final
|
||||
|
||||
- name: Upload e2e smoke diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-smoke-gateway-diagnostics-${{ github.run_number }}
|
||||
path: |
|
||||
${{ runner.temp }}/rustfs-e2e-smoke-logs/
|
||||
${{ runner.temp }}/rustfs-e2e-smoke-list.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Upload e2e smoke JUnit report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-smoke-gateway-junit-${{ github.run_number }}
|
||||
path: target/nextest/e2e-smoke/junit.xml
|
||||
if-no-files-found: warn
|
||||
|
||||
e2e-full:
|
||||
name: End-to-End Tests (full merge gate)
|
||||
# Merge gate only (backlog#1149 ci-5): the never-automated user-visible
|
||||
# suites — KMS, object_lock, multipart_auth, quota, checksum, encryption,
|
||||
# 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
|
||||
# dispatch. protocols / the 7 cluster suites / replication / #[ignore] are
|
||||
# owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
||||
# every PR, so it is gated to main and gateway/integration pushes, the merge
|
||||
# queue, and manual dispatch. protocols / the 7 cluster suites / replication
|
||||
# / #[ignore] are owned by other lanes (see .config/nextest.toml profile.e2e-full).
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
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/gateway/integration'))
|
||||
needs: [ build-rustfs-debug-binary ]
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 55
|
||||
|
||||
@@ -551,54 +551,6 @@ jobs:
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
# Docker Hub's overview is a separate `full_description` field that
|
||||
# `docker push` never touches, so it drifted from README.md and kept
|
||||
# linking to pages that no longer exist (#7995). Republish it from the
|
||||
# commit the images were built from, only when images were pushed.
|
||||
sync-dockerhub-description:
|
||||
name: Sync Docker Hub Description
|
||||
needs: [ build-check, build-docker ]
|
||||
if: needs.build-check.outputs.should_build == 'true' && needs.build-check.outputs.should_push == 'true'
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.build-check.outputs.source_ref }}
|
||||
sparse-checkout: README.md
|
||||
sparse-checkout-cone-mode: false
|
||||
|
||||
- name: Sync Docker Hub description
|
||||
uses: peter-evans/dockerhub-description@1b9a80c056b620d92cedb9d9b5a223409c68ddfa # v5.0.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: ${{ env.REGISTRY_DOCKERHUB }}
|
||||
readme-filepath: ./README.md
|
||||
# README.md links to docs/ and CONTRIBUTING.md by relative path;
|
||||
# rewrite those to github.com URLs so they resolve on Docker Hub.
|
||||
enable-url-completion: true
|
||||
|
||||
# Docker Hub caps `full_description` at 25,000 bytes and the action
|
||||
# truncates to fit with only a warning, which would publish a README
|
||||
# cut off mid-sentence. README.md is already near the cap, so read the
|
||||
# published overview back and fail if it hit the limit.
|
||||
- name: Check published description was not truncated
|
||||
env:
|
||||
REPOSITORY: ${{ env.REGISTRY_DOCKERHUB }}
|
||||
run: |
|
||||
limit=25000
|
||||
published=$(curl -sSf "https://hub.docker.com/v2/repositories/${REPOSITORY}/" \
|
||||
| jq -j '.full_description' | wc -c | tr -d ' ')
|
||||
echo "Published overview: ${published} bytes (Docker Hub limit: ${limit})"
|
||||
# Truncation lands within one UTF-8 character of the cap.
|
||||
if [ "$published" -ge $((limit - 4)) ]; then
|
||||
echo "::error file=README.md::Docker Hub overview was truncated at ${limit} bytes; trim README.md before the next release."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Docker build summary
|
||||
docker-summary:
|
||||
name: Docker Build Summary
|
||||
|
||||
@@ -78,6 +78,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
distributed:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Distributed 4-node 4-disk e2e
|
||||
# GitHub-hosted VM: loop and tmpfs mounts work here. sm-standard-4 is an
|
||||
# ARC pod and rejects both (`mount -o loop` ENOENT, tmpfs "read-only").
|
||||
@@ -167,11 +168,13 @@ jobs:
|
||||
FILTER: ${{ inputs.filter }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -f target/nextest/e2e-distributed/junit.xml
|
||||
if [ -n "${FILTER}" ]; then
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test -E "${FILTER}"
|
||||
else
|
||||
python3 scripts/e2e_binary.py run -- cargo nextest run --profile e2e-distributed -p e2e_test --no-tests=fail
|
||||
fi
|
||||
test -s target/nextest/e2e-distributed/junit.xml
|
||||
|
||||
- name: Upload distributed e2e diagnostics
|
||||
if: always()
|
||||
@@ -200,7 +203,7 @@ jobs:
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [distributed]
|
||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
@@ -53,6 +53,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
repl-nightly:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Replication e2e (nightly)
|
||||
# dual-node tests spawn two full rustfs servers each; the extra headroom
|
||||
# over the per-PR sm-standard-2 e2e job keeps the parallel process fan-out
|
||||
@@ -119,6 +120,7 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
|
||||
cluster-nightly:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Cluster fault e2e (nightly)
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
@@ -168,6 +170,7 @@ jobs:
|
||||
if-no-files-found: warn
|
||||
|
||||
protocols-nightly:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Protocol e2e (nightly)
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 90
|
||||
@@ -229,7 +232,7 @@ jobs:
|
||||
# Only scheduled runs open/append the tracking issue (backlog#1149 ci-8);
|
||||
# manual workflow_dispatch runs stay quiet so a debugging run never files a
|
||||
# spurious alert.
|
||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
@@ -11,6 +11,7 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
collect:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: smoke-testing
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
|
||||
@@ -72,9 +72,11 @@ jobs:
|
||||
fuzz-build:
|
||||
name: Build Fuzz Harness
|
||||
if: >
|
||||
(github.event_name == 'pull_request' && github.event.action != 'closed') ||
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
github.repository == 'rustfs/rustfs' && (
|
||||
(github.event_name == 'pull_request' && github.event.action != 'closed') ||
|
||||
github.event_name == 'schedule' ||
|
||||
github.event_name == 'workflow_dispatch'
|
||||
)
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
@@ -239,7 +241,7 @@ jobs:
|
||||
# `always()` is required: without it this job is skipped when a needed
|
||||
# job fails. Alerts only for scheduled (nightly) runs (backlog#1149
|
||||
# ci-8); PR and manual dispatch failures are already watched by a human.
|
||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Copyright 2026 RustFS Team
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
name: Gateway Integration Sync
|
||||
|
||||
# Merges `main` into the long-lived `gateway/integration` branch every weekday
|
||||
# so the s3s -> gateway migration (rustfs/backlog#2734, task T0.1 in #2736)
|
||||
# never accumulates merge debt. A clean merge lands as a `--no-ff` merge commit
|
||||
# pushed straight to the branch. A conflicting merge is aborted and surfaced as
|
||||
# a pull request from `sync/<source>-<date>`, whose head is the un-merged
|
||||
# source commit, so the conflict is resolved the same day, in review, with no
|
||||
# other change riding along. The run itself stays green in that case: the pull
|
||||
# request is the signal. Any other failure (push rejected, pull request not
|
||||
# created) fails the run and, on the schedule, files the shared failure issue.
|
||||
#
|
||||
# Resolving a conflict pull request: merge `origin/gateway/integration` into
|
||||
# the sync branch, resolve, push, get the review. Land it with a fast-forward
|
||||
# push of the resolved merge commit to `gateway/integration`, not with the
|
||||
# squash button: a squash drops the merge ancestry and the same hunks conflict
|
||||
# again on the next run.
|
||||
#
|
||||
# Write scope: only `gateway/integration` and `sync/*` are ever pushed, and
|
||||
# both push targets below are literal refs. The pushes use a one-hour
|
||||
# installation token of the organisation's own GitHub App (contents: write)
|
||||
# because that app is the only non-admin bypass actor of the
|
||||
# `gateway/integration` ruleset; GITHUB_TOKEN cannot be a ruleset bypass actor,
|
||||
# so it cannot push through the required reviews and checks. The conflict pull
|
||||
# request is opened with FLAKE_UPDATE_TOKEN, the PAT that already opens the
|
||||
# weekly flake.lock pull requests, because this repository keeps "Allow GitHub
|
||||
# Actions to create and approve pull requests" switched off. GITHUB_TOKEN is
|
||||
# therefore read-only here.
|
||||
#
|
||||
# `schedule` fires only from the default branch, so the cron is live once this
|
||||
# file exists on `main`. `workflow_dispatch` runs from any ref that carries the
|
||||
# file and always acts on `gateway/integration`.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * 1-5" # Weekdays 02:00 UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: >-
|
||||
Branch to merge into gateway/integration. The schedule always merges
|
||||
main; only the conflict acceptance test (rustfs/backlog#2736) sets
|
||||
another branch.
|
||||
required: false
|
||||
default: main
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
TARGET_BRANCH: gateway/integration
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Merge main into gateway/integration
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Create the branch push token
|
||||
id: push-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
app-id: ${{ vars.CLA_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.CLA_BOT_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: rustfs
|
||||
permission-contents: write
|
||||
|
||||
- name: Checkout gateway/integration
|
||||
uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
# persist-credentials-exempt: this checkout's token IS the push
|
||||
# credential — the merge commit and the sync branch are pushed with it
|
||||
# below. It is a one-hour installation token scoped to contents.
|
||||
ref: gateway/integration
|
||||
fetch-depth: 0
|
||||
token: ${{ steps.push-token.outputs.token }}
|
||||
|
||||
- name: Configure the bot identity
|
||||
run: |
|
||||
git config user.name "rustfs-cla-assistant[bot]"
|
||||
git config user.email "273121464+rustfs-cla-assistant[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Merge the source branch
|
||||
id: merge
|
||||
env:
|
||||
SOURCE_REF: ${{ inputs.source_ref || 'main' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! [[ "$SOURCE_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|
||||
|| [[ "$SOURCE_REF" == *..* ]] \
|
||||
|| [[ "$SOURCE_REF" == "$TARGET_BRANCH" ]]; then
|
||||
echo "::error::refusing source_ref '${SOURCE_REF}'"
|
||||
exit 1
|
||||
fi
|
||||
git fetch origin "refs/heads/${SOURCE_REF}:refs/remotes/origin/${SOURCE_REF}"
|
||||
source_sha=$(git rev-parse "origin/${SOURCE_REF}")
|
||||
target_sha=$(git rev-parse HEAD)
|
||||
{
|
||||
echo "source_ref=${SOURCE_REF}"
|
||||
echo "source_sha=${source_sha}"
|
||||
echo "target_sha=${target_sha}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
if git merge --no-ff --no-edit "origin/${SOURCE_REF}"; then
|
||||
if [[ "$(git rev-parse HEAD)" == "$target_sha" ]]; then
|
||||
echo "result=up-to-date" >> "$GITHUB_OUTPUT"
|
||||
echo "\`${TARGET_BRANCH}\` already contains \`${SOURCE_REF}\` (${source_sha}); nothing to push." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "result=merged" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
git diff --name-only --diff-filter=U > "$RUNNER_TEMP/conflicts.txt"
|
||||
git merge --abort
|
||||
if [[ ! -s "$RUNNER_TEMP/conflicts.txt" ]]; then
|
||||
echo "::error::git merge failed without reporting conflicting paths; this is not a conflict"
|
||||
exit 1
|
||||
fi
|
||||
echo "result=conflict" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Push the merge commit
|
||||
if: steps.merge.outputs.result == 'merged'
|
||||
env:
|
||||
SOURCE_REF: ${{ steps.merge.outputs.source_ref }}
|
||||
SOURCE_SHA: ${{ steps.merge.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git push origin HEAD:refs/heads/gateway/integration
|
||||
echo "Merged \`${SOURCE_REF}\` (${SOURCE_SHA}) into \`${TARGET_BRANCH}\` as $(git rev-parse HEAD)." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Open the conflict pull request
|
||||
if: steps.merge.outputs.result == 'conflict'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.FLAKE_UPDATE_TOKEN }}
|
||||
SOURCE_REF: ${{ steps.merge.outputs.source_ref }}
|
||||
SOURCE_SHA: ${{ steps.merge.outputs.source_sha }}
|
||||
TARGET_SHA: ${{ steps.merge.outputs.target_sha }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
date_utc=$(date -u +%Y-%m-%d)
|
||||
sync_branch="sync/${SOURCE_REF//\//-}-${date_utc}"
|
||||
git push origin "refs/remotes/origin/${SOURCE_REF}:refs/heads/${sync_branch}"
|
||||
{
|
||||
echo "The scheduled merge of \`${SOURCE_REF}\` into \`${TARGET_BRANCH}\` stopped on conflicts and was aborted; \`${TARGET_BRANCH}\` is unchanged."
|
||||
echo
|
||||
echo "This branch is the un-merged \`${SOURCE_REF}\` head. Merge \`origin/${TARGET_BRANCH}\` into it, resolve the files below, push, and get the review. Land the resolved merge commit with a fast-forward push to \`${TARGET_BRANCH}\`; a squash merge drops the merge ancestry and the same hunks conflict again on the next run. Do not include any change that is not conflict resolution."
|
||||
echo
|
||||
echo "- Run: ${RUN_URL}"
|
||||
echo "- Source: \`${SOURCE_REF}\` at ${SOURCE_SHA}"
|
||||
echo "- Target: \`${TARGET_BRANCH}\` at ${TARGET_SHA}"
|
||||
echo
|
||||
echo "## Conflicting files"
|
||||
echo
|
||||
echo "Output of \`git diff --name-only --diff-filter=U\` captured before \`git merge --abort\`:"
|
||||
echo
|
||||
sed 's/^/- /' "$RUNNER_TEMP/conflicts.txt"
|
||||
} > "$RUNNER_TEMP/pr-body.md"
|
||||
title="sync: merge ${SOURCE_REF} ${date_utc} (conflicts)"
|
||||
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --state open --json number --jq '.[0].number // empty')
|
||||
if [[ -n "$existing" ]]; then
|
||||
gh pr edit "$existing" --repo "$GITHUB_REPOSITORY" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md"
|
||||
pr_url=$(gh pr view "$existing" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
|
||||
else
|
||||
pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET_BRANCH" --head "$sync_branch" --title "$title" --body-file "$RUNNER_TEMP/pr-body.md")
|
||||
fi
|
||||
echo "Conflicts in \`${SOURCE_REF}\` -> \`${TARGET_BRANCH}\`; pull request: ${pr_url}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
alert-on-failure:
|
||||
name: Alert on scheduled failure
|
||||
needs: [sync]
|
||||
# Same ci-8 mechanism audit.yml and coverage.yml use: a scheduled run that
|
||||
# could not push or could not open the conflict pull request must not stay
|
||||
# visible only in the Actions tab.
|
||||
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Open or update failure-tracking issue
|
||||
uses: ./.github/actions/schedule-failure-issue
|
||||
with:
|
||||
github-token: ${{ secrets.BACKLOG_ISSUE_TOKEN }}
|
||||
@@ -130,6 +130,21 @@ jobs:
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0
|
||||
|
||||
- name: Install yq
|
||||
run: |
|
||||
set -euo pipefail
|
||||
yq_version="4.54.1"
|
||||
yq_sha256="8e34fc298390875de416e6a4afcb8cabeceb25d9aa8506c1a2f9353cf702ea5f"
|
||||
tools_dir="$RUNNER_TEMP/rustfs-helm-tools"
|
||||
mkdir -p "$tools_dir"
|
||||
curl --fail --silent --show-error --location --retry 3 \
|
||||
"https://github.com/mikefarah/yq/releases/download/v${yq_version}/yq_linux_amd64" \
|
||||
--output "$tools_dir/yq"
|
||||
printf '%s %s\n' "$yq_sha256" "$tools_dir/yq" | sha256sum --check -
|
||||
chmod +x "$tools_dir/yq"
|
||||
echo "$tools_dir" >> "$GITHUB_PATH"
|
||||
"$tools_dir/yq" --version
|
||||
|
||||
- name: Test Helm Chart Templates
|
||||
run: ./scripts/test_helm_templates.sh
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ env:
|
||||
|
||||
jobs:
|
||||
resolve-source:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Resolve nightly source
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 10
|
||||
@@ -496,8 +497,10 @@ jobs:
|
||||
name: Alert on scheduled failure
|
||||
needs: [build, kms-vault-lane, kms-vault-ha-failover]
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
github.repository == 'rustfs/rustfs' && (
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
@@ -32,6 +32,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
update-flake:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Update flake.lock
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 90
|
||||
|
||||
@@ -49,6 +49,7 @@ env:
|
||||
|
||||
jobs:
|
||||
warp-ab:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Warp A/B budget gate
|
||||
runs-on: sm-standard-2
|
||||
# A normal nightly restores the last successful binary and builds only the
|
||||
@@ -364,8 +365,10 @@ jobs:
|
||||
# composite action already reports cancelled/timed-out jobs in the issue
|
||||
# body.
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
github.repository == 'rustfs/rustfs' && (
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
@@ -42,6 +42,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
check-ephemerality:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Check runner ephemerality
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 15
|
||||
@@ -65,7 +66,7 @@ jobs:
|
||||
# Same ci-8 mechanism as coverage.yml, audit.yml and the nightly lanes:
|
||||
# scheduled runs file a tracking issue, manual dispatch stays quiet so
|
||||
# debugging never produces a spurious alert.
|
||||
if: always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure')
|
||||
if: github.repository == 'rustfs/rustfs' && (always() && github.event_name == 'schedule' && contains(needs.*.result, 'failure'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
@@ -266,7 +266,10 @@ jobs:
|
||||
# the previous 45m step cap killed four consecutive runs mid-rebalance
|
||||
# with steps 1-7 green. 240m covers the observed pace (rebalance 1/4
|
||||
# in ~21m) plus one decommission pass; override per dispatch.
|
||||
timeout-minutes: ${{ inputs.pool_timeout_minutes || 240 }}
|
||||
# fromJSON coerces the string dispatch input to the number that
|
||||
# timeout-minutes requires; the bare || form yielded the string '240'
|
||||
# and the runner rejected the template (Unexpected value '240').
|
||||
timeout-minutes: ${{ fromJSON(inputs.pool_timeout_minutes || '240') }}
|
||||
# Case failures keep the run green: the report and the backlog
|
||||
# issue manager carry the product signal.
|
||||
continue-on-error: true
|
||||
@@ -583,25 +586,6 @@ jobs:
|
||||
echo "pool test script reported a failure (step FAIL verdict or die() abort)" >&2
|
||||
failed=1
|
||||
fi
|
||||
# A runner-level kill (step timeout) truncates the suite without a
|
||||
# FAIL verdict or an [ERROR] line, so require every expected
|
||||
# [POOL-STEP] marker to be present with PASS - missing markers mean
|
||||
# the run was truncated, not green. This also closes the gap for
|
||||
# standalone dispatches, where the chain driver's evidence
|
||||
# validation does not run.
|
||||
last_step=6
|
||||
if [ "${{ inputs.pools || '3' }}" = "3" ]; then
|
||||
last_step=8
|
||||
if [ "${{ inputs.run_decommission != 'false' }}" = "true" ]; then
|
||||
last_step=9
|
||||
fi
|
||||
fi
|
||||
for n in $(seq 1 "${last_step}"); do
|
||||
grep -q "^\[POOL-STEP\] ${n} .* PASS" "${POOL_ARTIFACT_DIR}/pool-test.log" 2>/dev/null || {
|
||||
echo "pool test log is missing [POOL-STEP] ${n} PASS (expected through step ${last_step}); the run was truncated" >&2
|
||||
failed=1
|
||||
}
|
||||
done
|
||||
[ "${failed}" -eq 0 ] || exit 1
|
||||
|
||||
- name: Upload functional report to dashboard
|
||||
|
||||
@@ -28,6 +28,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
check-freshness:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: Check scheduled validation freshness
|
||||
# Stay on GitHub-hosted runners: this monitor must keep running when the
|
||||
# self-hosted sm-standard-* pool is down, which is exactly when the
|
||||
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
alert-on-incomplete-run:
|
||||
name: Alert on incomplete scheduled run
|
||||
if: >-
|
||||
github.repository == 'rustfs/rustfs' &&
|
||||
github.event.workflow_run.event == 'schedule' &&
|
||||
github.event.workflow_run.conclusion != 'success' &&
|
||||
github.event.workflow_run.conclusion != 'failure'
|
||||
|
||||
@@ -26,6 +26,7 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -14,6 +14,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
update:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
|
||||
@@ -40,6 +40,7 @@ env:
|
||||
|
||||
jobs:
|
||||
targets-live:
|
||||
if: github.repository == 'rustfs/rustfs'
|
||||
name: PostgreSQL, MySQL, AMQP, and NATS
|
||||
runs-on: sm-standard-2
|
||||
timeout-minutes: 90
|
||||
@@ -175,8 +176,10 @@ jobs:
|
||||
name: Alert on scheduled failure
|
||||
needs: [targets-live]
|
||||
if: >-
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
github.repository == 'rustfs/rustfs' && (
|
||||
always() && github.event_name == 'schedule' &&
|
||||
(contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
|
||||
@@ -17,6 +17,8 @@ rules. A skill cannot expand the user's requested scope or grant authorization.
|
||||
|
||||
- Inquiry, diagnosis, review, and planning tasks are read-only unless the user
|
||||
explicitly requests changes.
|
||||
- Ordinary diff/commit reviews use the finding standard below directly; no
|
||||
generic review skill is required. PR and high-risk reviews retain their workflows.
|
||||
- For implementation, read the relevant code, tests, and local guidance, then
|
||||
make the smallest change that satisfies the request.
|
||||
- State assumptions only when they affect behavior or verification. Ask only
|
||||
@@ -88,6 +90,9 @@ runtime/build output:
|
||||
|
||||
- Run `cargo fmt --all --check` for Rust changes.
|
||||
- Run the narrowest test that exercises the changed behavior.
|
||||
- For an explicitly requested coverage assessment, use the
|
||||
[coverage guide](docs/testing/README.md#coverage); a regression test alone does
|
||||
not require coverage measurement.
|
||||
- Add package-scoped `cargo check` or Clippy only for targets, features, public
|
||||
APIs, error handling, or control flow not compiled by the focused test.
|
||||
- Use `make pre-commit` only when its repository-wide fast checks add confidence
|
||||
@@ -150,13 +155,18 @@ For every added or edited `tracing` call:
|
||||
|
||||
- Reuse the module's `EVENT_*`, `LOG_COMPONENT_*`, and `LOG_SUBSYSTEM_*`
|
||||
constants and field shape.
|
||||
- Put fields first and a short label last.
|
||||
- Put stable fields first (`event`, `component`, `subsystem`, `state`/`result`,
|
||||
then context when available) and a short label last.
|
||||
- Use `error` for behavior/security failure, `warn` for degradation/fallback,
|
||||
`info` for low-frequency lifecycle, `debug` for diagnostics, and `trace` for
|
||||
repetitive request/object success paths.
|
||||
- Never log secrets, credential payloads, or merged configs.
|
||||
- Never log secrets, credential payloads, raw untrusted bodies, or merged configs,
|
||||
including through error strings and `Debug` output.
|
||||
- Prefer one aggregate summary over inventories or startup banners.
|
||||
|
||||
Use `.agents/skills/rustfs-logging-governance/SKILL.md` for logging changes.
|
||||
Run `./scripts/check_logging_guardrails.sh` for changed logging sites. For a broad
|
||||
logging audit, event-model migration, or guardrail expansion, read
|
||||
[logging governance](docs/operations/logging-governance.md).
|
||||
|
||||
## Cross-Cutting Storage Invariants
|
||||
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ default build (lifecycle:
|
||||
- ⚠️ PARTIALLY VIOLATED: serving-side `s3s` references remain in ecstore
|
||||
(bucket metadata/replication/lifecycle DTOs and error mapping). The
|
||||
count is ratcheted shrink-only by `scripts/check_s3s_footprint.sh`
|
||||
(`S3S_ECSTORE_FILES_BASELINE`; the `object_lock` module was converted to
|
||||
(the `ecstore_files` line of `.config/s3s-footprint-baseline.txt`; the `object_lock` module was converted to
|
||||
storage-level types as the first ratchet step). Target state: the
|
||||
baseline reaches zero and ecstore's `Cargo.toml` drops `s3s`.
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **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
|
||||
- **SSE-C TLS requirement now covers copy-source keys**: with `RUSTFS_SSE_C_REQUIRE_TLS=true`, a `CopyObject` or `UploadPartCopy` that carried only the `x-amz-copy-source-server-side-encryption-customer-*` headers (an SSE-C source copied to a destination that is not SSE-C) was accepted on a plaintext transport and was not counted in `rustfs_ssec_plaintext_requests_total`, although those headers carry the source object's customer key. They now count as SSE-C headers: such requests are counted, and refused with `400 InvalidRequest` when the switch is on. Requests over TLS are unaffected.
|
||||
- **SSE-KMS encryption context could replace the location entry**: data keys are wrapped under an encryption-context entry, keyed by the bucket name, that ties them to the object's location. A client `x-amz-server-side-encryption-context` entry under the same key replaced it, and because the client context is stored with the object, the replacement also applied on every read. SSE-KMS writes now refuse such a context with `400 InvalidArgument`; other keys, including the names of other buckets, are unaffected. Objects stored with such an entry by an earlier release remain readable. See `docs/operations/kms-backend-security.md`.
|
||||
- **Two admin handlers reported a missing policy or user as `500 InternalError`**: `GET /rustfs/admin/v3/info-canned-policy` and `DELETE /rustfs/admin/v3/remove-user` wrapped every IAM error as an internal fault instead of classifying it, so a lookup for something that does not exist answered `500 InternalError` where the rest of the admin API answers `404 NoSuchResource` — `GET /rustfs/admin/v3/user-info` already did, and `iam_error_to_s3_error` already mapped `NoSuchPolicy`, `NoSuchUser`, `NoSuchServiceAccount` and `NoSuchTempAccount` to `NoSuchResource`. A client could not tell "does not exist" from a real server failure, and a controller that checks existence before creating (a Kubernetes operator reconciling policies or users) could never create the object: the missing-policy lookup failed with a `500` and it retried forever. Both handlers now map their IAM errors through the shared `iam_error_to_s3_error` helper, so a missing policy or user is `404 NoSuchResource` and the `error` is preserved as the S3 error's source; the three IAM calls in the removal path (temporary-user lookup, service-account lookup, delete) are all reclassified, and every other IAM error stays `500 InternalError`. Fixes #8117.
|
||||
- **Vault backends accepted key names that escape the key prefix**: only the Local backend refused a key identifier containing a path separator, so on Vault KV2 `POST /rustfs/admin/v3/kms/keys` with a name such as `bad/name` succeeded and created a nested KV2 path that the key listing then reported as a directory rather than a key, and an identifier containing `..` addressed a record outside the configured key prefix once the HTTP client normalised the URL. Both Vault backends now apply the Local backend's containment rule at the point where the identifier becomes a path or a Transit key name: an empty identifier, one containing `/`, `\\` or NUL, or the dot segments `.` and `..` is refused with `400` (`InvalidKey`) before any request reaches Vault. The AWS backend is unaffected, since it addresses keys by ARN and alias. A Vault deployment that created such a key on an earlier build must re-create it under a plain name; objects encrypted under the old name stay readable only until that key is refused, so migrate them first. Refs rustfs/backlog#2474.
|
||||
- **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.
|
||||
|
||||
Generated
+274
-306
File diff suppressed because it is too large
Load Diff
+41
-30
@@ -48,7 +48,7 @@ members = [
|
||||
"crates/replication", # Replication contracts and wire formats
|
||||
"crates/concurrency", # Concurrency management for RustFS - timeout, locking, backpressure, and I/O scheduling
|
||||
"crates/s3-client", # S3 client for engine-side consumption of remote S3 endpoints (tiering, transition targets)
|
||||
"crates/s3-types", # S3 event type definitions
|
||||
"crates/s3-types", # S3 event and error type definitions
|
||||
"crates/s3-ops", # S3 operation definitions and mapping
|
||||
"crates/s3select-api", # S3 Select API interface
|
||||
"crates/s3select-query", # S3 Select query engine
|
||||
@@ -73,7 +73,7 @@ resolver = "3"
|
||||
edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.98.1"
|
||||
rust-version = "1.99.0"
|
||||
version = "1.0.1"
|
||||
homepage = "https://rustfs.com"
|
||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||
@@ -146,8 +146,7 @@ rustfs-zip = { path = "./crates/zip", version = "1.0.1" }
|
||||
async-channel = "2.5.0"
|
||||
async_zip = { default-features = false, version = "0.0.19" }
|
||||
mysql_async = { default-features = false, version = "0.37.1" }
|
||||
async-compression = { version = "0.4.48" }
|
||||
async-recursion = "1.1.1"
|
||||
async-compression = { version = "0.4.50" }
|
||||
async-trait = "0.1.92"
|
||||
async-nats = { version = "0.50.0", default-features = false }
|
||||
axum = "0.8.9"
|
||||
@@ -158,7 +157,7 @@ futures-util = "0.3.34"
|
||||
pollster = "1.0.1"
|
||||
pulsar = { default-features = false, version = "6.9.0" }
|
||||
lapin = { default-features = false, version = "4.12.0" }
|
||||
hyper = { version = "1.11.1" }
|
||||
hyper = { version = "1.12.0" }
|
||||
hyper-rustls = { default-features = false, version = "0.27.10" }
|
||||
hyper-util = { version = "0.1.21" }
|
||||
http = "1.5.0"
|
||||
@@ -167,10 +166,10 @@ http-body-util = "0.1.5"
|
||||
memchr = "2.8.3"
|
||||
minlz = "1.2.3"
|
||||
reqwest = "0.13.5"
|
||||
rustfs-kafka-async = { version = "1.3.1" }
|
||||
rustfs-kafka-async = { version = "1.4.0" }
|
||||
socket2 = { version = "0.6.5" }
|
||||
tokio = { version = "1.53.1" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.5" }
|
||||
tokio = { version = "1.53.2" }
|
||||
tokio-rustls = { default-features = false, version = "0.26.6" }
|
||||
tokio-stream = { version = "0.1.19" }
|
||||
tokio-test = "0.4.6"
|
||||
tokio-util = { version = "0.7.19" }
|
||||
@@ -179,6 +178,13 @@ tonic-prost = { version = "0.14.6" }
|
||||
tonic-prost-build = { version = "0.14.6" }
|
||||
tower = { version = "0.5.3" }
|
||||
tower-http = { version = "0.7.1" }
|
||||
h3 = "0.0.8"
|
||||
h3-quinn = "0.0.10"
|
||||
quinn = { version = "0.11.11", default-features = false, features = [
|
||||
"futures-io",
|
||||
"runtime-tokio",
|
||||
"rustls-aws-lc-rs",
|
||||
] }
|
||||
|
||||
# Serialization and Data Formats
|
||||
apache-avro = { version = "0.22.0", features = ["snappy", "zstandard"] }
|
||||
@@ -220,13 +226,13 @@ x509-parser = "0.18.1"
|
||||
sha1 = "0.11.0"
|
||||
sha2 = "0.11.0"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9.0" }
|
||||
zeroize = { version = "1.9.1" }
|
||||
proptest = "1"
|
||||
|
||||
# Time and Date
|
||||
chrono = { version = "0.4.45" }
|
||||
humantime = "2.4.0"
|
||||
jiff = { version = "0.2.37" }
|
||||
jiff = { version = "0.2.38" }
|
||||
time = { version = "0.3.55" }
|
||||
|
||||
# Database
|
||||
@@ -245,12 +251,12 @@ atoi = "3.1.0"
|
||||
atomic_enum = "0.3.0"
|
||||
aws-config = { version = "1.12.0" }
|
||||
aws-credential-types = { version = "1.3.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.122.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.150.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.118.0" }
|
||||
aws-sdk-kms = { default-features = false, version = "1.123.0" }
|
||||
aws-sdk-s3 = { default-features = false, version = "1.152.0" }
|
||||
aws-sdk-sts = { default-features = false, version = "1.119.0" }
|
||||
aws-smithy-async = { version = "1.3.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.4.2" }
|
||||
aws-smithy-runtime-api = { version = "1.18.0" }
|
||||
aws-smithy-http-client = { default-features = false, version = "1.5.0" }
|
||||
aws-smithy-runtime-api = { version = "1.19.0" }
|
||||
aws-smithy-types = { version = "1.8.1" }
|
||||
base64-simd = "0.8.0"
|
||||
brotli = "9.0.0"
|
||||
@@ -269,7 +275,7 @@ enumset = "1.1.14"
|
||||
faster-hex = "1.0.0"
|
||||
flate2 = "1.1.10"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.19.0"
|
||||
google-cloud-storage = "1.20.0"
|
||||
google-cloud-auth = "1.17.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
# Base32 for RFC 6238 TOTP shared secrets (RFC 4648 unpadded, the alphabet
|
||||
@@ -279,8 +285,8 @@ hex-simd = "0.8.0"
|
||||
highway = { version = "1.3.0" }
|
||||
hostname = "0.4.2"
|
||||
ipnetwork = { version = "0.21.1" }
|
||||
lazy_static = "1.5.0"
|
||||
libc = "0.2.189"
|
||||
lazy_static = "1.5.1"
|
||||
libc = "0.2.190"
|
||||
libsystemd = "0.7.2"
|
||||
local-ip-address = "0.6.13"
|
||||
log = "0.4"
|
||||
@@ -300,7 +306,7 @@ percent-encoding = "2.3.2"
|
||||
# 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
|
||||
# pull in an image stack this only needs SVG and text output from.
|
||||
qrcode-rs = { version = "2.1.1", default-features = false, features = ["std", "svg"] }
|
||||
qrcode-rs = { version = "2.2.0", default-features = false, features = ["std", "svg"] }
|
||||
pin-project-lite = "0.2.17"
|
||||
pretty_assertions = "1.4.1"
|
||||
rand = { version = "0.10.3" }
|
||||
@@ -315,16 +321,21 @@ rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.5" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
s3s = { version = "0.17.0", features = ["minio"] }
|
||||
s3s-sigv4 = "0.17.0"
|
||||
s3s = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
|
||||
s3s-sigv4 = { git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0" }
|
||||
# The same s3s pin under the name the legacy edge of the gateway migration uses
|
||||
# (crates/s3-types, feature `compat-s3s`). Keep `rev` identical to `s3s` above.
|
||||
# DELETE BY T4.2 (rustfs/backlog#2784).
|
||||
legacy-s3s = { package = "s3s", git = "https://github.com/s3s-project/s3s", rev = "5761ddfe4c509fcac87e3b99fd5bd9e176b442c0", features = ["minio"] }
|
||||
serial_test = "4.0.1"
|
||||
shadow-rs = { default-features = false, version = "2.0.0" }
|
||||
siphasher = "1.0.4"
|
||||
smallvec = { version = "1.16.2" }
|
||||
compact_str = "0.10.0"
|
||||
snap = "1.1.2"
|
||||
starshard = { version = "2.3.0" }
|
||||
starshard = { version = "2.4.0" }
|
||||
strum = { version = "0.28.0" }
|
||||
sync_wrapper = "1.0.2"
|
||||
sysinfo = "0.39.6"
|
||||
temp-env = "0.3.6"
|
||||
tempfile = "3.27.0"
|
||||
@@ -339,21 +350,21 @@ tracing-subscriber = { version = "0.3.23" }
|
||||
transform-stream = "0.3.1"
|
||||
url = "2.5.8"
|
||||
urlencoding = "2.1.3"
|
||||
uuid = { version = "1.26.1" }
|
||||
uuid = { version = "1.27.0" }
|
||||
vaultrs = { version = "0.8.0" }
|
||||
tar = "0.4.46"
|
||||
walkdir = "2.5.0"
|
||||
winapi-util = "0.1.11"
|
||||
windows = { version = "0.62.2" }
|
||||
windows-sys = "0.61.2"
|
||||
xxhash-rust = { version = "0.8.18" }
|
||||
xxhash-rust = { version = "0.8.19" }
|
||||
zip = "8.6.0"
|
||||
zstd = "0.14.0"
|
||||
|
||||
# Observability and Metrics
|
||||
metrics = "0.24.6"
|
||||
metrics-util = "0.20"
|
||||
dial9 = { version = "0.5.2", default-features = false }
|
||||
dial9 = { version = "0.5.3", default-features = false }
|
||||
opentelemetry = { version = "0.33.0" }
|
||||
opentelemetry-appender-tracing = { version = "0.33.0" }
|
||||
opentelemetry-otlp = { version = "0.33.0" }
|
||||
@@ -366,10 +377,10 @@ pyroscope = { version = "2.1.1" }
|
||||
# FTP and SFTP
|
||||
libunftp = { version = "0.23.1" }
|
||||
unftp-core = "0.1.0"
|
||||
suppaftp = { version = "12.1.0" }
|
||||
suppaftp = { version = "12.1.1" }
|
||||
rcgen = { version = "0.14.10", default-features = false, features = ["aws_lc_rs", "crypto", "pem"] }
|
||||
russh = { version = "0.63.3" }
|
||||
russh-sftp = "3.0.0"
|
||||
russh = { version = "0.64.1" }
|
||||
russh-sftp = "3.0.1"
|
||||
|
||||
# WebDAV
|
||||
dav-server = "0.11.0"
|
||||
@@ -377,9 +388,9 @@ dav-server = "0.11.0"
|
||||
# Performance Analysis and Memory Profiling
|
||||
rustfs-mimalloc = { version = "0.5.6" }
|
||||
# Preserve Unicode focus filters until rustfs/backlog#2302 is resolved.
|
||||
hotpath = { version = "0.26.1", default-features = false }
|
||||
hotpath = { version = "0.28.5", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
insta = { version = "1.48" }
|
||||
insta = { version = "1.49" }
|
||||
|
||||
[workspace.metadata.cargo-shear]
|
||||
ignored = ["hotpath", "rustfs"]
|
||||
|
||||
@@ -141,7 +141,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# Using specific version
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.1
|
||||
```
|
||||
|
||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||
@@ -220,7 +220,7 @@ For developers who want to build RustFS Docker images from source with multi-arc
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# Build specific version
|
||||
./docker-buildx.sh --release v1.0.0 --push
|
||||
./docker-buildx.sh --release 1.0.1 --push
|
||||
|
||||
# Build for custom registry
|
||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||
@@ -238,7 +238,7 @@ You can also use Make targets for convenience:
|
||||
```bash
|
||||
make docker-buildx # Build locally
|
||||
make docker-buildx-push # Build and push
|
||||
make docker-buildx-version VERSION=v1.0.0 # Build specific version
|
||||
make docker-buildx-version VERSION=1.0.1 # Build specific version
|
||||
make help-docker # Show all Docker-related commands
|
||||
```
|
||||
|
||||
|
||||
+3
-3
@@ -138,7 +138,7 @@ chown -R 10001:10001 data logs
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||
|
||||
# 使用指定版本运行
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.1
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
@@ -176,7 +176,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
./docker-buildx.sh --push
|
||||
|
||||
# 构建指定版本
|
||||
./docker-buildx.sh --release v1.0.0 --push
|
||||
./docker-buildx.sh --release 1.0.1 --push
|
||||
|
||||
# 构建并推送到自定义仓库
|
||||
./docker-buildx.sh --registry your-registry.com --namespace yourname --push
|
||||
@@ -193,7 +193,7 @@ docker compose -f docker-compose-simple.yml up -d
|
||||
```bash
|
||||
make docker-buildx # 本地构建
|
||||
make docker-buildx-push # 构建并推送
|
||||
make docker-buildx-version VERSION=v1.0.0 # 构建指定版本
|
||||
make docker-buildx-version VERSION=1.0.1 # 构建指定版本
|
||||
make help-docker # 显示所有 Docker 相关命令
|
||||
```
|
||||
|
||||
|
||||
@@ -168,9 +168,10 @@ Scanner cycle budget controls:
|
||||
|
||||
## Foreground write admission environment variables
|
||||
|
||||
Large direct `PutObject` requests and multipart `UploadPart` requests share one
|
||||
per-process permit pool that bounds concurrent body ingest and storage writes.
|
||||
Small direct PUTs stay on the legacy path.
|
||||
Large direct `PutObject` requests, multipart `UploadPart` requests, and
|
||||
server-side multipart `UploadPartCopy` requests share one per-process permit
|
||||
pool that bounds concurrent body ingest and storage writes. Small direct PUTs
|
||||
stay on the legacy path.
|
||||
|
||||
- `RUSTFS_PUT_LARGE_FOREGROUND_ADMISSION_ENABLE`
|
||||
- enables the default-on pool; `false` keeps only the soft request counter.
|
||||
@@ -190,11 +191,11 @@ Small direct PUTs stay on the legacy path.
|
||||
- smallest `UploadPart` that takes a permit; `0` gates every part.
|
||||
- default is `0`.
|
||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||
- how long an `UploadPart` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
|
||||
- default is `10000`. Parts wait before body ingest, so SDK-default clients that send every part of an upload concurrently drain through the pool instead of failing on a full pool.
|
||||
- RustFS does not read the request body while a part is queued, so the client's socket write stalls for the whole wait and whatever timeout the client or an intermediary has configured competes with this value. Keep it with margin below the shortest such timeout in use (botocore applies its 60 s `connect_timeout` to the body write; the AWS SDK for Java v2 has a 30 s socket write timeout; reverse proxies add their own body timeouts); a wait that outlives the client timeout surfaces as a dropped connection instead of `SlowDown`.
|
||||
- how long an `UploadPart` or `UploadPartCopy` waits in the bounded queue for a permit before returning S3 `SlowDown`; `0` rejects immediately when the pool is full.
|
||||
- default is `10000`. Parts wait before body ingest or source-object reads, 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. `UploadPartCopy` waits before taking bucket lifecycle locks or opening source readers, and uses the unknown-size weight because the authoritative copy length is available only after source metadata and range validation. Keep the wait with margin below the shortest client/proxy timeout in use (botocore applies its 60 s `connect_timeout` to the body write; the AWS SDK for Java v2 has a 30 s socket write timeout; reverse proxies add their own body timeouts); a wait that outlives the client timeout surfaces as a dropped connection instead of `SlowDown`.
|
||||
- `RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MAX_PENDING`
|
||||
- maximum `UploadPart` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
|
||||
- maximum `UploadPart` and `UploadPartCopy` requests waiting for a permit at once; parts beyond it return `SlowDown` without waiting.
|
||||
- default is `0`, which derives 16 times the large-write slot count, or the explicit request-count limit (512 at stock settings). Automatic subdivision does not enlarge this queue.
|
||||
- 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). Autotuned receive buffers can remain large on reused connections; queue depth does not imply a fixed per-connection memory cost.
|
||||
- `RUSTFS_PUT_FOREGROUND_ADMISSION_ENABLE`, `RUSTFS_PUT_FOREGROUND_ADMISSION_LIMIT`, `RUSTFS_PUT_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS`
|
||||
|
||||
@@ -18,10 +18,8 @@ use const_str::concat;
|
||||
/// Default value: RustFS
|
||||
/// Environment variable: RUSTFS_APP_NAME
|
||||
pub const APP_NAME: &str = "RustFS";
|
||||
/// Application version
|
||||
/// Default value: 1.0.0
|
||||
/// Environment variable: RUSTFS_VERSION
|
||||
pub const VERSION: &str = "1.0.0";
|
||||
/// Cargo package version for this workspace member.
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Default configuration logger level
|
||||
/// Default value: error
|
||||
@@ -39,12 +37,6 @@ pub const SAMPLE_RATIO: f64 = 1.0;
|
||||
/// Default value: 30
|
||||
pub const METER_INTERVAL: u64 = 30;
|
||||
|
||||
/// Default configuration service version
|
||||
/// Default value: 1.0.0
|
||||
/// Environment variable: RUSTFS_OBS_SERVICE_VERSION
|
||||
/// Uses the same value as VERSION constant
|
||||
pub const SERVICE_VERSION: &str = "1.0.0";
|
||||
|
||||
/// Default configuration environment
|
||||
/// Default value: production
|
||||
pub const ENVIRONMENT: &str = "production";
|
||||
@@ -410,7 +402,6 @@ mod tests {
|
||||
APP_NAME,
|
||||
VERSION,
|
||||
DEFAULT_LOG_LEVEL,
|
||||
SERVICE_VERSION,
|
||||
ENVIRONMENT,
|
||||
RUSTFS_TLS_KEY,
|
||||
RUSTFS_TLS_CERT,
|
||||
@@ -446,8 +437,7 @@ mod tests {
|
||||
fn test_configuration_consistency() {
|
||||
// Test configuration consistency
|
||||
|
||||
// Version consistency
|
||||
assert_eq!(VERSION, SERVICE_VERSION, "Application version should match service version");
|
||||
assert_eq!(VERSION, env!("CARGO_PKG_VERSION"));
|
||||
|
||||
// Port conflict check
|
||||
let ports = [DEFAULT_PORT, DEFAULT_CONSOLE_PORT];
|
||||
|
||||
@@ -387,7 +387,9 @@ pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 32 * 10
|
||||
/// Multipart pressure is often many moderate-sized parts rather than one very
|
||||
/// large request. The default gates every multipart part through the same permit
|
||||
/// pool as large/unknown-size PutObject while keeping small direct PUTs on the
|
||||
/// legacy path.
|
||||
/// legacy path. Server-side UploadPartCopy requests use this same multipart
|
||||
/// admission path with unknown-size weighting because source metadata and range
|
||||
/// validation happen after admission.
|
||||
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: &str =
|
||||
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES";
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_MIN_SIZE_BYTES: usize = 0;
|
||||
@@ -415,7 +417,9 @@ pub const DEFAULT_PUT_LARGE_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 250;
|
||||
/// 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.
|
||||
/// connection (issue #7385). Server-side UploadPartCopy waits in the same queue
|
||||
/// before taking bucket lifecycle locks or opening source readers. `0` rejects
|
||||
/// immediately when the pool is full.
|
||||
pub const ENV_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: &str =
|
||||
"RUSTFS_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS";
|
||||
pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 10_000;
|
||||
@@ -427,7 +431,7 @@ pub const DEFAULT_PUT_MULTIPART_FOREGROUND_ADMISSION_WAIT_TIMEOUT_MS: u64 = 10_0
|
||||
// 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 or UploadPartCopy requests waiting for a foreground write permit per process.
|
||||
///
|
||||
/// Parts beyond this queue depth are rejected with S3 `SlowDown`/503 without
|
||||
/// waiting, so a genuinely saturated node still fails fast instead of holding
|
||||
|
||||
@@ -207,3 +207,9 @@ pub const ENV_TLS_RELOAD_INTERVAL: &str = "RUSTFS_TLS_RELOAD_INTERVAL";
|
||||
|
||||
/// Default interval for TLS certificate reload check
|
||||
pub const DEFAULT_TLS_RELOAD_INTERVAL: u64 = 30;
|
||||
|
||||
/// Environment variable to enable experimental HTTP/3 support.
|
||||
pub const ENV_HTTP3_ENABLE: &str = "RUSTFS_HTTP3_ENABLE";
|
||||
|
||||
/// HTTP/3 is disabled unless explicitly enabled.
|
||||
pub const DEFAULT_HTTP3_ENABLE: bool = false;
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
//! tracked separately.
|
||||
|
||||
use crate::common::{ClusterTopology, RustFSTestClusterEnvironment};
|
||||
use crate::fault_proxy::FaultMode;
|
||||
use aws_sdk_s3::Client;
|
||||
use std::time::Duration;
|
||||
use tokio::time::{Instant, timeout};
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
@@ -144,7 +148,99 @@ async fn cluster_volume_fault_proxy_pass_smoke() -> TestResult {
|
||||
cluster.start().await?;
|
||||
cluster.create_test_bucket(BUCKET).await?;
|
||||
let payload = vec![0x6Du8; 256 * 1024];
|
||||
put_get_roundtrip(&cluster, "volume-proxy/object", &payload).await
|
||||
put_get_roundtrip(&cluster, "volume-proxy/object", &payload).await?;
|
||||
|
||||
// Node 1 owns only two disks; a successful three-vote write must
|
||||
// authenticate at least one disk mutation through node 0's proxy.
|
||||
let client = cluster.create_s3_client(1)?;
|
||||
let client = Client::from_conf(
|
||||
client
|
||||
.config()
|
||||
.to_builder()
|
||||
.retry_config(aws_sdk_s3::config::retry::RetryConfig::standard().with_max_attempts(1))
|
||||
.build(),
|
||||
);
|
||||
let forwarded_before = proxy.forwarded_bytes();
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key("volume-proxy/authenticated")
|
||||
.body(bytes::Bytes::copy_from_slice(&payload).into())
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
proxy.forwarded_bytes() > forwarded_before,
|
||||
"successful peer-disk publication must traverse the proxy"
|
||||
);
|
||||
|
||||
let dropped_before = proxy.dropped_bytes();
|
||||
proxy.set_mode(FaultMode::Blackhole);
|
||||
assert_eq!(proxy.mode(), FaultMode::Blackhole);
|
||||
let error = timeout(
|
||||
Duration::from_secs(30),
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key("volume-proxy/rejected")
|
||||
.body(bytes::Bytes::copy_from_slice(&payload).into())
|
||||
.send(),
|
||||
)
|
||||
.await?
|
||||
.expect_err("two local disks cannot commit a write while the peer is blackholed");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
assert_eq!(error.as_service_error().and_then(|error| error.meta().code()), Some("ServiceUnavailable"));
|
||||
assert!(
|
||||
proxy.dropped_bytes() > dropped_before,
|
||||
"the injected blackhole must discard actual peer traffic"
|
||||
);
|
||||
|
||||
proxy.set_mode(FaultMode::Pass);
|
||||
assert_eq!(proxy.mode(), FaultMode::Pass);
|
||||
let http = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(3))
|
||||
.build()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let response = http.get(format!("{}/health/ready", cluster.nodes[1].url)).send().await?;
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
// Reachable peers can still have Returning disk handles that reject
|
||||
// writes. Wait for this node's live writer inventory to be Online.
|
||||
if status.as_u16() == 200
|
||||
&& body["details"]["storage"]["source"] == "local_runtime"
|
||||
&& body["details"]["storage"]["writeQuorum"] == true
|
||||
&& body["details"]["storage"]["unavailableDrives"]
|
||||
.as_array()
|
||||
.is_some_and(Vec::is_empty)
|
||||
&& body["details"]["lock"]["ready"] == true
|
||||
{
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "peer write quorum did not recover: HTTP {status}, {body}");
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
client
|
||||
.put_object()
|
||||
.bucket(BUCKET)
|
||||
.key("volume-proxy/recovered")
|
||||
.body(bytes::Bytes::copy_from_slice(&payload).into())
|
||||
.send()
|
||||
.await?;
|
||||
for key in ["volume-proxy/object", "volume-proxy/authenticated", "volume-proxy/recovered"] {
|
||||
let got = client.get_object().bucket(BUCKET).key(key).send().await?;
|
||||
let body = got.body.collect().await?.into_bytes();
|
||||
assert_eq!(body.as_ref(), payload, "proxy recovery must preserve the full object body for {key}");
|
||||
}
|
||||
let rejected = client
|
||||
.head_object()
|
||||
.bucket(BUCKET)
|
||||
.key("volume-proxy/rejected")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a rejected proxied write must never become visible after recovery");
|
||||
assert_eq!(rejected.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ use s3s::Body;
|
||||
use serde_json;
|
||||
use std::fs as stdfs;
|
||||
use std::io::ErrorKind;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{Ipv6Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Once;
|
||||
@@ -1442,7 +1442,11 @@ impl RustFSTestClusterEnvironment {
|
||||
return Err(format!("a volume proxy is already configured for node {node_idx}").into());
|
||||
}
|
||||
let target = self.nodes[node_idx].address.parse::<SocketAddr>()?;
|
||||
let proxy = crate::fault_proxy::FaultProxy::start(target).await?;
|
||||
// Endpoint locality and RPC audience both use the advertised authority.
|
||||
// Keep the node's port while the second loopback IP separates the proxy
|
||||
// listener from the direct IPv4 server and S3 client address.
|
||||
let listen = SocketAddr::from((Ipv6Addr::LOCALHOST, target.port()));
|
||||
let proxy = crate::fault_proxy::FaultProxy::start_on(listen, target).await?;
|
||||
self.volume_proxy_addresses[node_idx] = Some(proxy.local_addr());
|
||||
Ok(proxy)
|
||||
}
|
||||
@@ -2467,6 +2471,20 @@ mod tests {
|
||||
|
||||
assert!(volumes.contains(&proxied), "volumes must use the proxy address: {volumes}");
|
||||
assert!(!volumes.contains(&direct), "volumes must not retain the direct address: {volumes}");
|
||||
assert_eq!(proxy.local_addr().port(), proxy.target_addr().port());
|
||||
assert_ne!(proxy.local_addr().ip(), proxy.target_addr().ip());
|
||||
assert_eq!(env.nodes[0].address, direct, "S3 clients must retain the direct address");
|
||||
let mut endpoint =
|
||||
crate::storage_api::Endpoint::try_from(volumes.as_str()).expect("proxied volume should be a valid endpoint");
|
||||
endpoint
|
||||
.update_is_local(proxy.target_addr().port())
|
||||
.expect("proxied endpoint locality should resolve");
|
||||
assert!(endpoint.is_local, "the proxied node must keep its local disk and first-disk authority");
|
||||
assert_eq!(
|
||||
endpoint.host_port(),
|
||||
proxied,
|
||||
"the local RPC audience must be the advertised proxy authority"
|
||||
);
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,60 @@ use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{Delete, MetadataDirective, ObjectIdentifier};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_bucket_tags_opa_refreshes_warm_peer_caches() -> TestResult {
|
||||
use super::harness::cluster_admin_ok;
|
||||
use crate::sts_query_compat_test::{OPA_AUTH_TOKEN, OpaMock, set_department};
|
||||
|
||||
init_logging();
|
||||
let mut opa = OpaMock::start().await?;
|
||||
let dist = DistCluster::start_with_env(
|
||||
DistLayout::FourNodeFourDisk,
|
||||
&[
|
||||
("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str()),
|
||||
("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", OPA_AUTH_TOKEN),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
let secret = uuid::Uuid::new_v4().to_string();
|
||||
cluster_admin_ok(
|
||||
&dist.cluster,
|
||||
http::Method::PUT,
|
||||
"/rustfs/admin/v3/add-user?accessKey=opabuckettags",
|
||||
Some(serde_json::json!({"secretKey": secret, "status": "enabled"}).to_string()),
|
||||
)
|
||||
.await?;
|
||||
let bucket = unique_bucket("opa-tags");
|
||||
dist.create_bucket(&bucket).await?;
|
||||
let admin = dist.client(0)?;
|
||||
put_object(&admin, &bucket, "report", b"report".to_vec()).await?;
|
||||
let clients = (0..4)
|
||||
.map(|node| dist.client_with_credentials(node, "opabuckettags", &secret))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// Warm every peer, then verify the acknowledged updates/removal without
|
||||
// polling or restarting nodes. This exercises healthy-peer metadata reload.
|
||||
for department in [Some("finance"), Some("engineering"), None, Some("finance")] {
|
||||
match department {
|
||||
Some(value) => set_department(&admin, &bucket, value).await?,
|
||||
None => {
|
||||
admin.delete_bucket_tagging().bucket(&bucket).send().await?;
|
||||
}
|
||||
}
|
||||
for client in &clients {
|
||||
let result = client.get_object().bucket(&bucket).key("report").send().await;
|
||||
if department == Some("finance") {
|
||||
assert_eq!(result?.body.collect().await?.into_bytes().as_ref(), b"report");
|
||||
} else {
|
||||
let error = result.expect_err("updated or removed bucket tags must revoke this policy's read access");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
}
|
||||
opa.expect_bucket_tags("s3:GetObject", &bucket, department).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_four_drive_s3_put_get_head_list_copy_rename_delete_and_presign() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -31,6 +31,7 @@ use super::harness::{
|
||||
use crate::common::{
|
||||
AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via, init_logging,
|
||||
};
|
||||
use crate::upgrade_compatibility_test::wait_for_upgrade_write_readiness;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -400,6 +401,19 @@ async fn four_node_direct_upgrade_preserves_history_and_iam_credentials() -> Tes
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn replace_node_and_wait_for_write_readiness(dist: &mut DistCluster, seed: &UpgradeSeed, node_idx: usize) -> TestResult {
|
||||
dist.replace_node_with_current_binary(node_idx).await?;
|
||||
// Node readiness proves reads can succeed; a rolling writer must also
|
||||
// recover before replacing the next peer or checking upgrade contracts.
|
||||
wait_for_upgrade_write_readiness(
|
||||
&dist.clients()?,
|
||||
&seed.history_bucket,
|
||||
&format!("replaced-node-{node_idx}"),
|
||||
CREDENTIAL_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> TestResult {
|
||||
init_logging();
|
||||
@@ -410,15 +424,15 @@ async fn four_node_rolling_upgrade_preserves_history_and_iam_credentials() -> Te
|
||||
|
||||
let seed = seed_history_and_iam(&dist).await?;
|
||||
|
||||
dist.replace_node_with_current_binary(0).await?;
|
||||
replace_node_and_wait_for_write_readiness(&mut dist, &seed, 0).await?;
|
||||
assert_history_and_iam(&dist, &seed, "one-current-node").await?;
|
||||
|
||||
for node_idx in [1, 2] {
|
||||
dist.replace_node_with_current_binary(node_idx).await?;
|
||||
replace_node_and_wait_for_write_readiness(&mut dist, &seed, node_idx).await?;
|
||||
}
|
||||
assert_history_and_iam(&dist, &seed, "one-previous-node").await?;
|
||||
|
||||
dist.replace_node_with_current_binary(3).await?;
|
||||
replace_node_and_wait_for_write_readiness(&mut dist, &seed, 3).await?;
|
||||
assert_history_and_iam(&dist, &seed, "homogeneous-current").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
// 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.
|
||||
|
||||
//! What one stack answered, and how two answers are compared.
|
||||
//!
|
||||
//! An [`Observation`] keeps only what a client acts on: the HTTP status, the S3 error code, the
|
||||
//! allowlisted response headers and a few typed fields decoded from the response. The error
|
||||
//! message is kept for diagnostics and never compared: message wording is judged by the wire
|
||||
//! corpus, not here. The comparator is pure so it can be tested without a server.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Response headers compared exactly (after [`normalized_header_value`]).
|
||||
const HEADER_ALLOWLIST: [&str; 7] = [
|
||||
"content-type",
|
||||
"x-amz-version-id",
|
||||
"etag",
|
||||
"x-amz-delete-marker",
|
||||
"content-range",
|
||||
"accept-ranges",
|
||||
"x-amz-request-charged",
|
||||
];
|
||||
|
||||
/// Every response header with this prefix is compared as well.
|
||||
const HEADER_ALLOWLIST_PREFIX: &str = "x-amz-server-side-encryption";
|
||||
|
||||
/// Placeholder for a field one side did not send.
|
||||
const ABSENT: &str = "<absent>";
|
||||
|
||||
/// The lowercase header name if the comparator looks at this header.
|
||||
pub(crate) fn compared_header(name: &str) -> Option<String> {
|
||||
let name = name.to_ascii_lowercase();
|
||||
(HEADER_ALLOWLIST.contains(&name.as_str()) || name.starts_with(HEADER_ALLOWLIST_PREFIX)).then_some(name)
|
||||
}
|
||||
|
||||
/// A version ID is minted per process, so its value never matches across two servers; only
|
||||
/// whether it is the literal `null` (an unversioned write) carries meaning.
|
||||
fn normalized_header_value(name: &str, value: &str) -> String {
|
||||
if name == "x-amz-version-id" && value != "null" {
|
||||
"<version-id>".to_owned()
|
||||
} else {
|
||||
value.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// One request's outcome as a client sees it.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub(crate) struct Observation {
|
||||
/// The S3 operation, named as in the `s3s::S3` trait (`GetObject`).
|
||||
pub op: &'static str,
|
||||
/// Which request of the script this was (`normal`, `negative`, or a setup label).
|
||||
pub case: &'static str,
|
||||
/// HTTP status; 0 when no response arrived.
|
||||
pub status: u16,
|
||||
/// The S3 error code, or the SDK's error kind when there was no service error.
|
||||
pub error_code: Option<String>,
|
||||
/// Allowlisted headers only, lowercase names, normalized values.
|
||||
pub headers: BTreeMap<String, String>,
|
||||
/// Typed values decoded from the response body, never free text.
|
||||
pub fields: BTreeMap<String, String>,
|
||||
/// Diagnostic only; never compared.
|
||||
#[serde(skip)]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl Observation {
|
||||
/// Builds an observation, keeping only the allowlisted headers.
|
||||
pub(crate) fn new<'h>(
|
||||
op: &'static str,
|
||||
case: &'static str,
|
||||
status: u16,
|
||||
error_code: Option<String>,
|
||||
headers: impl IntoIterator<Item = (&'h str, &'h str)>,
|
||||
) -> Self {
|
||||
let headers = headers
|
||||
.into_iter()
|
||||
.filter_map(|(name, value)| compared_header(name).map(|name| (name, value)))
|
||||
.map(|(name, value)| {
|
||||
let value = normalized_header_value(&name, value);
|
||||
(name, value)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
op,
|
||||
case,
|
||||
status,
|
||||
error_code,
|
||||
headers,
|
||||
fields: BTreeMap::new(),
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_field(mut self, name: &str, value: impl Into<String>) -> Self {
|
||||
self.fields.insert(name.to_owned(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn is_success(&self) -> bool {
|
||||
(200..300).contains(&self.status)
|
||||
}
|
||||
}
|
||||
|
||||
/// One field on which the two stacks answered differently.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub(crate) struct Difference {
|
||||
pub op: String,
|
||||
pub case: String,
|
||||
pub field: String,
|
||||
pub a: String,
|
||||
pub b: String,
|
||||
}
|
||||
|
||||
/// Every difference between two transcripts of the same script, matched by `(op, case)`.
|
||||
pub(crate) fn compare(a: &[Observation], b: &[Observation]) -> Vec<Difference> {
|
||||
let mut differences = Vec::new();
|
||||
let (index_a, duplicates_a) = index(a);
|
||||
let (index_b, duplicates_b) = index(b);
|
||||
for (op, case) in duplicates_a.union(&duplicates_b) {
|
||||
let count = |side: &[Observation]| {
|
||||
side.iter()
|
||||
.filter(|observation| (observation.op, observation.case) == (*op, *case))
|
||||
.count()
|
||||
.to_string()
|
||||
};
|
||||
differences.push(difference(op, case, "duplicate_case", count(a), count(b)));
|
||||
}
|
||||
let keys: BTreeSet<_> = index_a.keys().chain(index_b.keys()).copied().collect();
|
||||
for key @ (op, case) in keys {
|
||||
match (index_a.get(&key), index_b.get(&key)) {
|
||||
(Some(a), Some(b)) => compare_one(a, b, &mut differences),
|
||||
(a, b) => {
|
||||
let presence = |side: Option<&&Observation>| if side.is_some() { "present" } else { ABSENT }.to_owned();
|
||||
differences.push(difference(op, case, "observation", presence(a), presence(b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
differences
|
||||
}
|
||||
|
||||
type Key = (&'static str, &'static str);
|
||||
|
||||
fn index(side: &[Observation]) -> (BTreeMap<Key, &Observation>, BTreeSet<Key>) {
|
||||
let mut index = BTreeMap::new();
|
||||
let mut duplicates = BTreeSet::new();
|
||||
for observation in side {
|
||||
let key = (observation.op, observation.case);
|
||||
if index.insert(key, observation).is_some() {
|
||||
duplicates.insert(key);
|
||||
}
|
||||
}
|
||||
(index, duplicates)
|
||||
}
|
||||
|
||||
fn difference(op: &str, case: &str, field: &str, a: String, b: String) -> Difference {
|
||||
Difference {
|
||||
op: op.to_owned(),
|
||||
case: case.to_owned(),
|
||||
field: field.to_owned(),
|
||||
a,
|
||||
b,
|
||||
}
|
||||
}
|
||||
|
||||
fn compare_one(a: &Observation, b: &Observation, differences: &mut Vec<Difference>) {
|
||||
let mut field = |name: String, value_a: Option<&str>, value_b: Option<&str>| {
|
||||
if value_a != value_b {
|
||||
let show = |value: Option<&str>| value.unwrap_or(ABSENT).to_owned();
|
||||
differences.push(difference(a.op, a.case, &name, show(value_a), show(value_b)));
|
||||
}
|
||||
};
|
||||
field(
|
||||
"status".to_owned(),
|
||||
Some(a.status.to_string().as_str()),
|
||||
Some(b.status.to_string().as_str()),
|
||||
);
|
||||
field("error_code".to_owned(), a.error_code.as_deref(), b.error_code.as_deref());
|
||||
let headers: BTreeSet<_> = a.headers.keys().chain(b.headers.keys()).collect();
|
||||
for name in headers {
|
||||
field(
|
||||
format!("header.{name}"),
|
||||
a.headers.get(name).map(String::as_str),
|
||||
b.headers.get(name).map(String::as_str),
|
||||
);
|
||||
}
|
||||
let fields: BTreeSet<_> = a.fields.keys().chain(b.fields.keys()).collect();
|
||||
for name in fields {
|
||||
field(
|
||||
format!("body.{name}"),
|
||||
a.fields.get(name).map(String::as_str),
|
||||
b.fields.get(name).map(String::as_str),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Operations observed on both sides.
|
||||
pub(crate) fn compared_ops(a: &[Observation], b: &[Observation]) -> BTreeSet<&'static str> {
|
||||
let ops_b: BTreeSet<_> = b.iter().map(|observation| observation.op).collect();
|
||||
a.iter()
|
||||
.map(|observation| observation.op)
|
||||
.filter(|op| ops_b.contains(op))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `(op, case)` pairs observed on both sides.
|
||||
pub(crate) fn compared_cases(a: &[Observation], b: &[Observation]) -> usize {
|
||||
let cases_b: BTreeSet<_> = b.iter().map(|observation| (observation.op, observation.case)).collect();
|
||||
a.iter()
|
||||
.map(|observation| (observation.op, observation.case))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.intersection(&cases_b)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// The JSON report written for every run.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct Report {
|
||||
pub schema: u32,
|
||||
pub run: String,
|
||||
pub stack_a: String,
|
||||
pub stack_b: String,
|
||||
pub region_a: Option<String>,
|
||||
pub region_b: Option<String>,
|
||||
pub compared_ops: usize,
|
||||
pub compared_cases: usize,
|
||||
pub differences: Vec<Difference>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// Writes the report as `<directory>/<run>.json` and returns its path.
|
||||
pub(crate) fn write_to(&self, directory: &Path) -> std::io::Result<PathBuf> {
|
||||
std::fs::create_dir_all(directory)?;
|
||||
let path = directory.join(format!("{}.json", self.run));
|
||||
let json = serde_json::to_vec_pretty(self).map_err(std::io::Error::other)?;
|
||||
std::fs::write(&path, json)?;
|
||||
Ok(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// One line per difference, for a failing assertion.
|
||||
pub(crate) fn render(differences: &[Difference]) -> String {
|
||||
differences
|
||||
.iter()
|
||||
.map(|d| format!("({}, {}, {}, a={:?}, b={:?})", d.op, d.case, d.field, d.a, d.b))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ok(op: &'static str) -> Observation {
|
||||
Observation::new(op, "normal", 200, None, [("content-type", "application/xml")])
|
||||
}
|
||||
|
||||
fn fields(differences: &[Difference]) -> Vec<&str> {
|
||||
differences.iter().map(|d| d.field.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_transcripts_have_no_difference() {
|
||||
let a = vec![
|
||||
ok("HeadBucket"),
|
||||
ok("GetBucketLocation").with_field("location_constraint", "us-east-1"),
|
||||
];
|
||||
let b = a.clone();
|
||||
assert_eq!(compare(&a, &b), Vec::new());
|
||||
assert_eq!(compared_ops(&a, &b).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_difference_is_reported() {
|
||||
let a = vec![ok("PutBucketAcl")];
|
||||
let b = vec![Observation::new(
|
||||
"PutBucketAcl",
|
||||
"normal",
|
||||
403,
|
||||
None,
|
||||
[("content-type", "application/xml")],
|
||||
)];
|
||||
assert_eq!(
|
||||
compare(&a, &b),
|
||||
vec![Difference {
|
||||
op: "PutBucketAcl".into(),
|
||||
case: "normal".into(),
|
||||
field: "status".into(),
|
||||
a: "200".into(),
|
||||
b: "403".into(),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_code_difference_is_reported() {
|
||||
let a = vec![Observation::new("GetObject", "negative", 404, Some("NoSuchKey".into()), [])];
|
||||
let b = vec![Observation::new(
|
||||
"GetObject",
|
||||
"negative",
|
||||
404,
|
||||
Some("NoSuchBucket".into()),
|
||||
[],
|
||||
)];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["error_code"]);
|
||||
assert_eq!((differences[0].a.as_str(), differences[0].b.as_str()), ("NoSuchKey", "NoSuchBucket"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_code_on_one_side_only_is_reported() {
|
||||
let a = vec![Observation::new("HeadBucket", "negative", 404, Some("NotFound".into()), [])];
|
||||
let b = vec![Observation::new("HeadBucket", "negative", 404, None, [])];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["error_code"]);
|
||||
assert_eq!(differences[0].b, ABSENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allowlisted_header_difference_is_reported() {
|
||||
let a = vec![Observation::new("HeadObject", "normal", 200, None, [("ETag", "\"aa\"")])];
|
||||
let b = vec![Observation::new("HeadObject", "normal", 200, None, [("etag", "\"bb\"")])];
|
||||
assert_eq!(fields(&compare(&a, &b)), ["header.etag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_sent_by_one_side_only_is_reported() {
|
||||
let a = vec![Observation::new(
|
||||
"DeleteObject",
|
||||
"normal",
|
||||
204,
|
||||
None,
|
||||
[("x-amz-delete-marker", "true")],
|
||||
)];
|
||||
let b = vec![Observation::new("DeleteObject", "normal", 204, None, [])];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["header.x-amz-delete-marker"]);
|
||||
assert_eq!((differences[0].a.as_str(), differences[0].b.as_str()), ("true", ABSENT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_server_side_encryption_header_is_compared() {
|
||||
let header = "x-amz-server-side-encryption-customer-key-md5";
|
||||
let a = vec![Observation::new("PutObject", "sse-c", 200, None, [(header, "one")])];
|
||||
let b = vec![Observation::new("PutObject", "sse-c", 200, None, [(header, "two")])];
|
||||
assert_eq!(fields(&compare(&a, &b)), [format!("header.{header}")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_outside_the_allowlist_are_not_compared() {
|
||||
let a = vec![Observation::new(
|
||||
"ListBuckets",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[
|
||||
("date", "Mon"),
|
||||
("x-amz-request-id", "1"),
|
||||
("server", "a"),
|
||||
("content-length", "10"),
|
||||
],
|
||||
)];
|
||||
let b = vec![Observation::new(
|
||||
"ListBuckets",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[
|
||||
("date", "Tue"),
|
||||
("x-amz-request-id", "2"),
|
||||
("server", "b"),
|
||||
("content-length", "11"),
|
||||
],
|
||||
)];
|
||||
assert_eq!(compare(&a, &b), Vec::new());
|
||||
assert!(a[0].headers.is_empty(), "non-allowlisted headers were kept: {:?}", a[0].headers);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_version_ids_are_not_a_difference() {
|
||||
let a = vec![Observation::new(
|
||||
"PutObject",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[("x-amz-version-id", "0b1c")],
|
||||
)];
|
||||
let b = vec![Observation::new(
|
||||
"PutObject",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[("x-amz-version-id", "9f8e")],
|
||||
)];
|
||||
assert_eq!(compare(&a, &b), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_version_id_against_a_real_one_is_reported() {
|
||||
let a = vec![Observation::new(
|
||||
"PutObject",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[("x-amz-version-id", "null")],
|
||||
)];
|
||||
let b = vec![Observation::new(
|
||||
"PutObject",
|
||||
"normal",
|
||||
200,
|
||||
None,
|
||||
[("x-amz-version-id", "9f8e")],
|
||||
)];
|
||||
assert_eq!(fields(&compare(&a, &b)), ["header.x-amz-version-id"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_field_difference_is_reported() {
|
||||
let a = vec![ok("GetBucketLocation").with_field("location_constraint", "us-east-1")];
|
||||
let b = vec![ok("GetBucketLocation").with_field("location_constraint", "eu-west-1")];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["body.location_constraint"]);
|
||||
assert_eq!((differences[0].a.as_str(), differences[0].b.as_str()), ("us-east-1", "eu-west-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_body_field_on_one_side_only_is_reported() {
|
||||
let a = vec![ok("ListObjectsV2").with_field("keys", "[]")];
|
||||
let b = vec![ok("ListObjectsV2")];
|
||||
assert_eq!(fields(&compare(&a, &b)), ["body.keys"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_are_never_compared() {
|
||||
let mut a = Observation::new("GetObject", "negative", 404, Some("NoSuchKey".into()), []);
|
||||
let mut b = a.clone();
|
||||
a.message = Some("The specified key does not exist.".into());
|
||||
b.message = Some("Object not found".into());
|
||||
assert_eq!(compare(&[a], &[b]), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_case_observed_on_one_side_only_is_reported() {
|
||||
let a = vec![ok("HeadBucket"), ok("ListBuckets")];
|
||||
let b = vec![ok("HeadBucket")];
|
||||
let differences = compare(&a, &b);
|
||||
assert_eq!(fields(&differences), ["observation"]);
|
||||
assert_eq!(differences[0].op, "ListBuckets");
|
||||
assert_eq!(compared_ops(&a, &b).into_iter().collect::<Vec<_>>(), ["HeadBucket"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_case_recorded_twice_is_reported() {
|
||||
let a = vec![ok("HeadBucket"), ok("HeadBucket")];
|
||||
let b = vec![ok("HeadBucket")];
|
||||
assert_eq!(fields(&compare(&a, &b)), ["duplicate_case"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compared_ops_counts_operations_not_cases() {
|
||||
let negative = Observation::new("HeadBucket", "negative", 404, Some("NotFound".into()), []);
|
||||
let a = vec![ok("HeadBucket"), negative, ok("ListBuckets")];
|
||||
let b = a.clone();
|
||||
assert_eq!(compared_ops(&a, &b).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_lists_each_difference_as_op_field_a_b() {
|
||||
let directory = std::env::temp_dir().join(format!("dual-stack-report-{}", uuid::Uuid::new_v4()));
|
||||
let report = Report {
|
||||
schema: 1,
|
||||
run: "unit".into(),
|
||||
stack_a: "legacy".into(),
|
||||
stack_b: "legacy".into(),
|
||||
region_a: None,
|
||||
region_b: Some("eu-west-1".into()),
|
||||
compared_ops: 1,
|
||||
compared_cases: 1,
|
||||
differences: compare(
|
||||
&[ok("GetBucketLocation").with_field("location_constraint", "us-east-1")],
|
||||
&[ok("GetBucketLocation").with_field("location_constraint", "eu-west-1")],
|
||||
),
|
||||
};
|
||||
let path = report.write_to(&directory).expect("write the report");
|
||||
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).expect("read the report")).expect("parse");
|
||||
let _ = std::fs::remove_dir_all(&directory);
|
||||
assert_eq!(path.file_name().and_then(|name| name.to_str()), Some("unit.json"));
|
||||
assert_eq!(json["compared_ops"], 1);
|
||||
assert_eq!(
|
||||
json["differences"],
|
||||
serde_json::json!([{
|
||||
"op": "GetBucketLocation",
|
||||
"case": "normal",
|
||||
"field": "body.location_constraint",
|
||||
"a": "us-east-1",
|
||||
"b": "eu-west-1",
|
||||
}])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// 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.
|
||||
|
||||
//! Dual-stack differential: one `rustfs` binary, two processes, one SDK script (rustfs/backlog#2740).
|
||||
//!
|
||||
//! Stack A always runs `RUSTFS_S3_STACK=legacy`. Stack B runs the value of `RUSTFS_E2E_STACK_B`
|
||||
//! (default `legacy` until the gateway stack is assembled), and `RUSTFS_E2E_STACK_B_REGION`, when
|
||||
//! set, starts B in another region so that a difference can be injected by hand. Both servers
|
||||
//! answer the script in `scenarios`, which covers every operation of `impl S3 for FS`; `compare`
|
||||
//! reports each `(op, field, a, b)` on which the answers differ, and every run writes its report
|
||||
//! to `<target>/dual-stack/<run>.json`. Two processes, not two embedded servers: bucket metadata
|
||||
//! is process-global, so two stacks in one process would read each other's buckets.
|
||||
|
||||
mod compare;
|
||||
mod scenarios;
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, workspace_root};
|
||||
use compare::{Difference, Observation, Report, compare, compared_cases, compared_ops, render};
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
|
||||
const STACK_A: &str = "legacy";
|
||||
const ENV_STACK_B: &str = "RUSTFS_E2E_STACK_B";
|
||||
const ENV_STACK_B_REGION: &str = "RUSTFS_E2E_STACK_B_REGION";
|
||||
/// Becomes `gateway` once rustfs/backlog#2734 T2.5 assembles the gateway stack.
|
||||
const DEFAULT_STACK_B: &str = "legacy";
|
||||
/// The region the negative control gives stack A explicitly, so an inherited `RUSTFS_REGION`
|
||||
/// cannot erase the injected difference.
|
||||
const CONTROL_REGION_A: &str = "us-east-1";
|
||||
const CONTROL_REGION_B: &str = "eu-west-1";
|
||||
/// `normal` cases the legacy stack must answer with a 2xx. The comparison alone cannot tell a
|
||||
/// script that exercises real behavior from one in which both stacks fail the same way. Five of
|
||||
/// the 73 fail on legacy by design of this script: PutBucketReplication (no registered remote
|
||||
/// target), GetBucketReplication (so no configuration), PutBucketNotificationConfiguration (no
|
||||
/// notification target configured), RestoreObject (the object is not archived) and
|
||||
/// GetObjectTorrent (legacy answers `NoSuchKey`).
|
||||
const MIN_NORMAL_SUCCESSES: usize = 68;
|
||||
|
||||
struct PairConfig {
|
||||
stack_b: String,
|
||||
region_a: Option<String>,
|
||||
region_b: Option<String>,
|
||||
}
|
||||
|
||||
impl PairConfig {
|
||||
fn from_env() -> Self {
|
||||
Self {
|
||||
stack_b: std::env::var(ENV_STACK_B).unwrap_or_else(|_| DEFAULT_STACK_B.to_owned()),
|
||||
region_a: None,
|
||||
region_b: std::env::var(ENV_STACK_B_REGION).ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts one server on `stack`, overriding its region only when asked.
|
||||
async fn start_stack(stack: &str, region: Option<&str>) -> Result<RustFSTestEnvironment, BoxError> {
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
let mut vars = vec![("RUSTFS_S3_STACK", stack)];
|
||||
if let Some(region) = region {
|
||||
vars.push(("RUSTFS_REGION", region));
|
||||
}
|
||||
env.start_rustfs_server_without_cleanup_with_env(&vars).await?;
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
/// Starts stack A and stack B side by side, each with its own port and data directory.
|
||||
async fn spawn_pair(config: &PairConfig) -> Result<(RustFSTestEnvironment, RustFSTestEnvironment), BoxError> {
|
||||
tokio::try_join!(
|
||||
start_stack(STACK_A, config.region_a.as_deref()),
|
||||
start_stack(&config.stack_b, config.region_b.as_deref())
|
||||
)
|
||||
}
|
||||
|
||||
fn report_directory() -> PathBuf {
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| PathBuf::from("target"), PathBuf::from);
|
||||
let target = if target.is_absolute() {
|
||||
target
|
||||
} else {
|
||||
workspace_root().join(target)
|
||||
};
|
||||
target.join("dual-stack")
|
||||
}
|
||||
|
||||
struct Run {
|
||||
report: Report,
|
||||
path: PathBuf,
|
||||
a: Vec<Observation>,
|
||||
}
|
||||
|
||||
/// Runs the script against both stacks concurrently and writes the report.
|
||||
async fn run_differential(label: &str, config: &PairConfig) -> Run {
|
||||
let (server_a, server_b) = spawn_pair(config).await.expect("start stack A and stack B");
|
||||
let (a, b) = tokio::join!(Box::pin(scenarios::run(&server_a)), Box::pin(scenarios::run(&server_b)));
|
||||
let millis = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |elapsed| elapsed.as_millis());
|
||||
let report = Report {
|
||||
schema: 1,
|
||||
run: format!("{label}-{millis}-{}", std::process::id()),
|
||||
stack_a: STACK_A.to_owned(),
|
||||
stack_b: config.stack_b.clone(),
|
||||
region_a: config.region_a.clone(),
|
||||
region_b: config.region_b.clone(),
|
||||
compared_ops: compared_ops(&a, &b).len(),
|
||||
compared_cases: compared_cases(&a, &b),
|
||||
differences: compare(&a, &b),
|
||||
};
|
||||
let path = report.write_to(&report_directory()).expect("write the dual-stack report");
|
||||
Run { report, path, a }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn both_stacks_answer_every_operation_identically() {
|
||||
init_logging();
|
||||
let config = PairConfig::from_env();
|
||||
let run = run_differential("self-comparison", &config).await;
|
||||
let report = &run.report;
|
||||
|
||||
let mut observed: Vec<_> = run.a.iter().map(|observation| observation.op).collect();
|
||||
observed.sort_unstable();
|
||||
observed.dedup();
|
||||
assert_eq!(
|
||||
observed,
|
||||
scenarios::OPERATIONS,
|
||||
"the script must drive exactly the operations of impl S3 for FS"
|
||||
);
|
||||
assert_eq!(report.compared_ops, scenarios::OPERATIONS.len(), "report: {}", run.path.display());
|
||||
assert!(
|
||||
report.differences.is_empty(),
|
||||
"stack {} and stack {} differ on {} of {} cases (report: {}):\n{}",
|
||||
report.stack_a,
|
||||
report.stack_b,
|
||||
report.differences.len(),
|
||||
report.compared_cases,
|
||||
run.path.display(),
|
||||
render(&report.differences)
|
||||
);
|
||||
|
||||
let not_4xx: Vec<_> = run
|
||||
.a
|
||||
.iter()
|
||||
.filter(|observation| observation.case == "negative" && !(400..500).contains(&observation.status))
|
||||
.collect();
|
||||
assert!(not_4xx.is_empty(), "negative cases must fail with a 4xx on {STACK_A}: {not_4xx:#?}");
|
||||
let successes = run
|
||||
.a
|
||||
.iter()
|
||||
.filter(|observation| observation.case == "normal" && observation.is_success())
|
||||
.count();
|
||||
assert!(
|
||||
successes >= MIN_NORMAL_SUCCESSES,
|
||||
"only {successes} normal cases succeeded on {STACK_A}: {:#?}",
|
||||
run.a
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn differential_detects_an_injected_difference() {
|
||||
init_logging();
|
||||
let config = PairConfig {
|
||||
stack_b: STACK_A.to_owned(),
|
||||
region_a: Some(CONTROL_REGION_A.to_owned()),
|
||||
region_b: Some(CONTROL_REGION_B.to_owned()),
|
||||
};
|
||||
let run = run_differential("injected-region", &config).await;
|
||||
let expected = Difference {
|
||||
op: "GetBucketLocation".to_owned(),
|
||||
case: "normal".to_owned(),
|
||||
field: "body.location_constraint".to_owned(),
|
||||
a: CONTROL_REGION_A.to_owned(),
|
||||
b: CONTROL_REGION_B.to_owned(),
|
||||
};
|
||||
assert!(
|
||||
run.report.differences.contains(&expected),
|
||||
"B started in {CONTROL_REGION_B} but GetBucketLocation was not reported (report: {}):\n{}",
|
||||
run.path.display(),
|
||||
render(&run.report.differences)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -50,8 +50,8 @@ use std::collections::{BTreeMap, HashMap, VecDeque};
|
||||
use std::error::Error;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
@@ -363,6 +363,14 @@ pub struct RequestRecord {
|
||||
pub upload_id: Option<String>,
|
||||
pub part_number: Option<i32>,
|
||||
pub content_length: Option<u64>,
|
||||
pub source_version_id: Option<String>,
|
||||
pub source_mtime: Option<String>,
|
||||
pub source_etag: Option<String>,
|
||||
pub source_replication_request: bool,
|
||||
pub journaled_at_unix_millis: Option<u64>,
|
||||
/// Computed by the target, before response stall/disconnect faults. This
|
||||
/// does not prove that any response bytes reached the client.
|
||||
pub prepared_response: Option<PreparedResponse>,
|
||||
pub consumed_bytes: Option<usize>,
|
||||
pub replication_timestamps: ReplicationTimestampHeaders,
|
||||
pub proxy_headers: ProxyHeaderSnapshot,
|
||||
@@ -381,6 +389,20 @@ pub struct RequestRecord {
|
||||
pub fault: Option<FaultAction>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PreparedResponse {
|
||||
pub status: u16,
|
||||
pub version_id: Option<String>,
|
||||
pub etag: Option<String>,
|
||||
pub last_modified: Option<String>,
|
||||
pub content_length: Option<u64>,
|
||||
pub prepared_at_unix_millis: Option<u64>,
|
||||
pub elapsed: Duration,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct RequestSequence(Arc<OnceLock<u64>>);
|
||||
|
||||
/// Integrity and framing headers of an upload. A remote target's acceptance
|
||||
/// rules key off exactly these (rustfs#6853: `aws-chunked` framing stored
|
||||
/// verbatim; rustfs#7082: Object Lock PUTs need `Content-MD5` or
|
||||
@@ -430,6 +452,10 @@ impl TransportSnapshot {
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct JournaledHeaders {
|
||||
content_length: Option<u64>,
|
||||
source_version_id: Option<String>,
|
||||
source_mtime: Option<String>,
|
||||
source_etag: Option<String>,
|
||||
source_replication_request: bool,
|
||||
replication_timestamps: ReplicationTimestampHeaders,
|
||||
proxy_headers: ProxyHeaderSnapshot,
|
||||
transport: TransportSnapshot,
|
||||
@@ -444,6 +470,10 @@ impl JournaledHeaders {
|
||||
.get(CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse().ok()),
|
||||
source_version_id: header_value(headers, &SOURCE_VERSION_ID_HEADERS).map(bounded_journal_value),
|
||||
source_mtime: header_value(headers, &SOURCE_MTIME_HEADERS).map(bounded_journal_value),
|
||||
source_etag: header_value(headers, &SOURCE_ETAG_HEADERS).map(bounded_journal_value),
|
||||
source_replication_request: header_value(headers, &SOURCE_REPLICATION_REQUEST_HEADERS).as_deref() == Some("true"),
|
||||
replication_timestamps: ReplicationTimestampHeaders::from_headers(headers),
|
||||
proxy_headers: ProxyHeaderSnapshot::from_headers(headers),
|
||||
transport: TransportSnapshot::from_headers(headers),
|
||||
@@ -511,6 +541,10 @@ struct StoreState {
|
||||
/// version id the target never had answers 404 `NoSuchVersion` instead of
|
||||
/// the idempotent 204 RustFS/MinIO give.
|
||||
reject_unknown_version_deletes: bool,
|
||||
/// Models MinIO and its derivatives (Storj): `X-Minio-Internal-` is a reserved
|
||||
/// metadata prefix, so a PutObject or UploadPart carrying any such request
|
||||
/// header is refused with `InvalidArgument` (rustfs#8362).
|
||||
reject_reserved_internal_headers: bool,
|
||||
limits: StoreLimits,
|
||||
buckets: HashMap<String, BucketState>,
|
||||
uploads: HashMap<String, MultipartState>,
|
||||
@@ -738,6 +772,7 @@ impl FakeS3Target {
|
||||
let connection_limit = Arc::new(Semaphore::new(MAX_CONNECTIONS));
|
||||
let (active_connections_tx, active_connections) = watch::channel(0usize);
|
||||
let task_connection_limit = Arc::clone(&connection_limit);
|
||||
let task_control = Arc::clone(&control);
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
let mut connections = tokio::task::JoinSet::new();
|
||||
@@ -751,6 +786,7 @@ impl FakeS3Target {
|
||||
};
|
||||
active_connections_tx.send_modify(|count| *count += 1);
|
||||
let service = service.clone();
|
||||
let control = Arc::clone(&task_control);
|
||||
let active_connections = active_connections_tx.clone();
|
||||
let mut connection_gate = connection_gate.clone();
|
||||
connections.spawn(async move {
|
||||
@@ -764,7 +800,7 @@ impl FakeS3Target {
|
||||
{
|
||||
return;
|
||||
}
|
||||
let handler = service_fn(move |request| handle_request(request, service.clone()));
|
||||
let handler = service_fn(move |request| handle_request(request, service.clone(), Arc::clone(&control)));
|
||||
let mut connection = http1::Builder::new();
|
||||
connection
|
||||
.keep_alive(false)
|
||||
@@ -975,6 +1011,43 @@ impl FakeS3Target {
|
||||
lock(&self.backend.store).reject_unknown_version_deletes = enabled;
|
||||
}
|
||||
|
||||
/// Refuse every PutObject / UploadPart that carries an `x-minio-internal-*`
|
||||
/// request header, the way MinIO and Storj do.
|
||||
pub fn reject_reserved_internal_headers(&self, enabled: bool) {
|
||||
lock(&self.backend.store).reject_reserved_internal_headers = enabled;
|
||||
}
|
||||
|
||||
/// Keys of every stored, non-delete object in the bucket, sorted.
|
||||
pub fn stored_keys(&self, bucket: &str) -> Vec<String> {
|
||||
let state = lock(&self.backend.store);
|
||||
let mut keys: Vec<String> = state
|
||||
.buckets
|
||||
.get(bucket)
|
||||
.map(|bucket| {
|
||||
bucket
|
||||
.objects
|
||||
.iter()
|
||||
.filter(|(_, versions)| versions.last().is_some_and(|version| !version.delete_marker))
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
keys.sort();
|
||||
keys
|
||||
}
|
||||
|
||||
/// Body and user metadata (names as stored, without the `x-amz-meta-` prefix)
|
||||
/// of the latest non-delete version of an object.
|
||||
pub fn stored_object(&self, bucket: &str, key: &str) -> Option<(Bytes, HashMap<String, String>)> {
|
||||
lock(&self.backend.store)
|
||||
.buckets
|
||||
.get(bucket)
|
||||
.and_then(|bucket| bucket.objects.get(key))
|
||||
.and_then(|versions| versions.last())
|
||||
.filter(|version| !version.delete_marker)
|
||||
.map(|version| (version.body.clone(), version.metadata.clone().unwrap_or_default()))
|
||||
}
|
||||
|
||||
pub fn require_checksum_for_object_lock(&self, enabled: bool) {
|
||||
lock(&self.backend.store).require_checksum_for_object_lock = enabled;
|
||||
}
|
||||
@@ -1161,7 +1234,10 @@ impl S3Access for FaultAccess {
|
||||
let operation = operation_from_s3_name(context.s3_op().name());
|
||||
let journaled = JournaledHeaders::from_headers(context.headers());
|
||||
let aws_chunked = journaled.transport.aws_chunked;
|
||||
let fault = record_request(&self.control, operation, context.method().clone(), parsed, journaled);
|
||||
let (sequence, fault) = record_request(&self.control, operation, context.method().clone(), parsed, journaled);
|
||||
if let Some(slot) = context.extensions_mut().get::<RequestSequence>() {
|
||||
let _ = slot.0.set(sequence);
|
||||
}
|
||||
if let Some(status) = fault.as_ref().and_then(|fault| scripted_status(&fault.action)) {
|
||||
return Err(scripted_status_error(status));
|
||||
}
|
||||
@@ -1174,6 +1250,15 @@ impl S3Access for FaultAccess {
|
||||
"this target does not decode aws-chunked request bodies; send a plain signed payload with an exact Content-Length"
|
||||
));
|
||||
}
|
||||
if matches!(operation, Operation::PutObject | Operation::UploadPart)
|
||||
&& lock(&self.store).reject_reserved_internal_headers
|
||||
&& context
|
||||
.headers()
|
||||
.keys()
|
||||
.any(|name| name.as_str().starts_with("x-minio-internal-"))
|
||||
{
|
||||
return Err(s3s::s3_error!(InvalidArgument, "Your metadata headers are not supported."));
|
||||
}
|
||||
let prebody_permit = if operation == Operation::CompleteMultipartUpload {
|
||||
Some(
|
||||
timeout(
|
||||
@@ -1228,7 +1313,7 @@ fn record_request(
|
||||
method: Method,
|
||||
parsed: ParsedRequest,
|
||||
headers: JournaledHeaders,
|
||||
) -> Option<RequestFault> {
|
||||
) -> (u64, Option<RequestFault>) {
|
||||
let mut state = lock(control);
|
||||
let action = parsed
|
||||
.key
|
||||
@@ -1251,6 +1336,12 @@ fn record_request(
|
||||
upload_id: parsed.upload_id.map(bounded_journal_value),
|
||||
part_number: parsed.part_number,
|
||||
content_length: headers.content_length,
|
||||
source_version_id: headers.source_version_id,
|
||||
source_mtime: headers.source_mtime,
|
||||
source_etag: headers.source_etag,
|
||||
source_replication_request: headers.source_replication_request,
|
||||
journaled_at_unix_millis: unix_millis(),
|
||||
prepared_response: None,
|
||||
consumed_bytes: None,
|
||||
replication_timestamps: headers.replication_timestamps,
|
||||
proxy_headers: headers.proxy_headers,
|
||||
@@ -1261,7 +1352,14 @@ fn record_request(
|
||||
continuation_token: parsed.continuation_token.map(bounded_journal_value),
|
||||
fault: action.clone(),
|
||||
});
|
||||
action.map(|action| RequestFault { sequence, action })
|
||||
(sequence, action.map(|action| RequestFault { sequence, action }))
|
||||
}
|
||||
|
||||
fn unix_millis() -> Option<u64> {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|elapsed| u64::try_from(elapsed.as_millis()).ok())
|
||||
}
|
||||
|
||||
fn bounded_journal_value(mut value: String) -> String {
|
||||
@@ -1269,14 +1367,31 @@ fn bounded_journal_value(mut value: String) -> String {
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
value.truncate(end);
|
||||
value
|
||||
if value.capacity() > MAX_RETAINED_IDENTIFIER_BYTES {
|
||||
value[..end].to_owned()
|
||||
} else {
|
||||
value.truncate(end);
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(request: Request<Incoming>, service: S3Service) -> Result<Response<Body>, BoxError> {
|
||||
let mut response = timeout(MAX_REQUEST_DURATION, call_s3(service, request.map(Body::from)))
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "fake target request exceeded 65 seconds"))??;
|
||||
async fn handle_request(
|
||||
mut request: Request<Incoming>,
|
||||
service: S3Service,
|
||||
control: Arc<Mutex<ControlState>>,
|
||||
) -> Result<Response<Body>, BoxError> {
|
||||
let started = Instant::now();
|
||||
let sequence = RequestSequence::default();
|
||||
request.extensions_mut().insert(sequence.clone());
|
||||
let mut response = timeout(MAX_REQUEST_DURATION, async {
|
||||
let result = call_s3(service, request.map(Body::from)).await;
|
||||
if let (Ok(response), Some(sequence)) = (&result, sequence.0.get()) {
|
||||
update_prepared_response(&control, *sequence, response, started.elapsed());
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "fake target request exceeded 65 seconds"))??;
|
||||
if response.headers_mut().remove(DISCONNECT_HEADER).is_some() {
|
||||
return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "fake target scripted disconnect").into());
|
||||
}
|
||||
@@ -1531,6 +1646,25 @@ fn update_consumed(control: &Mutex<ControlState>, sequence: u64, consumed: usize
|
||||
}
|
||||
}
|
||||
|
||||
fn update_prepared_response(control: &Mutex<ControlState>, sequence: u64, response: &Response<Body>, elapsed: Duration) {
|
||||
let prepared = PreparedResponse {
|
||||
status: response.status().as_u16(),
|
||||
version_id: header_value(response.headers(), &["x-amz-version-id"]).map(bounded_journal_value),
|
||||
etag: header_value(response.headers(), &[ETAG.as_str()]).map(bounded_journal_value),
|
||||
last_modified: header_value(response.headers(), &[LAST_MODIFIED.as_str()]).map(bounded_journal_value),
|
||||
content_length: response
|
||||
.headers()
|
||||
.get(CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse().ok()),
|
||||
prepared_at_unix_millis: unix_millis(),
|
||||
elapsed,
|
||||
};
|
||||
if let Some(record) = lock(control).requests.iter_mut().find(|record| record.sequence == sequence) {
|
||||
record.prepared_response = Some(prepared);
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_non_body_fault(fault: Option<&RequestFault>, control: &Mutex<ControlState>) -> S3Result<()> {
|
||||
match fault.map(|fault| &fault.action) {
|
||||
Some(action @ (FaultAction::Status(_) | FaultAction::ResponseStatus(_))) => {
|
||||
@@ -3230,10 +3364,38 @@ mod tests {
|
||||
let retry = client.clone();
|
||||
requests.spawn(async move { get_bytes(&retry, bucket, "held", None).await });
|
||||
timeout(Duration::from_secs(2), gate.entered.wait_for(|count| *count == 2)).await??;
|
||||
let held_retry = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|record| record.operation == Operation::GetObject && record.key.as_deref() == Some("held"))
|
||||
.expect("the held retry must be journaled");
|
||||
assert_eq!(held_retry.prepared_response, None);
|
||||
assert_eq!(
|
||||
timeout(Duration::from_secs(2), get_bytes(&client, bucket, "unrelated", None)).await??,
|
||||
Bytes::from_static(b"payload")
|
||||
);
|
||||
let unrelated = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.operation == Operation::GetObject && record.key.as_deref() == Some("unrelated"))
|
||||
.expect("the unrelated GET must be journaled");
|
||||
let unrelated_response = unrelated.prepared_response.expect("the unrelated GET prepared a response");
|
||||
assert_eq!(unrelated_response.status, 200);
|
||||
assert_eq!(
|
||||
unrelated_response.version_id.as_deref(),
|
||||
Some(target.stored_versions(bucket, "unrelated")[0].0.as_str())
|
||||
);
|
||||
assert!(held_retry.sequence < unrelated.sequence);
|
||||
assert_eq!(
|
||||
target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.sequence == held_retry.sequence)
|
||||
.expect("the held retry remains journaled")
|
||||
.prepared_response,
|
||||
None
|
||||
);
|
||||
assert!(requests.try_join_next().is_none(), "the retry must remain behind the gate");
|
||||
drop(gate);
|
||||
assert_eq!(
|
||||
@@ -3242,6 +3404,18 @@ mod tests {
|
||||
.expect("retried GET task")??,
|
||||
Bytes::from_static(b"payload")
|
||||
);
|
||||
let held_response = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|record| record.sequence == held_retry.sequence)
|
||||
.and_then(|record| record.prepared_response)
|
||||
.expect("the released retry prepared its own response");
|
||||
assert_eq!(held_response.status, 200);
|
||||
assert_eq!(
|
||||
held_response.version_id.as_deref(),
|
||||
Some(target.stored_versions(bucket, "held")[0].0.as_str())
|
||||
);
|
||||
assert_ne!(held_response.version_id, unrelated_response.version_id);
|
||||
assert_eq!(get_bytes(&client, bucket, "held", None).await?, Bytes::from_static(b"payload"));
|
||||
assert_eq!(target.count_requests(Operation::GetObject, "held"), 3);
|
||||
Ok(())
|
||||
@@ -3586,6 +3760,23 @@ mod tests {
|
||||
.await?;
|
||||
assert_eq!(first_head.content_length(), Some(5));
|
||||
assert_eq!(first_head.version_id(), first.version_id.as_deref());
|
||||
let journaled_head = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|record| record.operation == Operation::HeadObject)
|
||||
.expect("the version HEAD must be journaled");
|
||||
assert_eq!(journaled_head.version_id, first.version_id);
|
||||
assert!(journaled_head.journaled_at_unix_millis.is_some());
|
||||
let prepared_head = journaled_head
|
||||
.prepared_response
|
||||
.expect("the version HEAD prepared a response");
|
||||
assert_eq!(prepared_head.status, 200);
|
||||
assert_eq!(prepared_head.version_id.as_deref(), first_head.version_id());
|
||||
assert_eq!(prepared_head.etag.as_deref(), first_head.e_tag());
|
||||
assert_eq!(prepared_head.content_length, Some(5));
|
||||
assert!(prepared_head.last_modified.is_some());
|
||||
assert!(prepared_head.prepared_at_unix_millis.is_some());
|
||||
assert_eq!(
|
||||
get_bytes(&client, "target-bucket", "nested/key", None).await?,
|
||||
Bytes::from_static(b"second")
|
||||
@@ -3611,6 +3802,20 @@ mod tests {
|
||||
.await
|
||||
.expect_err("deleted version must not remain readable");
|
||||
assert_sdk_error!(deleted, 404, "NoSuchVersion");
|
||||
let deleted_record = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|record| record.operation == Operation::GetObject)
|
||||
.expect("the deleted version GET must be journaled");
|
||||
assert_eq!(deleted_record.version_id, first.version_id);
|
||||
assert_eq!(
|
||||
deleted_record
|
||||
.prepared_response
|
||||
.expect("the error response was prepared")
|
||||
.status,
|
||||
404
|
||||
);
|
||||
let marker = client
|
||||
.delete_object()
|
||||
.bucket("target-bucket")
|
||||
@@ -4042,7 +4247,21 @@ mod tests {
|
||||
.await
|
||||
.expect_err("wrong part ETag must fail without consuming the upload");
|
||||
assert_sdk_error!(invalid_complete, 400, "InvalidPart");
|
||||
client
|
||||
let rejected_complete = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|record| record.operation == Operation::CompleteMultipartUpload)
|
||||
.expect("the rejected completion must be journaled");
|
||||
assert_eq!(rejected_complete.upload_id.as_deref(), Some(upload_id.as_str()));
|
||||
assert_eq!(
|
||||
rejected_complete
|
||||
.prepared_response
|
||||
.expect("the rejection was prepared")
|
||||
.status,
|
||||
400
|
||||
);
|
||||
let successful_complete = client
|
||||
.complete_multipart_upload()
|
||||
.bucket("target-bucket")
|
||||
.key("multipart")
|
||||
@@ -4050,6 +4269,18 @@ mod tests {
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(parts)).build())
|
||||
.send()
|
||||
.await?;
|
||||
let completed_record = target
|
||||
.requests()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|record| record.operation == Operation::CompleteMultipartUpload)
|
||||
.expect("the successful completion must be journaled");
|
||||
assert_eq!(completed_record.upload_id.as_deref(), Some(upload_id.as_str()));
|
||||
let prepared_complete = completed_record
|
||||
.prepared_response
|
||||
.expect("the completion response was prepared");
|
||||
assert_eq!(prepared_complete.status, 200);
|
||||
assert_eq!(prepared_complete.version_id.as_deref(), successful_complete.version_id());
|
||||
let completed = get_bytes(&client, "target-bucket", "multipart", None).await?;
|
||||
assert_eq!(completed.len(), MIN_MULTIPART_PART_BYTES + 5);
|
||||
assert_eq!(&completed[..MIN_MULTIPART_PART_BYTES], first_body.as_ref());
|
||||
@@ -4430,6 +4661,32 @@ mod tests {
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(completed.version_id(), Some(completed_version.as_str()));
|
||||
let receipt_records = target.requests();
|
||||
let receipt_create = receipt_records
|
||||
.iter()
|
||||
.find(|record| {
|
||||
record.operation == Operation::CreateMultipartUpload && record.key.as_deref() == Some("multipart-receipt-time")
|
||||
})
|
||||
.expect("the source version must be retained on MPU creation");
|
||||
assert_eq!(receipt_create.source_version_id.as_deref(), Some(completed_version.as_str()));
|
||||
let receipt_complete = receipt_records
|
||||
.iter()
|
||||
.find(|record| {
|
||||
record.operation == Operation::CompleteMultipartUpload
|
||||
&& record.upload_id.as_deref() == Some(receipt_upload.as_str())
|
||||
})
|
||||
.expect("the source mtime must be retained on completion");
|
||||
assert_eq!(receipt_complete.source_mtime.as_deref(), Some("1970-01-01T00:00:00Z"));
|
||||
assert!(receipt_complete.source_replication_request);
|
||||
assert_eq!(
|
||||
receipt_complete
|
||||
.prepared_response
|
||||
.as_ref()
|
||||
.expect("the receipt-time response was prepared")
|
||||
.version_id
|
||||
.as_deref(),
|
||||
completed.version_id()
|
||||
);
|
||||
assert_eq!(
|
||||
get_bytes(&client, "target-bucket", "multipart-receipt-time", None).await?,
|
||||
Bytes::from_static(b"completed"),
|
||||
@@ -4648,6 +4905,23 @@ mod tests {
|
||||
.expect_err("disconnected request must not store an object");
|
||||
assert_sdk_error!(disconnected, 404, "NotFound");
|
||||
|
||||
target.inject_for_key(Operation::PutObject, "lost-response", FaultAction::DisconnectAfterResponse, 1);
|
||||
client
|
||||
.put_object()
|
||||
.bucket("target-bucket")
|
||||
.key("lost-response")
|
||||
.body(ByteStream::from_static(b"stored"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a prepared response does not prove client delivery");
|
||||
let lost_response_head = client
|
||||
.head_object()
|
||||
.bucket("target-bucket")
|
||||
.key("lost-response")
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(lost_response_head.content_length(), Some(6));
|
||||
|
||||
assert_eq!(
|
||||
parse_request(&Method::PUT, &"/target-bucket/key?uploadId=upload&partNumber=bad".parse::<Uri>()?).operation,
|
||||
Operation::Unknown
|
||||
@@ -4661,6 +4935,28 @@ mod tests {
|
||||
.count(),
|
||||
4
|
||||
);
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.fault == Some(FaultAction::Status(StatusCode::SERVICE_UNAVAILABLE)))
|
||||
.all(|request| {
|
||||
request
|
||||
.prepared_response
|
||||
.as_ref()
|
||||
.is_some_and(|response| response.status == 503)
|
||||
})
|
||||
);
|
||||
let lost_response = requests
|
||||
.iter()
|
||||
.find(|request| request.operation == Operation::PutObject && request.key.as_deref() == Some("lost-response"))
|
||||
.expect("the disconnected response must be journaled");
|
||||
assert_eq!(lost_response.fault, Some(FaultAction::DisconnectAfterResponse));
|
||||
let prepared_lost_response = lost_response
|
||||
.prepared_response
|
||||
.as_ref()
|
||||
.expect("the target prepared the successful response");
|
||||
assert_eq!(prepared_lost_response.status, 200);
|
||||
assert_eq!(prepared_lost_response.version_id.as_deref(), lost_response_head.version_id());
|
||||
assert!(
|
||||
requests
|
||||
.iter()
|
||||
@@ -4851,8 +5147,44 @@ mod tests {
|
||||
assert_eq!(records.len(), MAX_REQUEST_RECORDS);
|
||||
assert_eq!(records.front().map(|record| record.sequence), Some(2));
|
||||
assert_eq!(records.back().map(|record| record.sequence), Some((MAX_REQUEST_RECORDS + 1) as u64));
|
||||
let mut oversized_allocation = String::with_capacity(MAX_RETAINED_IDENTIFIER_BYTES * 16);
|
||||
oversized_allocation.push_str("short");
|
||||
let short_value = bounded_journal_value(oversized_allocation);
|
||||
assert_eq!(short_value, "short");
|
||||
assert!(short_value.capacity() <= MAX_RETAINED_IDENTIFIER_BYTES);
|
||||
let mut response = Response::new(Body::empty());
|
||||
for name in ["x-amz-version-id", "etag", "last-modified"] {
|
||||
response.headers_mut().insert(
|
||||
name,
|
||||
HeaderValue::from_str(&"r".repeat(MAX_RETAINED_IDENTIFIER_BYTES + 1)).expect("header"),
|
||||
);
|
||||
}
|
||||
response.headers_mut().insert(CONTENT_LENGTH, HeaderValue::from_static("17"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("authorization", HeaderValue::from_static("unrecorded-response-authorization"));
|
||||
response.headers_mut().insert(
|
||||
"x-amz-server-side-encryption-customer-key",
|
||||
HeaderValue::from_static("unrecorded-response-customer-key"),
|
||||
);
|
||||
update_prepared_response(&control, 1, &response, Duration::ZERO);
|
||||
update_prepared_response(&control, u64::MAX, &response, Duration::ZERO);
|
||||
assert_eq!(lock(&control).requests, records, "misses and evicted records must not be resurrected");
|
||||
let bounded_control = Mutex::new(ControlState::default());
|
||||
let utf8_boundary = format!("{}é", "a".repeat(MAX_RETAINED_IDENTIFIER_BYTES - 1));
|
||||
let mut source_headers = HeaderMap::new();
|
||||
for name in ["x-rustfs-source-version-id", "x-minio-source-mtime", "x-rustfs-source-etag"] {
|
||||
source_headers.insert(
|
||||
name,
|
||||
HeaderValue::from_str(&"s".repeat(MAX_RETAINED_IDENTIFIER_BYTES + 1)).expect("header"),
|
||||
);
|
||||
}
|
||||
source_headers.insert("x-minio-source-replication-request", HeaderValue::from_static("true"));
|
||||
source_headers.insert("authorization", HeaderValue::from_static("unrecorded-request-authorization"));
|
||||
source_headers.insert(
|
||||
"x-amz-server-side-encryption-customer-key",
|
||||
HeaderValue::from_static("unrecorded-request-customer-key"),
|
||||
);
|
||||
record_request(
|
||||
&bounded_control,
|
||||
Operation::GetObject,
|
||||
@@ -4865,8 +5197,9 @@ mod tests {
|
||||
upload_id: Some("u".repeat(MAX_RETAINED_IDENTIFIER_BYTES + 1)),
|
||||
..Default::default()
|
||||
},
|
||||
JournaledHeaders::default(),
|
||||
JournaledHeaders::from_headers(&source_headers),
|
||||
);
|
||||
update_prepared_response(&bounded_control, 1, &response, Duration::from_millis(7));
|
||||
{
|
||||
let bounded_records = lock(&bounded_control);
|
||||
let bounded = &bounded_records.requests[0];
|
||||
@@ -4874,7 +5207,39 @@ mod tests {
|
||||
assert_eq!(bounded.key.as_ref().map(String::len), Some(MAX_RETAINED_IDENTIFIER_BYTES - 1));
|
||||
assert_eq!(bounded.version_id.as_ref().map(String::len), Some(MAX_RETAINED_IDENTIFIER_BYTES));
|
||||
assert_eq!(bounded.upload_id.as_ref().map(String::len), Some(MAX_RETAINED_IDENTIFIER_BYTES));
|
||||
assert_eq!(bounded.source_version_id.as_ref().map(String::len), Some(MAX_RETAINED_IDENTIFIER_BYTES));
|
||||
assert_eq!(bounded.source_mtime.as_ref().map(String::len), Some(MAX_RETAINED_IDENTIFIER_BYTES));
|
||||
assert_eq!(bounded.source_etag.as_ref().map(String::len), Some(MAX_RETAINED_IDENTIFIER_BYTES));
|
||||
for value in [&bounded.source_version_id, &bounded.source_mtime, &bounded.source_etag]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(value.capacity() <= MAX_RETAINED_IDENTIFIER_BYTES);
|
||||
}
|
||||
assert!(bounded.source_replication_request);
|
||||
let prepared = bounded
|
||||
.prepared_response
|
||||
.as_ref()
|
||||
.expect("the retained record receives its response");
|
||||
assert_eq!(prepared.version_id.as_ref().map(String::len), Some(MAX_RETAINED_IDENTIFIER_BYTES));
|
||||
assert_eq!(prepared.etag.as_ref().map(String::len), Some(MAX_RETAINED_IDENTIFIER_BYTES));
|
||||
assert_eq!(prepared.last_modified.as_ref().map(String::len), Some(MAX_RETAINED_IDENTIFIER_BYTES));
|
||||
for value in [&prepared.version_id, &prepared.etag, &prepared.last_modified]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
assert!(value.capacity() <= MAX_RETAINED_IDENTIFIER_BYTES);
|
||||
}
|
||||
assert_eq!(prepared.content_length, Some(17));
|
||||
assert_eq!(prepared.elapsed, Duration::from_millis(7));
|
||||
assert!(
|
||||
!format!("{bounded:?}").contains("unrecorded-"),
|
||||
"authorization and customer keys must not enter the journal"
|
||||
);
|
||||
}
|
||||
lock(&bounded_control).requests.clear();
|
||||
update_prepared_response(&bounded_control, 1, &response, Duration::ZERO);
|
||||
assert!(lock(&bounded_control).requests.is_empty(), "a drained record must not be resurrected");
|
||||
let target = FakeS3Target::start().await.expect("target");
|
||||
target.create_bucket("b".repeat(63));
|
||||
let empty_bucket = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
|
||||
use std::io;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
@@ -108,19 +110,33 @@ pub struct FaultProxy {
|
||||
mode_tx: watch::Sender<FaultMode>,
|
||||
shutdown_tx: watch::Sender<bool>,
|
||||
accept_task: JoinHandle<()>,
|
||||
traffic: Arc<ProxyTraffic>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ProxyTraffic {
|
||||
forwarded_bytes: AtomicUsize,
|
||||
dropped_bytes: AtomicUsize,
|
||||
}
|
||||
|
||||
impl FaultProxy {
|
||||
/// Bind a listener on `127.0.0.1:0` and start forwarding accepted
|
||||
/// connections to `target`. Starts in [`FaultMode::Pass`].
|
||||
pub async fn start(target: SocketAddr) -> io::Result<Self> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?;
|
||||
Self::start_on(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)), target).await
|
||||
}
|
||||
|
||||
/// Bind a specific address so advertised volume endpoints can retain the
|
||||
/// server's port and authenticated RPC identity on another loopback IP.
|
||||
pub async fn start_on(listen: SocketAddr, target: SocketAddr) -> io::Result<Self> {
|
||||
let listener = TcpListener::bind(listen).await?;
|
||||
let listen_addr = listener.local_addr()?;
|
||||
|
||||
let (mode_tx, mode_rx) = watch::channel(FaultMode::Pass);
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||
|
||||
let accept_task = tokio::spawn(accept_loop(listener, target, mode_rx, shutdown_rx));
|
||||
let traffic = Arc::new(ProxyTraffic::default());
|
||||
let accept_task = tokio::spawn(accept_loop(listener, target, mode_rx, shutdown_rx, traffic.clone()));
|
||||
|
||||
Ok(Self {
|
||||
listen_addr,
|
||||
@@ -128,9 +144,20 @@ impl FaultProxy {
|
||||
mode_tx,
|
||||
shutdown_tx,
|
||||
accept_task,
|
||||
traffic,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes actually forwarded in either direction, excluding dropped traffic.
|
||||
pub fn forwarded_bytes(&self) -> usize {
|
||||
self.traffic.forwarded_bytes.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Bytes actually discarded by a blackhole or one-way partition.
|
||||
pub fn dropped_bytes(&self) -> usize {
|
||||
self.traffic.dropped_bytes.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Local address the proxy is listening on. Point clients here instead of
|
||||
/// the real target to route their traffic through the proxy.
|
||||
pub fn local_addr(&self) -> SocketAddr {
|
||||
@@ -171,6 +198,7 @@ async fn accept_loop(
|
||||
target: SocketAddr,
|
||||
mode_rx: watch::Receiver<FaultMode>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
traffic: Arc<ProxyTraffic>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
@@ -188,6 +216,7 @@ async fn accept_loop(
|
||||
target,
|
||||
mode_rx.clone(),
|
||||
shutdown_rx.clone(),
|
||||
traffic.clone(),
|
||||
));
|
||||
}
|
||||
// A transient accept error should not tear the proxy down.
|
||||
@@ -205,6 +234,7 @@ async fn handle_connection(
|
||||
target: SocketAddr,
|
||||
mode_rx: watch::Receiver<FaultMode>,
|
||||
shutdown_rx: watch::Receiver<bool>,
|
||||
traffic: Arc<ProxyTraffic>,
|
||||
) {
|
||||
let server = match TcpStream::connect(target).await {
|
||||
Ok(s) => s,
|
||||
@@ -221,8 +251,9 @@ async fn handle_connection(
|
||||
server_wr,
|
||||
mode_rx.clone(),
|
||||
shutdown_rx.clone(),
|
||||
traffic.clone(),
|
||||
));
|
||||
let down = tokio::spawn(pump(Direction::ServerToClient, server_rd, client_wr, mode_rx, shutdown_rx));
|
||||
let down = tokio::spawn(pump(Direction::ServerToClient, server_rd, client_wr, mode_rx, shutdown_rx, traffic));
|
||||
|
||||
let _ = up.await;
|
||||
let _ = down.await;
|
||||
@@ -237,6 +268,7 @@ async fn pump<R, W>(
|
||||
mut to: W,
|
||||
mut mode_rx: watch::Receiver<FaultMode>,
|
||||
mut shutdown_rx: watch::Receiver<bool>,
|
||||
traffic: Arc<ProxyTraffic>,
|
||||
) where
|
||||
R: AsyncReadExt + Unpin,
|
||||
W: AsyncWriteExt + Unpin,
|
||||
@@ -257,15 +289,22 @@ async fn pump<R, W>(
|
||||
let mode = *mode_rx.borrow_and_update();
|
||||
match mode {
|
||||
// Whole connection is a black hole: drain and drop.
|
||||
FaultMode::Blackhole => continue,
|
||||
FaultMode::Blackhole => {
|
||||
traffic.dropped_bytes.fetch_add(n, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
// This direction is partitioned off: drop its bytes; the peer keeps
|
||||
// reading nothing while the other direction may still flow.
|
||||
FaultMode::Partition(blocked) if blocked == dir => continue,
|
||||
FaultMode::Partition(blocked) if blocked == dir => {
|
||||
traffic.dropped_bytes.fetch_add(n, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
FaultMode::Latency(delay) => {
|
||||
tokio::time::sleep(delay).await;
|
||||
if to.write_all(&buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
traffic.forwarded_bytes.fetch_add(n, Ordering::Relaxed);
|
||||
if to.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -274,6 +313,7 @@ async fn pump<R, W>(
|
||||
if to.write_all(&buf[..n]).await.is_err() {
|
||||
break;
|
||||
}
|
||||
traffic.forwarded_bytes.fetch_add(n, Ordering::Relaxed);
|
||||
if to.flush().await.is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -285,7 +325,6 @@ async fn pump<R, W>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -375,6 +414,8 @@ mod tests {
|
||||
|
||||
assert_eq!(got, payload, "pass mode must forward bytes unchanged");
|
||||
assert_eq!(echo.received().await, payload, "server must have received the bytes");
|
||||
assert_eq!(proxy.forwarded_bytes(), payload.len() * 2);
|
||||
assert_eq!(proxy.dropped_bytes(), 0);
|
||||
|
||||
proxy.shutdown().await;
|
||||
}
|
||||
@@ -414,6 +455,8 @@ mod tests {
|
||||
|
||||
assert!(got.is_empty(), "blackhole mode must not return any bytes, got {got:?}");
|
||||
assert!(echo.received().await.is_empty(), "blackhole must not forward to the server");
|
||||
assert_eq!(proxy.forwarded_bytes(), 0);
|
||||
assert_eq!(proxy.dropped_bytes(), b"into-the-void".len());
|
||||
|
||||
// Proxy is still alive and steerable after a blackholed connection.
|
||||
proxy.set_mode(FaultMode::Pass);
|
||||
|
||||
@@ -1450,8 +1450,11 @@ mod tests {
|
||||
cluster.set_env("RUSTFS_HEAL_PAGE_PARALLEL_ENABLE", "false");
|
||||
}
|
||||
// Keep every node's Heal runtime enabled for normal disk registration.
|
||||
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG")
|
||||
.unwrap_or_else(|_| "rustfs::heal::task=info,rustfs=error".to_string());
|
||||
// Capture durable handoff and marker cleanup failures during shutdown.
|
||||
let server_rust_log = std::env::var("RUSTFS_HEAL_CHAOS_SERVER_RUST_LOG").unwrap_or_else(|_| {
|
||||
"rustfs::heal::task=info,rustfs::heal::manager=info,rustfs::main::handle_shutdown=info,rustfs_heal::heal=warn,rustfs=error"
|
||||
.to_string()
|
||||
});
|
||||
cluster.set_env("RUST_LOG", server_rust_log);
|
||||
let log_dir = std::env::var("RUSTFS_HEAL_CHAOS_LOG_DIR").unwrap_or_else(|_| {
|
||||
// Keep explicit directories compatible and isolate parallel suite captures.
|
||||
|
||||
@@ -886,6 +886,8 @@ fn configure_reader_metric_cluster(cluster: &mut RustFSTestClusterEnvironment, c
|
||||
cluster.set_env("RUSTFS_OBS_METRICS_EXPORT_ENABLED", "true");
|
||||
cluster.set_env("RUSTFS_OBS_TRACES_EXPORT_ENABLED", "false");
|
||||
cluster.set_env("RUSTFS_OBS_LOGS_EXPORT_ENABLED", "false");
|
||||
// Keep startup diagnostics available when OTLP log export is disabled.
|
||||
cluster.set_env("RUSTFS_OBS_LOG_STDOUT_ENABLED", "true");
|
||||
cluster.set_env("RUSTFS_OBS_METER_INTERVAL", "1");
|
||||
cluster.set_env("RUSTFS_OBS_USE_STDOUT", "false");
|
||||
cluster.set_env("RUSTFS_GET_CODEC_STREAMING_ENABLE", "false");
|
||||
|
||||
@@ -31,6 +31,12 @@ pub mod chaos;
|
||||
#[cfg(test)]
|
||||
pub mod fake_s3_target;
|
||||
|
||||
// Dual-stack differential (rustfs/backlog#2740): one binary, two processes on
|
||||
// different RUSTFS_S3_STACK values, the same SDK script for every S3 operation,
|
||||
// compared on status, error code, allowlisted headers and typed body fields.
|
||||
#[cfg(test)]
|
||||
mod dual_stack_differential;
|
||||
|
||||
// On-demand migration (backlog#2147): shared two-server environment, admin
|
||||
// wrappers, and the harness self-test (backlog#2151). Behavior scenarios are
|
||||
// added by later ODM tasks.
|
||||
|
||||
@@ -30,6 +30,7 @@ mod tests {
|
||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use futures::{StreamExt, stream};
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
@@ -561,17 +562,23 @@ mod tests {
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
let object_count = 1002;
|
||||
for i in 0..object_count {
|
||||
let key = format!("object{:04}.txt", i);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from_static(b"test content"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put object");
|
||||
}
|
||||
// Finish every durable PUT before listing, with the same bounded fanout as the delimiter fixtures.
|
||||
stream::iter(0..object_count)
|
||||
.for_each_concurrent(16, |i| {
|
||||
let client = &client;
|
||||
async move {
|
||||
let key = format!("object{i:04}.txt");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from_static(b"test content"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
eprintln!("Seeded {object_count} objects in {bucket}; starting ListObjectsV2 pagination");
|
||||
let deadline = Instant::now() + PAGINATION_TIMEOUT;
|
||||
@@ -810,23 +817,25 @@ mod tests {
|
||||
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
// Create 1000 objects: 100 directories with 10 files each
|
||||
let mut all_keys = Vec::new();
|
||||
let dirs: Vec<String> = (0..100).map(|i| format!("dir-{:03}/", i)).collect();
|
||||
for dir in &dirs {
|
||||
for i in 0..10 {
|
||||
let key = format!("{dir}file{:02}.txt", i);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put object");
|
||||
all_keys.push(key);
|
||||
}
|
||||
}
|
||||
// Keep every fixture key while overlapping durable PUTs within a bounded fanout.
|
||||
let all_keys: Vec<String> = (0..100)
|
||||
.flat_map(|dir| (0..10).map(move |file| format!("dir-{dir:03}/file{file:02}.txt")))
|
||||
.collect();
|
||||
stream::iter(&all_keys)
|
||||
.for_each_concurrent(16, |key| {
|
||||
let client = &client;
|
||||
async move {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
eprintln!("Seeded {} objects in {bucket}; starting ListObjectsV2 pagination", all_keys.len());
|
||||
let deadline = Instant::now() + PAGINATION_TIMEOUT;
|
||||
@@ -1040,22 +1049,35 @@ mod tests {
|
||||
|
||||
create_bucket(&client, bucket).await.expect("Failed to create bucket");
|
||||
|
||||
// 12 dirs × 100 files = 1200 raw keys
|
||||
// Keep all 1200 raw keys while bounding upload concurrency so fixture setup
|
||||
// leaves the test's timeout budget available for the listing regression.
|
||||
let dir_count = 12;
|
||||
let files_per_dir = 100;
|
||||
for d in 0..dir_count {
|
||||
for f in 0..files_per_dir {
|
||||
let key = format!("dir-{:02}/file{:03}.txt", d, f);
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await
|
||||
.expect("Failed to put object");
|
||||
}
|
||||
}
|
||||
let fixture_started = std::time::Instant::now();
|
||||
stream::iter((0..dir_count).flat_map(|d| (0..files_per_dir).map(move |f| (d, f))))
|
||||
.for_each_concurrent(16, |(d, f)| {
|
||||
let client = &client;
|
||||
async move {
|
||||
let key = format!("dir-{d:02}/file{f:03}.txt");
|
||||
client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from_static(b"x"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("Failed to put fixture object {key}: {err}"));
|
||||
}
|
||||
})
|
||||
.await;
|
||||
info!(
|
||||
event = "pagination_fixture_ready",
|
||||
component = "e2e_test",
|
||||
subsystem = "list_objects_v2",
|
||||
object_count = dir_count * files_per_dir,
|
||||
elapsed_ms = fixture_started.elapsed().as_millis(),
|
||||
"Pagination fixture ready"
|
||||
);
|
||||
|
||||
eprintln!(
|
||||
"Seeded {} objects in {bucket}; starting ListObjectsV2 pagination",
|
||||
@@ -1077,7 +1099,20 @@ mod tests {
|
||||
dir_count,
|
||||
output.common_prefixes().len()
|
||||
);
|
||||
let prefixes = output
|
||||
.common_prefixes()
|
||||
.iter()
|
||||
.map(|entry| entry.prefix().expect("each CommonPrefix must carry its prefix").to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let expected_prefixes = (0..dir_count).map(|d| format!("dir-{d:02}/")).collect::<Vec<_>>();
|
||||
assert_eq!(prefixes, expected_prefixes);
|
||||
assert!(output.contents().is_empty(), "all fixture objects must collapse into prefixes");
|
||||
assert_eq!(output.key_count(), Some(i32::try_from(dir_count).expect("fixture count fits i32")));
|
||||
assert_eq!(output.max_keys(), Some(1000));
|
||||
assert!(
|
||||
output.next_continuation_token().is_none(),
|
||||
"a complete listing must not return a continuation token"
|
||||
);
|
||||
// 12 visible < 1000 capped MaxKeys → not truncated
|
||||
assert!(
|
||||
!output.is_truncated().unwrap_or(false),
|
||||
|
||||
@@ -65,6 +65,130 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_parts_sdk_paginator_terminates_and_preserves_completion() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(Vec::new()).await?;
|
||||
let client = env.create_s3_client();
|
||||
let bucket = "multipart-pagination";
|
||||
let key = "sparse-parts.bin";
|
||||
env.create_test_bucket(bucket).await?;
|
||||
let upload = client.create_multipart_upload().bucket(bucket).key(key).send().await?;
|
||||
let upload_id = upload.upload_id().ok_or("CreateMultipartUpload returned no upload ID")?;
|
||||
|
||||
let mut empty_pages = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.into_paginator()
|
||||
.stop_on_duplicate_token(false)
|
||||
.send();
|
||||
let empty = tokio::time::timeout(std::time::Duration::from_secs(30), empty_pages.next())
|
||||
.await?
|
||||
.ok_or("empty upload must return one page")??;
|
||||
assert!(empty.parts().is_empty());
|
||||
assert_eq!(empty.is_truncated(), Some(false));
|
||||
assert_eq!(empty.next_part_number_marker(), None);
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), empty_pages.next())
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let mut expected_body = Vec::new();
|
||||
let mut completed_parts = Vec::new();
|
||||
for (part_number, body) in [(1, vec![b'a'; PART_SIZE]), (3, vec![b'b'; PART_SIZE]), (10, b"tail".to_vec())] {
|
||||
expected_body.extend_from_slice(&body);
|
||||
let uploaded = client
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.part_number(part_number)
|
||||
.body(ByteStream::from(body))
|
||||
.send()
|
||||
.await?;
|
||||
completed_parts.push(
|
||||
CompletedPart::builder()
|
||||
.part_number(part_number)
|
||||
.set_e_tag(uploaded.e_tag().map(str::to_owned))
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
for (page_size, expected_pages) in [(1, 3), (2, 2), (3, 1), (1000, 1)] {
|
||||
let mut pages = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.into_paginator()
|
||||
.page_size(page_size)
|
||||
// The server must terminate pagination even without the SDK's duplicate-token safeguard.
|
||||
.stop_on_duplicate_token(false)
|
||||
.send();
|
||||
let mut page_count = 0;
|
||||
let mut listed_parts = Vec::new();
|
||||
while let Some(page) = tokio::time::timeout(std::time::Duration::from_secs(30), pages.next()).await? {
|
||||
assert!(page_count < expected_pages, "paginator must terminate after the final page");
|
||||
let page = page?;
|
||||
page_count += 1;
|
||||
let truncated = page_count < expected_pages;
|
||||
assert_eq!(page.is_truncated(), Some(truncated));
|
||||
let last_part = page.parts().last().and_then(|part| part.part_number());
|
||||
let expected_marker = truncated.then(|| last_part.expect("truncated page must contain parts").to_string());
|
||||
assert_eq!(page.next_part_number_marker(), expected_marker.as_deref());
|
||||
listed_parts.extend(
|
||||
page.parts()
|
||||
.iter()
|
||||
.map(|part| part.part_number().expect("part number must be present")),
|
||||
);
|
||||
}
|
||||
assert_eq!(page_count, expected_pages);
|
||||
assert_eq!(listed_parts, [1, 3, 10]);
|
||||
}
|
||||
|
||||
let mut resumed_pages = client
|
||||
.list_parts()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.part_number_marker("2")
|
||||
.into_paginator()
|
||||
.page_size(1)
|
||||
.stop_on_duplicate_token(false)
|
||||
.send();
|
||||
for (part_number, next_marker) in [(3, Some("3")), (10, None)] {
|
||||
let page = tokio::time::timeout(std::time::Duration::from_secs(30), resumed_pages.next())
|
||||
.await?
|
||||
.ok_or("parts above a missing marker must still be listed")??;
|
||||
assert_eq!(page.parts().len(), 1);
|
||||
assert_eq!(page.parts()[0].part_number(), Some(part_number));
|
||||
assert_eq!(page.is_truncated(), Some(next_marker.is_some()));
|
||||
assert_eq!(page.next_part_number_marker(), next_marker);
|
||||
}
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), resumed_pages.next())
|
||||
.await?
|
||||
.is_none()
|
||||
);
|
||||
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(CompletedMultipartUpload::builder().set_parts(Some(completed_parts)).build())
|
||||
.send()
|
||||
.await?;
|
||||
assert_completed_object(&client, bucket, key, "STANDARD", &expected_body).await?;
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multipart_upload_preserves_standard_and_rrs_across_retry_and_resume()
|
||||
-> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
|
||||
@@ -300,17 +300,28 @@ async fn test_degraded_cluster_read_quorum_follows_erasure_layout() -> TestResul
|
||||
}
|
||||
|
||||
cluster.stop_node(read_quorum - 1)?;
|
||||
let lock_read_quorum = node_count - node_count / 2;
|
||||
// EC:2 on six nodes still has three shared lock votes after losing
|
||||
// its fourth data shard. Preserve the distinct data-quorum error.
|
||||
let expected_code = if read_quorum - 1 < lock_read_quorum {
|
||||
"ServiceUnavailable"
|
||||
} else {
|
||||
"SlowDownRead"
|
||||
};
|
||||
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"
|
||||
),
|
||||
Err(error) => {
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
assert_eq!(
|
||||
error.as_service_error().and_then(|error| error.meta().code()),
|
||||
Some(expected_code),
|
||||
"a quorum loss must not be mistaken for a missing object"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +436,60 @@ async fn test_concurrent_cluster_overwrites_do_not_fail_namespace_lock_quorum()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unavailable_readiness_drive_indices(payload: &serde_json::Value) -> Vec<usize> {
|
||||
let mut indices = payload["details"]["storage"]["unavailableDrives"]
|
||||
.as_array()
|
||||
.expect("node readiness must report unavailable drives")
|
||||
.iter()
|
||||
.map(|drive| {
|
||||
usize::try_from(drive["diskIndex"].as_u64().expect("drive index must be unsigned"))
|
||||
.expect("drive index must fit usize")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
indices.sort_unstable();
|
||||
indices
|
||||
}
|
||||
|
||||
fn assert_node_readiness_contract(payload: &serde_json::Value, survivors: usize) {
|
||||
let read_ready = survivors >= 2;
|
||||
let write_ready = survivors >= 3;
|
||||
let storage = &payload["details"]["storage"];
|
||||
assert_eq!(payload["ready"], read_ready);
|
||||
assert_eq!(storage["ready"], read_ready);
|
||||
assert_eq!(storage["readQuorum"], read_ready);
|
||||
assert_eq!(storage["writeQuorum"], write_ready);
|
||||
assert_eq!(storage["readinessScope"], "read_quorum");
|
||||
assert_eq!(storage["source"], "local_runtime");
|
||||
assert_eq!(storage["status"], if read_ready { "connected" } else { "disconnected" });
|
||||
assert_eq!(payload["details"]["poolMetadata"]["ready"], true);
|
||||
assert_eq!(payload["details"]["iam"]["ready"], true);
|
||||
assert_eq!(payload["details"]["lock"]["ready"], read_ready);
|
||||
assert_eq!(unavailable_readiness_drive_indices(payload), (survivors..4).collect::<Vec<_>>());
|
||||
for drive in storage["unavailableDrives"].as_array().expect("unavailable drives") {
|
||||
assert_eq!(drive["poolIndex"], 0);
|
||||
assert_eq!(drive["setIndex"], 0);
|
||||
let state = drive["runtimeState"].as_str().expect("runtime state must be reported");
|
||||
let host_online = drive["hostOnline"].as_bool().expect("host reachability must be reported");
|
||||
assert!(
|
||||
matches!(state, "online" | "suspect" | "offline" | "returning"),
|
||||
"unknown runtime state: {state}"
|
||||
);
|
||||
assert!(
|
||||
state != "online" || !host_online,
|
||||
"a reachable online drive cannot be excluded from quorum"
|
||||
);
|
||||
}
|
||||
if !read_ready {
|
||||
assert!(
|
||||
payload["degradedReasons"]
|
||||
.as_array()
|
||||
.expect("degraded reasons")
|
||||
.iter()
|
||||
.any(|reason| reason == "storage_and_lock_unavailable")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_node_readiness_tracks_quorum(cluster: &mut RustFSTestClusterEnvironment) -> TestResult {
|
||||
let clients: Vec<_> = cluster
|
||||
.create_all_clients()?
|
||||
@@ -483,27 +548,14 @@ async fn assert_node_readiness_tracks_quorum(cluster: &mut RustFSTestClusterEnvi
|
||||
&& payload["details"]["poolMetadata"]["ready"] == true
|
||||
&& payload["details"]["iam"]["ready"] == true
|
||||
&& payload["details"]["lock"]["ready"] == read_quorum
|
||||
&& unavailable_readiness_drive_indices(&payload) == (survivors..4).collect::<Vec<_>>()
|
||||
{
|
||||
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"], "read_quorum");
|
||||
assert_eq!(payload["details"]["storage"]["source"], "local_runtime");
|
||||
assert_eq!(
|
||||
payload["details"]["storage"]["status"],
|
||||
if node_ready { "connected" } else { "disconnected" }
|
||||
);
|
||||
if !node_ready {
|
||||
assert!(
|
||||
payload["degradedReasons"]
|
||||
.as_array()
|
||||
.expect("degraded reasons")
|
||||
.iter()
|
||||
.any(|reason| reason == "storage_and_lock_unavailable")
|
||||
);
|
||||
}
|
||||
assert_node_readiness_contract(&payload, survivors);
|
||||
|
||||
for path in ["/health/ready", "/minio/health/ready"] {
|
||||
let head = http.head(format!("{url}{path}")).send().await?;
|
||||
@@ -512,7 +564,9 @@ async fn assert_node_readiness_tracks_quorum(cluster: &mut RustFSTestClusterEnvi
|
||||
let response = http.get(format!("{url}{path}")).send().await?;
|
||||
assert_eq!(response.status().as_u16(), node_status);
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
assert_eq!(body["details"]["storage"], payload["details"]["storage"]);
|
||||
// Each response samples live diagnostics; suspect -> offline
|
||||
// must not change the quorum contract or excluded topology.
|
||||
assert_node_readiness_contract(&body, survivors);
|
||||
assert_eq!(body["details"]["poolMetadata"], payload["details"]["poolMetadata"]);
|
||||
}
|
||||
let live = http.get(format!("{url}/health/live")).send().await?;
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::protocols::sftp_compliance::{
|
||||
test_sftp_compliance_readonly, test_sftp_compliance_standalone, test_sftp_compliance_suite,
|
||||
};
|
||||
use crate::protocols::sftp_core::{test_sftp_core_operations, test_sftp_idle_timeout_disconnects};
|
||||
use crate::protocols::webdav_core::test_webdav_core_operations;
|
||||
use crate::protocols::webdav_core::{test_webdav_core_operations, test_webdav_quota_reporting};
|
||||
use std::time::Instant;
|
||||
use tokio::time::{Duration, sleep};
|
||||
use tracing::{error, info};
|
||||
@@ -99,6 +99,10 @@ impl ProtocolTestSuite {
|
||||
info!("=== Starting WebDAV Core Test ===");
|
||||
"WebDAV core operations (MKCOL, PUT, GET, DELETE, PROPFIND)"
|
||||
}
|
||||
"test_webdav_quota_reporting" => {
|
||||
info!("=== Starting WebDAV Quota Reporting Test ===");
|
||||
"WebDAV quota reporting (PROPFIND quota properties: bucket-quota sum and physical-capacity fallback)"
|
||||
}
|
||||
"test_sftp_core_operations" => {
|
||||
info!("=== Starting SFTP Core Test ===");
|
||||
"SFTP core operations (banner, mkdir, put, get with SHA compare, rename, delete, rmdir)"
|
||||
@@ -158,6 +162,7 @@ impl ProtocolTestSuite {
|
||||
match test_def.name {
|
||||
"test_ftps_core_operations" => test_ftps_core_operations().await.map_err(|e| e.into()),
|
||||
"test_webdav_core_operations" => test_webdav_core_operations().await.map_err(|e| e.into()),
|
||||
"test_webdav_quota_reporting" => test_webdav_quota_reporting().await.map_err(|e| e.into()),
|
||||
"test_sftp_core_operations" => test_sftp_core_operations().await.map_err(|e| e.into()),
|
||||
"test_sftp_compliance_suite" => test_sftp_compliance_suite().await.map_err(|e| e.into()),
|
||||
"test_sftp_compliance_readonly" => test_sftp_compliance_readonly().await.map_err(|e| e.into()),
|
||||
@@ -203,6 +208,10 @@ fn all_protocol_tests() -> Vec<TestDefinition> {
|
||||
name: "test_webdav_core_operations",
|
||||
required_feature: "webdav",
|
||||
},
|
||||
TestDefinition {
|
||||
name: "test_webdav_quota_reporting",
|
||||
required_feature: "webdav",
|
||||
},
|
||||
TestDefinition {
|
||||
name: "test_sftp_core_operations",
|
||||
required_feature: "sftp",
|
||||
@@ -253,9 +262,10 @@ mod tests {
|
||||
fn schedules_all_protocol_tests_without_feature_filter() {
|
||||
let names = scheduled_names(ProtocolTestSuite::with_requested_features(None));
|
||||
|
||||
assert_eq!(names.len(), 7);
|
||||
assert_eq!(names.len(), 8);
|
||||
assert!(names.contains(&"test_ftps_core_operations"));
|
||||
assert!(names.contains(&"test_webdav_core_operations"));
|
||||
assert!(names.contains(&"test_webdav_quota_reporting"));
|
||||
assert!(names.contains(&"test_sftp_core_operations"));
|
||||
assert!(names.contains(&"test_sftp_compliance_standalone"));
|
||||
}
|
||||
@@ -264,7 +274,14 @@ mod tests {
|
||||
fn schedules_only_requested_non_sftp_protocols() {
|
||||
let names = scheduled_names(ProtocolTestSuite::with_requested_features(Some("ftps, webdav")));
|
||||
|
||||
assert_eq!(names, vec!["test_ftps_core_operations", "test_webdav_core_operations"]);
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"test_ftps_core_operations",
|
||||
"test_webdav_core_operations",
|
||||
"test_webdav_quota_reporting"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -295,9 +312,10 @@ mod tests {
|
||||
fn full_feature_schedules_all_protocol_tests() {
|
||||
let names = scheduled_names(ProtocolTestSuite::with_requested_features(Some("full")));
|
||||
|
||||
assert_eq!(names.len(), 7);
|
||||
assert_eq!(names.len(), 8);
|
||||
assert!(names.contains(&"test_ftps_core_operations"));
|
||||
assert!(names.contains(&"test_webdav_core_operations"));
|
||||
assert!(names.contains(&"test_webdav_quota_reporting"));
|
||||
assert!(names.contains(&"test_sftp_core_operations"));
|
||||
assert!(names.contains(&"test_sftp_compliance_standalone"));
|
||||
}
|
||||
|
||||
@@ -33,10 +33,14 @@
|
||||
|
||||
use crate::common::rustfs_binary_path_with_features;
|
||||
use crate::common::{AdminTransport, admin_add_canned_policy_via, admin_attach_user_policy_via, admin_create_user_via};
|
||||
use crate::common::{FAST_DATA_USAGE_SCANNER_ENV, admin_request};
|
||||
use crate::protocols::test_env::{DEFAULT_ACCESS_KEY, DEFAULT_SECRET_KEY, ProtocolTestEnvironment};
|
||||
use anyhow::Result;
|
||||
use http::Method;
|
||||
use reqwest::Client;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::process::Command;
|
||||
use tokio::time::sleep;
|
||||
use tracing::info;
|
||||
|
||||
// Fixed WebDAV port for testing
|
||||
@@ -769,3 +773,261 @@ pub async fn test_webdav_core_operations() -> Result<()> {
|
||||
async fn test_webdav_core_operations_direct() -> Result<()> {
|
||||
test_webdav_core_operations().await
|
||||
}
|
||||
|
||||
/// Extract the numeric value of the first occurrence of a DAV property in a
|
||||
/// PROPFIND multistatus body. Namespace-prefix agnostic; a self-closing
|
||||
/// (unreported) property yields None.
|
||||
fn quota_prop_value(body: &str, prop: &str) -> Option<u64> {
|
||||
let prop_start = body.find(prop)?;
|
||||
let after_name = &body[prop_start + prop.len()..];
|
||||
let value_start = after_name.find('>')? + 1;
|
||||
let value_end = after_name[value_start..].find('<')? + value_start;
|
||||
after_name[value_start..value_end].trim().parse().ok()
|
||||
}
|
||||
|
||||
/// PROPFIND `/` at Depth 0 requesting the two quota properties, returning
|
||||
/// (quota-available-bytes, quota-used-bytes).
|
||||
async fn propfind_root_quota(client: &Client, base_url: &str, auth_header: &str) -> Result<(u64, u64)> {
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:"><D:prop><D:quota-available-bytes/><D:quota-used-bytes/></D:prop></D:propfind>"#;
|
||||
let resp = client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method"),
|
||||
base_url,
|
||||
)
|
||||
.header("Authorization", auth_header)
|
||||
.header("Depth", "0")
|
||||
.header("Content-Type", "application/xml")
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
anyhow::ensure!(status.as_u16() == 207, "PROPFIND / should return 207, got: {status} body: {text}");
|
||||
let available = quota_prop_value(&text, "quota-available-bytes")
|
||||
.ok_or_else(|| anyhow::anyhow!("quota-available-bytes missing from PROPFIND response: {text}"))?;
|
||||
let used = quota_prop_value(&text, "quota-used-bytes")
|
||||
.ok_or_else(|| anyhow::anyhow!("quota-used-bytes missing from PROPFIND response: {text}"))?;
|
||||
Ok((available, used))
|
||||
}
|
||||
|
||||
/// Set a HARD bucket quota, retrying while the admin API reports the quota
|
||||
/// subsystem as not yet ready (503), mirroring quota_test.rs readiness.
|
||||
async fn set_bucket_quota_with_retry(admin_base_url: &str, bucket: &str, quota_bytes: u64) -> Result<()> {
|
||||
let quota_path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||
let quota_config = serde_json::json!({
|
||||
"quota": quota_bytes,
|
||||
"quota_type": "HARD"
|
||||
})
|
||||
.to_string();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let (status, response) = admin_request(
|
||||
admin_base_url,
|
||||
Method::PUT,
|
||||
"a_path,
|
||||
Some(quota_config.clone()),
|
||||
DEFAULT_ACCESS_KEY,
|
||||
DEFAULT_SECRET_KEY,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
if status != http::StatusCode::SERVICE_UNAVAILABLE {
|
||||
anyhow::bail!("failed to set quota for {bucket}: {status} {response}");
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!("quota readiness did not converge for {bucket} within 30 seconds");
|
||||
}
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Test WebDAV: PROPFIND quota properties (quota-available-bytes /
|
||||
/// quota-used-bytes).
|
||||
///
|
||||
/// dav-server queries quota once per PROPFIND without a path argument, so the
|
||||
/// reported pair is session-scoped:
|
||||
///
|
||||
/// - Phase A: a visible bucket without quota (or no buckets at all) falls back
|
||||
/// to the cluster's usable physical capacity, the console dashboard numbers.
|
||||
/// - Phase B: once every visible bucket carries a HARD quota, the pair is the
|
||||
/// summed cached bucket usage against the summed quota limits.
|
||||
pub async fn test_webdav_quota_reporting() -> Result<()> {
|
||||
// Dedicated ports: the core WebDAV test owns 9010/9080.
|
||||
const QUOTA_S3_ADDRESS: &str = "127.0.0.1:9011";
|
||||
const QUOTA_WEBDAV_PORT: u16 = 9081;
|
||||
const QUOTA_WEBDAV_ADDRESS: &str = "127.0.0.1:9081";
|
||||
const QUOTA_LIMIT: u64 = 1_073_741_824; // 1 GiB
|
||||
const OBJECT_CONTENT: &[u8] = b"Hello, WebDAV!";
|
||||
|
||||
let env = ProtocolTestEnvironment::new().map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
let admin_base_url = format!("http://{QUOTA_S3_ADDRESS}");
|
||||
|
||||
info!("Starting WebDAV server on {}", QUOTA_WEBDAV_ADDRESS);
|
||||
let binary_path = rustfs_binary_path_with_features(Some("webdav"));
|
||||
let mut server_command = Command::new(&binary_path);
|
||||
server_command
|
||||
.arg("--address")
|
||||
.arg(QUOTA_S3_ADDRESS)
|
||||
.env("RUSTFS_CONSOLE_ENABLE", "false")
|
||||
.env("RUSTFS_WEBDAV_ENABLE", "true")
|
||||
.env("RUSTFS_WEBDAV_ADDRESS", QUOTA_WEBDAV_ADDRESS)
|
||||
.env("RUSTFS_WEBDAV_TLS_ENABLED", "false") // No TLS for testing
|
||||
.arg(&env.temp_dir);
|
||||
// Quota-mode reporting reads the data-usage scanner cache; run the scanner
|
||||
// on a fast cycle so the uploaded object converges quickly.
|
||||
for (key, value) in FAST_DATA_USAGE_SCANNER_ENV {
|
||||
server_command.env(key, value);
|
||||
}
|
||||
let mut server_process = server_command.spawn()?;
|
||||
|
||||
// Ensure server is cleaned up even on failure
|
||||
let result = async {
|
||||
ProtocolTestEnvironment::wait_for_port_ready(QUOTA_WEBDAV_PORT, 30)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let client = create_client();
|
||||
let auth_header = basic_auth_header();
|
||||
let base_url = format!("http://{QUOTA_WEBDAV_ADDRESS}");
|
||||
|
||||
// Phase A: physical-capacity fallback (a visible bucket has no quota)
|
||||
info!("Testing WebDAV quota: fallback to physical capacity without bucket quotas");
|
||||
let phase_a_bucket = "quota-fallback-probe";
|
||||
let resp = client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method"),
|
||||
format!("{base_url}/{phase_a_bucket}"),
|
||||
)
|
||||
.header("Authorization", &auth_header)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"MKCOL '{phase_a_bucket}' should succeed, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// Storage info may briefly report zero capacity right after startup
|
||||
// (disks still initializing), which omits the quota properties; poll
|
||||
// until they appear.
|
||||
let phase_a_deadline = Instant::now() + Duration::from_secs(30);
|
||||
let (available, _used) = loop {
|
||||
match propfind_root_quota(&client, &base_url, &auth_header).await {
|
||||
Ok(quota) => break quota,
|
||||
Err(e) if Instant::now() < phase_a_deadline => {
|
||||
info!("quota properties not reported yet, retrying: {e}");
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
available > 0,
|
||||
"physical-capacity fallback should report positive quota-available-bytes, got: {available}"
|
||||
);
|
||||
info!("PASS: quota fallback reports physical capacity (available={available})");
|
||||
|
||||
let resp = client
|
||||
.delete(format!("{base_url}/{phase_a_bucket}"))
|
||||
.header("Authorization", &auth_header)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success() || resp.status().as_u16() == 204,
|
||||
"DELETE bucket '{phase_a_bucket}' should succeed, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
// Phase B: every visible bucket has a HARD quota -> summed usage/quota
|
||||
info!("Testing WebDAV quota: summed bucket quota reporting");
|
||||
let bucket = "quota-probe";
|
||||
let object = "hello.txt";
|
||||
let resp = client
|
||||
.request(
|
||||
reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method"),
|
||||
format!("{base_url}/{bucket}"),
|
||||
)
|
||||
.header("Authorization", &auth_header)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(resp.status().is_success(), "MKCOL '{bucket}' should succeed, got: {}", resp.status());
|
||||
|
||||
let resp = client
|
||||
.put(format!("{base_url}/{bucket}/{object}"))
|
||||
.header("Authorization", &auth_header)
|
||||
.body(OBJECT_CONTENT)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success() || resp.status().as_u16() == 201,
|
||||
"PUT '{bucket}/{object}' should succeed, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
set_bucket_quota_with_retry(&admin_base_url, bucket, QUOTA_LIMIT).await?;
|
||||
|
||||
// The usage cache trails the scanner; poll PROPFIND until the uploaded
|
||||
// object is reflected (before convergence the missing usage counts as 0).
|
||||
let expected_used = OBJECT_CONTENT.len() as u64;
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
let (available, used) = loop {
|
||||
let (available, used) = propfind_root_quota(&client, &base_url, &auth_header).await?;
|
||||
if used == expected_used {
|
||||
break (available, used);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"quota-used-bytes did not converge to {expected_used} within 60 seconds (last: used={used} available={available})"
|
||||
);
|
||||
}
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
};
|
||||
assert_eq!(
|
||||
available,
|
||||
QUOTA_LIMIT - expected_used,
|
||||
"quota-available-bytes should be the remaining quota"
|
||||
);
|
||||
info!("PASS: summed quota reporting converged (used={used}, available={available})");
|
||||
|
||||
// Cleanup
|
||||
let resp = client
|
||||
.delete(format!("{base_url}/{bucket}/{object}"))
|
||||
.header("Authorization", &auth_header)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success() || resp.status().as_u16() == 204,
|
||||
"DELETE '{bucket}/{object}' should succeed, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
let resp = client
|
||||
.delete(format!("{base_url}/{bucket}"))
|
||||
.header("Authorization", &auth_header)
|
||||
.send()
|
||||
.await?;
|
||||
assert!(
|
||||
resp.status().is_success() || resp.status().as_u16() == 204,
|
||||
"DELETE bucket '{bucket}' should succeed, got: {}",
|
||||
resp.status()
|
||||
);
|
||||
|
||||
info!("WebDAV quota reporting tests passed");
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Always cleanup server process
|
||||
let _ = server_process.kill().await;
|
||||
let _ = server_process.wait().await;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_webdav_quota_reporting_direct() -> Result<()> {
|
||||
test_webdav_quota_reporting().await
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::disk::{RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
|
||||
pub(crate) use rustfs_ecstore::api::disk::{Endpoint, RUSTFS_META_BUCKET, VolumeInfo, WalkDirOptions};
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
||||
#[cfg(test)]
|
||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
||||
|
||||
@@ -35,7 +35,7 @@ use tokio::time::{Duration, timeout};
|
||||
|
||||
type BoxError = Box<dyn Error + Send + Sync>;
|
||||
type TestResult = Result<(), BoxError>;
|
||||
const OPA_AUTH_TOKEN: &str = "sts-opa-token";
|
||||
pub(crate) const OPA_AUTH_TOKEN: &str = "sts-opa-token";
|
||||
|
||||
fn sts_client(url: &str, access_key: &str, secret_key: &str, session_token: Option<&str>) -> Client {
|
||||
build_test_sts_client(url, access_key, secret_key, session_token, "e2e-sts-query-compat")
|
||||
@@ -103,6 +103,25 @@ async fn create_user(env: &RustFSTestEnvironment, user: &str, secret: &str) -> T
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_department(client: &aws_sdk_s3::Client, bucket: &str, department: &str) -> TestResult {
|
||||
client
|
||||
.put_bucket_tagging()
|
||||
.bucket(bucket)
|
||||
.tagging(
|
||||
aws_sdk_s3::types::Tagging::builder()
|
||||
.tag_set(
|
||||
aws_sdk_s3::types::Tag::builder()
|
||||
.key("department")
|
||||
.value(department)
|
||||
.build()?,
|
||||
)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assert_access_denied(client: &Client, context: &str) -> TestResult {
|
||||
let error = client
|
||||
.assume_role()
|
||||
@@ -240,15 +259,19 @@ async fn handle_opa_request(
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/input/action"))
|
||||
.and_then(Value::as_str);
|
||||
let bucket = payload
|
||||
let department = payload
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/input/resource/bucket"))
|
||||
.and_then(|value| value.pointer("/input/context/conditions/ExistingBucketTag~1department/0"))
|
||||
.and_then(Value::as_str);
|
||||
matches!(
|
||||
(action, bucket),
|
||||
(Some("s3:ListBucket"), Some("opa-list-visible")) | (Some("s3:GetBucketLocation"), Some("opa-list-location"))
|
||||
(action, department),
|
||||
(Some("s3:ListBucket"), Some("finance")) | (Some("s3:GetBucketLocation"), Some("legal"))
|
||||
)
|
||||
}
|
||||
Some(Value::String(account)) if account == "opabuckettags" => payload.as_ref().is_some_and(|value| {
|
||||
value["input"]["action"] == "s3:CreateBucket"
|
||||
|| value["input"]["context"]["conditions"]["ExistingBucketTag/department"] == serde_json::json!(["finance"])
|
||||
}),
|
||||
None => true,
|
||||
_ => false,
|
||||
};
|
||||
@@ -270,15 +293,15 @@ enum OpaValidationMode {
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
struct OpaMock {
|
||||
url: String,
|
||||
pub(crate) struct OpaMock {
|
||||
pub(crate) url: String,
|
||||
requests: mpsc::UnboundedReceiver<Value>,
|
||||
validation_started: mpsc::UnboundedReceiver<()>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl OpaMock {
|
||||
async fn start() -> Result<Self, BoxError> {
|
||||
pub(crate) async fn start() -> Result<Self, BoxError> {
|
||||
Self::start_with_mode(OpaValidationMode::Ready, Some(OPA_AUTH_TOKEN)).await
|
||||
}
|
||||
|
||||
@@ -335,6 +358,16 @@ impl OpaMock {
|
||||
.ok_or_else(|| "OPA request channel closed".into())
|
||||
}
|
||||
|
||||
pub(crate) async fn expect_bucket_tags(&mut self, action: &str, bucket: &str, department: Option<&str>) -> TestResult {
|
||||
let payload = self.next_request().await?;
|
||||
let input = &payload["input"];
|
||||
assert_eq!(input["action"], action);
|
||||
assert_eq!(input["resource"]["bucket"], bucket);
|
||||
let expected = department.map(|value| serde_json::json!([value]));
|
||||
assert_eq!(input["context"]["conditions"].get("ExistingBucketTag/department"), expected.as_ref());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_validation(&mut self) -> TestResult {
|
||||
timeout(Duration::from_secs(5), self.validation_started.recv())
|
||||
.await?
|
||||
@@ -571,8 +604,13 @@ async fn test_list_buckets_opa_contract() -> TestResult {
|
||||
.await?;
|
||||
|
||||
let admin_client = env.create_s3_client();
|
||||
for bucket in ["opa-list-hidden", "opa-list-location", "opa-list-visible"] {
|
||||
for (bucket, department) in [
|
||||
("opa-list-hidden", "engineering"),
|
||||
("opa-list-location", "legal"),
|
||||
("opa-list-visible", "finance"),
|
||||
] {
|
||||
admin_client.create_bucket().bucket(bucket).send().await?;
|
||||
set_department(&admin_client, bucket, department).await?;
|
||||
}
|
||||
|
||||
let user = "opalistbuckets";
|
||||
@@ -640,6 +678,240 @@ async fn test_list_buckets_opa_contract() -> TestResult {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_bucket_tags_opa_contract() -> TestResult {
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Tag, Tagging, VersioningConfiguration,
|
||||
};
|
||||
|
||||
init_logging();
|
||||
let mut opa = OpaMock::start().await?;
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server_without_cleanup_with_env(&[
|
||||
("RUSTFS_POLICY_PLUGIN_URL", opa.url.as_str()),
|
||||
("RUSTFS_POLICY_PLUGIN_AUTH_TOKEN", OPA_AUTH_TOKEN),
|
||||
("NO_PROXY", "127.0.0.1,localhost"),
|
||||
])
|
||||
.await?;
|
||||
let secret = uuid::Uuid::new_v4().to_string();
|
||||
create_user(&env, "opabuckettags", &secret).await?;
|
||||
let client = aws_sdk_s3::Client::from_conf(build_test_s3_config(&env.url, "opabuckettags", &secret, None, "e2e-bucket-tags"));
|
||||
let admin = env.create_s3_client();
|
||||
let source = "opa-tags-source";
|
||||
let destination = "opa-tags-destination";
|
||||
|
||||
client.create_bucket().bucket(source).send().await?;
|
||||
opa.expect_bucket_tags("s3:CreateBucket", source, None).await?;
|
||||
set_department(&admin, source, "finance").await?;
|
||||
admin
|
||||
.put_bucket_versioning()
|
||||
.bucket(source)
|
||||
.versioning_configuration(
|
||||
VersioningConfiguration::builder()
|
||||
.status(BucketVersioningStatus::Enabled)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let written = client
|
||||
.put_object()
|
||||
.bucket(source)
|
||||
.key("report")
|
||||
.tagging("department=engineering")
|
||||
.body(ByteStream::from_static(b"report"))
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:PutObject", source, Some("finance")).await?;
|
||||
let version = written.version_id().ok_or("versioned PUT must return a version ID")?;
|
||||
let read = client
|
||||
.get_object()
|
||||
.bucket(source)
|
||||
.key("report")
|
||||
.version_id(version)
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
client
|
||||
.head_object()
|
||||
.bucket(source)
|
||||
.key("report")
|
||||
.version_id(version)
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:GetObject", source, Some("finance")).await?;
|
||||
client.list_objects_v2().bucket(source).send().await?;
|
||||
opa.expect_bucket_tags("s3:ListBucket", source, Some("finance")).await?;
|
||||
client.list_object_versions().bucket(source).send().await?;
|
||||
opa.expect_bucket_tags("s3:ListBucketVersions", source, Some("finance"))
|
||||
.await?;
|
||||
|
||||
admin.create_bucket().bucket(destination).send().await?;
|
||||
set_department(&admin, destination, "engineering").await?;
|
||||
let copy_source = format!("{source}/report?versionId={version}");
|
||||
let error = client
|
||||
.copy_object()
|
||||
.bucket(destination)
|
||||
.key("copied")
|
||||
.copy_source(©_source)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("source tags must not authorize a different destination");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("engineering"))
|
||||
.await?;
|
||||
set_department(&admin, destination, "finance").await?;
|
||||
client
|
||||
.copy_object()
|
||||
.bucket(destination)
|
||||
.key("copied")
|
||||
.copy_source(©_source)
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
|
||||
|
||||
let upload = client
|
||||
.create_multipart_upload()
|
||||
.bucket(destination)
|
||||
.key("multipart")
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
|
||||
let upload_id = upload.upload_id().ok_or("multipart upload ID")?;
|
||||
client
|
||||
.upload_part()
|
||||
.bucket(destination)
|
||||
.key("multipart")
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.body(ByteStream::from_static(b"part"))
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
|
||||
let copied_part = client
|
||||
.upload_part_copy()
|
||||
.bucket(destination)
|
||||
.key("multipart")
|
||||
.upload_id(upload_id)
|
||||
.part_number(1)
|
||||
.copy_source(©_source)
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("finance")).await?;
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
|
||||
let part = copied_part.copy_part_result().ok_or("copied part result")?;
|
||||
client
|
||||
.complete_multipart_upload()
|
||||
.bucket(destination)
|
||||
.key("multipart")
|
||||
.upload_id(upload_id)
|
||||
.multipart_upload(
|
||||
CompletedMultipartUpload::builder()
|
||||
.parts(
|
||||
CompletedPart::builder()
|
||||
.part_number(1)
|
||||
.e_tag(part.e_tag().ok_or("part ETag")?)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
opa.expect_bucket_tags("s3:PutObject", destination, Some("finance")).await?;
|
||||
let read = client.get_object().bucket(destination).key("multipart").send().await?;
|
||||
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
|
||||
opa.expect_bucket_tags("s3:GetObject", destination, Some("finance")).await?;
|
||||
|
||||
// Mutation authorization uses the old tags, not the proposed replacement.
|
||||
set_department(&client, source, "engineering").await?;
|
||||
opa.expect_bucket_tags("s3:PutBucketTagging", source, Some("finance")).await?;
|
||||
let error = client
|
||||
.list_objects_v2()
|
||||
.bucket(source)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("updated tags revoke access");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
opa.expect_bucket_tags("s3:ListBucket", source, Some("engineering")).await?;
|
||||
let error = client
|
||||
.copy_object()
|
||||
.bucket(destination)
|
||||
.key("denied-source")
|
||||
.copy_source(©_source)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("destination tags must not authorize a denied source");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
opa.expect_bucket_tags("s3:GetObjectVersion", source, Some("engineering"))
|
||||
.await?;
|
||||
set_department(&client, source, "finance")
|
||||
.await
|
||||
.expect_err("requested tags must not authorize their own mutation");
|
||||
opa.expect_bucket_tags("s3:PutBucketTagging", source, Some("engineering"))
|
||||
.await?;
|
||||
set_department(&admin, source, "finance").await?;
|
||||
client.delete_bucket_tagging().bucket(source).send().await?;
|
||||
opa.expect_bucket_tags("s3:PutBucketTagging", source, Some("finance")).await?;
|
||||
let error = client
|
||||
.list_objects_v2()
|
||||
.bucket(source)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("untagged bucket is denied by this policy");
|
||||
assert_eq!(error.as_service_error().and_then(ProvideErrorMetadata::code), Some("AccessDenied"));
|
||||
opa.expect_bucket_tags("s3:ListBucket", source, None).await?;
|
||||
|
||||
// Ambiguous stored tags must not turn into an implicit IAM denial that a
|
||||
// separate bucket-policy Allow can override.
|
||||
admin
|
||||
.put_bucket_policy()
|
||||
.bucket(destination)
|
||||
.policy(
|
||||
serde_json::json!({
|
||||
"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": {"AWS": "*"},
|
||||
"Action": "s3:GetObject", "Resource": format!("arn:aws:s3:::{destination}/*")}]
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
// A normal OPA denial retains the existing bucket-policy Allow fallback;
|
||||
// a metadata lookup error must abort that same authorization path.
|
||||
set_department(&admin, destination, "engineering").await?;
|
||||
let read = client.get_object().bucket(destination).key("multipart").send().await?;
|
||||
assert_eq!(read.body.collect().await?.into_bytes().as_ref(), b"report");
|
||||
opa.expect_bucket_tags("s3:GetObject", destination, Some("engineering"))
|
||||
.await?;
|
||||
admin
|
||||
.put_bucket_tagging()
|
||||
.bucket(destination)
|
||||
.tagging(
|
||||
Tagging::builder()
|
||||
.tag_set(Tag::builder().key("department").value("finance").build()?)
|
||||
.tag_set(Tag::builder().key("department").value("engineering").build()?)
|
||||
.build()?,
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let error = client
|
||||
.get_object()
|
||||
.bucket(destination)
|
||||
.key("multipart")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("metadata failure must not fall back to a bucket-policy Allow");
|
||||
assert_eq!(error.raw_response().map(|response| response.status().as_u16()), Some(500));
|
||||
assert!(
|
||||
matches!(opa.requests.try_recv(), Err(mpsc::error::TryRecvError::Empty)),
|
||||
"invalid tags must not be sent to OPA as absent"
|
||||
);
|
||||
env.stop_server();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sts_and_list_buckets_fail_closed_while_opa_is_initializing() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
@@ -165,4 +165,210 @@ mod tests {
|
||||
info!("RT-13c PASS: scanner/config persists across restart");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RT-14: a transition PUT must not carry reserved `x-minio-internal-*` headers.
|
||||
///
|
||||
/// Regression pattern: transition forwarded the object's internal metadata as raw
|
||||
/// request headers, so MinIO-derived remotes (Storj) refused every transition with
|
||||
/// `400 InvalidArgument` while `tier add` still succeeded (rustfs#8362). The fake
|
||||
/// target refuses any PutObject carrying such a header, like MinIO and Storj do.
|
||||
#[tokio::test]
|
||||
async fn test_transition_put_omits_reserved_internal_headers() -> TestResult {
|
||||
use crate::fake_s3_target::{BucketMode, FAKE_ACCESS_KEY, FAKE_SECRET_KEY, FakeS3Target, Operation};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{
|
||||
BucketLifecycleConfiguration, ExpirationStatus, LifecycleRule, LifecycleRuleFilter, Transition,
|
||||
TransitionStorageClass,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
init_logging();
|
||||
info!("RT-14: transition PUT omits reserved internal headers");
|
||||
|
||||
const TIER: &str = "RT14COLD";
|
||||
const TARGET_BUCKET: &str = "rt14-cold";
|
||||
const SOURCE_BUCKET: &str = "rt14-hot";
|
||||
|
||||
let target = FakeS3Target::start().await.expect("start fake target");
|
||||
target.create_bucket_with_mode(TARGET_BUCKET, BucketMode::Unversioned);
|
||||
target.reject_reserved_internal_headers(true);
|
||||
|
||||
let mut env = RustFSTestEnvironment::new().await.expect("create test environment");
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_CONSOLE_ENABLE", "false"),
|
||||
("RUSTFS_TIER_RUSTFS_ALLOW_LOOPBACK_ENDPOINT", "true"),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.expect("start RustFS");
|
||||
|
||||
let tier_body = serde_json::json!({
|
||||
"type": "rustfs",
|
||||
"rustfs": {
|
||||
"name": TIER,
|
||||
"endpoint": target.endpoint(),
|
||||
"accessKey": FAKE_ACCESS_KEY,
|
||||
"secretKey": FAKE_SECRET_KEY,
|
||||
"bucket": TARGET_BUCKET,
|
||||
"prefix": "cold/",
|
||||
"region": "us-east-1",
|
||||
"storageClass": ""
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
match admin_ok(&env, http::Method::PUT, "/rustfs/admin/v3/tier", Some(tier_body.clone())).await {
|
||||
Ok(_) => break,
|
||||
Err(err) if Instant::now() < deadline => {
|
||||
info!(" add tier not ready yet: {err}");
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
Err(err) => panic!("RT-14 FAIL: add tier: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
let client = env.create_s3_client();
|
||||
client
|
||||
.create_bucket()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.send()
|
||||
.await
|
||||
.expect("create bucket");
|
||||
|
||||
let objects: Vec<(&str, Vec<u8>)> = vec![
|
||||
("small.bin", (0..1024u32).map(|i| (i % 251) as u8).collect()),
|
||||
("medium.bin", (0..200_000u32).map(|i| (i.wrapping_mul(31) % 253) as u8).collect()),
|
||||
];
|
||||
for (key, body) in &objects {
|
||||
client
|
||||
.put_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(*key)
|
||||
.body(ByteStream::from(body.clone()))
|
||||
.send()
|
||||
.await
|
||||
.expect("put source object");
|
||||
}
|
||||
|
||||
let rule = LifecycleRule::builder()
|
||||
.id("to-cold")
|
||||
.status(ExpirationStatus::Enabled)
|
||||
.filter(LifecycleRuleFilter::builder().prefix("").build())
|
||||
.transitions(
|
||||
Transition::builder()
|
||||
.days(0)
|
||||
.storage_class(TransitionStorageClass::from(TIER))
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
.expect("build rule");
|
||||
client
|
||||
.put_bucket_lifecycle_configuration()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.lifecycle_configuration(
|
||||
BucketLifecycleConfiguration::builder()
|
||||
.rules(rule)
|
||||
.build()
|
||||
.expect("build config"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("put lifecycle");
|
||||
|
||||
admin_ok(
|
||||
&env,
|
||||
http::Method::POST,
|
||||
&format!("/rustfs/admin/v3/ilm/transition/run?bucket={SOURCE_BUCKET}&prefix=&tier={TIER}&dryRun=false"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("run transition");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
while target.stored_keys(TARGET_BUCKET).len() < objects.len() {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"RT-14 FAIL: transition did not reach the target; stored keys: {:?}",
|
||||
target.stored_keys(TARGET_BUCKET)
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
let keys = target.stored_keys(TARGET_BUCKET);
|
||||
assert_eq!(keys.len(), objects.len(), "RT-14 FAIL: unexpected remote objects: {keys:?}");
|
||||
|
||||
let mut stored_bodies = Vec::new();
|
||||
for key in &keys {
|
||||
let (body, metadata) = target.stored_object(TARGET_BUCKET, key).expect("remote object");
|
||||
for name in [
|
||||
"x-rustfs-internal-transition-transaction-id",
|
||||
"x-minio-internal-transition-transaction-id",
|
||||
"x-rustfs-internal-transition-tier-destination-id",
|
||||
"x-minio-internal-transition-tier-destination-id",
|
||||
] {
|
||||
assert!(
|
||||
metadata.contains_key(name),
|
||||
"RT-14 FAIL: remote object {key} lost transition identity {name}: {:?}",
|
||||
metadata.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
stored_bodies.push(body.to_vec());
|
||||
}
|
||||
let mut expected_bodies: Vec<Vec<u8>> = objects.iter().map(|(_, body)| body.clone()).collect();
|
||||
expected_bodies.sort();
|
||||
stored_bodies.sort();
|
||||
assert_eq!(stored_bodies, expected_bodies, "RT-14 FAIL: remote bytes differ from the source objects");
|
||||
|
||||
let transition_puts = target
|
||||
.requests()
|
||||
.iter()
|
||||
.filter(|record| {
|
||||
record.operation == Operation::PutObject
|
||||
&& record
|
||||
.key
|
||||
.as_deref()
|
||||
.is_some_and(|key| key.contains("transition-transactions"))
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
transition_puts,
|
||||
objects.len(),
|
||||
"RT-14 FAIL: transition PUTs were retried or refused by the target"
|
||||
);
|
||||
|
||||
for (key, body) in &objects {
|
||||
let head = client
|
||||
.head_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(*key)
|
||||
.send()
|
||||
.await
|
||||
.expect("head transitioned");
|
||||
assert_eq!(
|
||||
head.storage_class().map(|class| class.as_str()),
|
||||
Some(TIER),
|
||||
"RT-14 FAIL: {key} is not reported as transitioned"
|
||||
);
|
||||
let got = client
|
||||
.get_object()
|
||||
.bucket(SOURCE_BUCKET)
|
||||
.key(*key)
|
||||
.send()
|
||||
.await
|
||||
.expect("get transitioned")
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.expect("read body")
|
||||
.into_bytes();
|
||||
assert_eq!(got.as_ref(), body.as_slice(), "RT-14 FAIL: read-back of {key} differs");
|
||||
}
|
||||
|
||||
target.shutdown().await;
|
||||
info!("RT-14 PASS: transition PUT omits reserved internal headers");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ use aws_sdk_s3::types::{
|
||||
};
|
||||
use http::{Method, StatusCode};
|
||||
use serde_json::Value;
|
||||
use std::future::Future;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
@@ -52,8 +53,15 @@ const VERSIONED_BUCKET: &str = "upgrade-versioned-data";
|
||||
const MIXED_BUCKET: &str = "upgrade-mixed-version-data";
|
||||
const MIXED_NODE_COUNT: usize = 4;
|
||||
const UPGRADE_READINESS_BODY: &[u8] = b"upgrade write readiness";
|
||||
// Written by the "previous-seed" readiness probe through node 0 before any
|
||||
// current binary starts; every later phase must still read it.
|
||||
const PREVIOUS_RELEASE_SEED_KEY: &str = ".upgrade-readiness/previous-seed/node-0";
|
||||
const MULTIPART_WORKERS: usize = 16;
|
||||
const MULTIPART_UPLOADS_PER_WORKER: usize = 16;
|
||||
// Bound each 64 KiB upload, including completion acknowledgement. A peer
|
||||
// that accepts the request but never responds must fail the compatibility
|
||||
// check with the writer and object, rather than consume the whole CI job.
|
||||
const MIXED_MULTIPART_UPLOAD_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// Peers keep a restarted node's drive in Suspect/Returning for roughly
|
||||
// probe_interval (2s) x success_threshold (3) after it comes back; 30s
|
||||
// comfortably covers that window plus CI scheduling jitter.
|
||||
@@ -68,6 +76,7 @@ const CONFIG_REPLICA_BUCKET: &str = "upgrade-config-replica";
|
||||
const ROLLBACK_BUCKET: &str = "rollback-config-data";
|
||||
const ROLLBACK_REPLICA_BUCKET: &str = "rollback-config-replica";
|
||||
const BUCKET_QUOTA_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const QUOTA_USAGE_READINESS_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
const LIFECYCLE_RULE_ID: &str = "upgrade-expire-logs";
|
||||
const LIFECYCLE_PREFIX: &str = "logs/";
|
||||
const LIFECYCLE_DAYS: i32 = 30;
|
||||
@@ -331,14 +340,21 @@ fn configure_cluster_logs(cluster: &mut RustFSTestClusterEnvironment) -> TestRes
|
||||
async fn write_multipart_load(clients: &[Client], phase: &str) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut tasks = JoinSet::new();
|
||||
for worker in 0..MULTIPART_WORKERS {
|
||||
let client = clients[worker % clients.len()].clone();
|
||||
let node = worker % clients.len();
|
||||
let client = clients[node].clone();
|
||||
let phase = phase.to_string();
|
||||
tasks.spawn(async move {
|
||||
let mut keys = Vec::with_capacity(MULTIPART_UPLOADS_PER_WORKER);
|
||||
for upload in 0..MULTIPART_UPLOADS_PER_WORKER {
|
||||
let key = format!("{phase}/multipart/{worker:02}/{upload:02}");
|
||||
let part = vec![u8::try_from(worker)?; 64 * 1024];
|
||||
write_multipart(&client, MIXED_BUCKET, &key, &[part]).await?;
|
||||
mixed_multipart_before(
|
||||
Instant::now() + MIXED_MULTIPART_UPLOAD_TIMEOUT,
|
||||
node,
|
||||
&key,
|
||||
write_multipart(&client, MIXED_BUCKET, &key, &[part]),
|
||||
)
|
||||
.await?;
|
||||
keys.push(key);
|
||||
}
|
||||
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(keys)
|
||||
@@ -352,6 +368,172 @@ async fn write_multipart_load(clients: &[Client], phase: &str) -> Result<Vec<Str
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
async fn mixed_multipart_before(
|
||||
deadline: Instant,
|
||||
node: usize,
|
||||
key: &str,
|
||||
upload: impl Future<Output = TestResult>,
|
||||
) -> TestResult {
|
||||
tokio::time::timeout_at(deadline, upload)
|
||||
.await
|
||||
.map_err(|_| MultipartAcknowledgementDeadline {
|
||||
node,
|
||||
key: key.to_owned(),
|
||||
})?
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MultipartAcknowledgementDeadline {
|
||||
node: usize,
|
||||
key: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MultipartAcknowledgementDeadline {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
formatter,
|
||||
"node {}: multipart upload {MIXED_BUCKET}/{} acknowledgement deadline exceeded",
|
||||
self.node, self.key
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MultipartAcknowledgementDeadline {}
|
||||
|
||||
// Decode only the public lock snapshot fields. Unknown fields, including
|
||||
// credential/configuration payloads, never enter the failure artifact.
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct MixedLockSnapshot {
|
||||
total: Option<u64>,
|
||||
truncated: Option<bool>,
|
||||
capability_note: Option<String>,
|
||||
locks: Vec<MixedLockEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
struct MixedLockEntry {
|
||||
resource: Option<String>,
|
||||
bucket: Option<String>,
|
||||
object: Option<String>,
|
||||
version: Option<String>,
|
||||
owner: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
lock_type: Option<String>,
|
||||
priority: Option<String>,
|
||||
since: Option<String>,
|
||||
elapsed_secs: Option<u64>,
|
||||
ttl_secs: Option<u64>,
|
||||
}
|
||||
|
||||
async fn read_mixed_lock_snapshot(response: &mut reqwest::Response) -> Result<MixedLockSnapshot, &'static str> {
|
||||
const MAX_BODY_BYTES: usize = 1024 * 1024;
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await.map_err(|_| "body_read_error")? {
|
||||
if chunk.len() > MAX_BODY_BYTES - body.len() {
|
||||
return Err("body_limit_exceeded");
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
serde_json::from_slice(&body).map_err(|_| "invalid_lock_snapshot")
|
||||
}
|
||||
|
||||
async fn probe_mixed_multipart_failure(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
node: usize,
|
||||
path: &str,
|
||||
head: bool,
|
||||
deadline: Instant,
|
||||
) -> Value {
|
||||
let started = Instant::now();
|
||||
let method = if head { Method::HEAD } else { Method::GET };
|
||||
let mut observation = serde_json::json!({
|
||||
"node": node, "method": method.as_str(), "path": path,
|
||||
"status": null, "observation": "unknown", "timeout": false,
|
||||
});
|
||||
// The shared absolute deadline bounds both headers and a slow/infinite
|
||||
// response body; a send-only timeout would leave the cluster alive forever.
|
||||
let result = tokio::time::timeout_at(deadline, async {
|
||||
let mut response = signed_request(
|
||||
method,
|
||||
&format!("{}{path}", cluster.nodes[node].url.trim_end_matches('/')),
|
||||
&cluster.access_key,
|
||||
&cluster.secret_key,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "request_error")?;
|
||||
let status = response.status();
|
||||
observation["status"] = serde_json::json!(status.as_u16());
|
||||
if head {
|
||||
observation["observation"] = serde_json::json!(match status {
|
||||
StatusCode::OK => "visible",
|
||||
StatusCode::NOT_FOUND => "not_visible",
|
||||
_ => "unknown",
|
||||
});
|
||||
for (field, header) in [
|
||||
("content_length", "content-length"),
|
||||
("etag", "etag"),
|
||||
("version", "x-amz-version-id"),
|
||||
] {
|
||||
observation[field] = serde_json::json!(response.headers().get(header).and_then(|value| value.to_str().ok()));
|
||||
}
|
||||
} else if status == StatusCode::OK {
|
||||
observation["snapshot"] =
|
||||
serde_json::to_value(read_mixed_lock_snapshot(&mut response).await?).map_err(|_| "snapshot_encode_error")?;
|
||||
observation["observation"] = serde_json::json!("captured");
|
||||
}
|
||||
Ok::<_, &'static str>(())
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(reason)) => observation["error"] = serde_json::json!(reason),
|
||||
Err(_) => observation["timeout"] = serde_json::json!(true),
|
||||
}
|
||||
observation["elapsed_secs"] = serde_json::json!(started.elapsed().as_secs_f64());
|
||||
observation
|
||||
}
|
||||
|
||||
async fn observe_mixed_multipart_failure(
|
||||
cluster: &RustFSTestClusterEnvironment,
|
||||
phase: &str,
|
||||
error: BoxError,
|
||||
log_dir: Option<&Path>,
|
||||
) -> TestResult {
|
||||
if let (Some(log_dir), Some(failure)) = (log_dir, error.downcast_ref::<MultipartAcknowledgementDeadline>()) {
|
||||
let started = Instant::now();
|
||||
let deadline = started + Duration::from_secs(5);
|
||||
// Reserve the last second for serialization and bounded artifact I/O.
|
||||
// All probes run directly in this future; no diagnostic task is spawned.
|
||||
let network_deadline = started + Duration::from_secs(4);
|
||||
let path = format!("/{MIXED_BUCKET}/{}", failure.key);
|
||||
let _ = tokio::time::timeout_at(deadline, async {
|
||||
let (heads, locks) = tokio::join!(
|
||||
futures::future::join_all((0..cluster.nodes.len()).map(|node| probe_mixed_multipart_failure(
|
||||
cluster,
|
||||
node,
|
||||
&path,
|
||||
true,
|
||||
network_deadline
|
||||
))),
|
||||
probe_mixed_multipart_failure(cluster, 0, "/rustfs/admin/v3/top/locks?count=1000", false, network_deadline),
|
||||
);
|
||||
let snapshot = serde_json::json!({
|
||||
"phase": phase, "bucket": MIXED_BUCKET, "key": failure.key,
|
||||
"writer_node": failure.node, "heads": heads, "top_locks": locks,
|
||||
});
|
||||
let body = serde_json::to_vec_pretty(&snapshot).map_err(|_| ())?;
|
||||
tokio::fs::write(log_dir.join("mixed-multipart-failure.json"), body)
|
||||
.await
|
||||
.map_err(|_| ())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
// Visibility and diagnostic failures cannot acknowledge the original MPU.
|
||||
Err(error)
|
||||
}
|
||||
|
||||
/// Assert that `client` eventually lists exactly `expected` objects under
|
||||
/// `{phase}/`, polling until [`LISTING_CONVERGENCE_TIMEOUT`].
|
||||
///
|
||||
@@ -396,7 +578,7 @@ async fn exercise_mixed_cluster(
|
||||
previous_node: usize,
|
||||
) -> TestResult {
|
||||
let clients = cluster.create_all_clients()?;
|
||||
wait_for_upgrade_write_readiness(&clients, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, phase, LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
let current_client = &clients[current_node];
|
||||
let previous_client = &clients[previous_node];
|
||||
|
||||
@@ -422,9 +604,20 @@ async fn exercise_mixed_cluster(
|
||||
.await?;
|
||||
assert_eq!(read_object(current_client, MIXED_BUCKET, &previous_key, None).await?.1, previous_body);
|
||||
|
||||
let multipart_keys = write_multipart_load(&clients, phase).await?;
|
||||
let multipart_keys = match write_multipart_load(&clients, phase).await {
|
||||
Ok(keys) => keys,
|
||||
Err(error) => {
|
||||
let log_dir = std::env::var_os("RUSTFS_E2E_LOG_DIR");
|
||||
return observe_mixed_multipart_failure(cluster, phase, error, log_dir.as_deref().map(Path::new)).await;
|
||||
}
|
||||
};
|
||||
let expected_count = multipart_keys.len() + 2;
|
||||
for (label, client) in [("current", current_client), ("previous", previous_client)] {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||
UPGRADE_READINESS_BODY,
|
||||
"{phase}: the {label} node must retain the previous-release seed"
|
||||
);
|
||||
wait_for_phase_listing(
|
||||
client,
|
||||
phase,
|
||||
@@ -453,7 +646,12 @@ fn upgrade_probe_client(client: &Client) -> Client {
|
||||
)
|
||||
}
|
||||
|
||||
async fn wait_for_upgrade_write_readiness(clients: &[Client], phase: &str, budget: Duration) -> TestResult {
|
||||
pub(crate) async fn wait_for_upgrade_write_readiness(
|
||||
clients: &[Client],
|
||||
bucket: &str,
|
||||
phase: &str,
|
||||
budget: Duration,
|
||||
) -> TestResult {
|
||||
// ListBuckets can succeed before peers recover a restarted disk. Cluster
|
||||
// health also accepts Returning disks whose write health is still FAULTY.
|
||||
// Probe every writer outside the asserted phase prefix; compatibility
|
||||
@@ -473,7 +671,7 @@ async fn wait_for_upgrade_write_readiness(clients: &[Client], phase: &str, budge
|
||||
deadline,
|
||||
client
|
||||
.put_object()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.bucket(bucket)
|
||||
.key(&key)
|
||||
.body(ByteStream::from_static(UPGRADE_READINESS_BODY))
|
||||
.send(),
|
||||
@@ -508,24 +706,23 @@ async fn prepare_previous_release_baseline(cluster: &mut RustFSTestClusterEnviro
|
||||
// waits for pool metadata (#7473). First prove the elected writer can
|
||||
// persist data, then restart each old process once with three peers still
|
||||
// readable. This preparation ends before any current binary is started.
|
||||
wait_for_upgrade_write_readiness(&clients[..1], "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
wait_for_upgrade_write_readiness(&clients[..1], MIXED_BUCKET, "previous-seed", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
for node in [1, 2, 3, 0] {
|
||||
cluster.stop_node(node)?;
|
||||
cluster.start_node_from_binary(node, previous_binary).await?;
|
||||
wait_for_upgrade_write_readiness(
|
||||
std::slice::from_ref(&clients[node]),
|
||||
MIXED_BUCKET,
|
||||
&format!("previous-restart-{node}"),
|
||||
LISTING_CONVERGENCE_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
wait_for_upgrade_write_readiness(&clients, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
wait_for_upgrade_write_readiness(&clients, MIXED_BUCKET, "previous-baseline", LISTING_CONVERGENCE_TIMEOUT).await?;
|
||||
for (reader, client) in clients.iter().enumerate() {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, ".upgrade-readiness/previous-seed/node-0", None)
|
||||
.await?
|
||||
.1,
|
||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||
UPGRADE_READINESS_BODY,
|
||||
"previous-release node {reader} must retain the seed across its rolling restart"
|
||||
);
|
||||
@@ -549,9 +746,10 @@ mod upgrade_write_readiness_tests {
|
||||
#[tokio::test]
|
||||
async fn waits_for_each_writer_after_metadata_is_ready() -> TestResult {
|
||||
let target = FakeS3Target::start().await?;
|
||||
target.create_bucket(MIXED_BUCKET);
|
||||
let bucket = "distributed-upgrade-history";
|
||||
target.create_bucket(bucket);
|
||||
let client = fake_source_client(&target);
|
||||
client.head_bucket().bucket(MIXED_BUCKET).send().await?;
|
||||
client.head_bucket().bucket(bucket).send().await?;
|
||||
|
||||
let phase = "one-previous-node";
|
||||
let first_key = format!(".upgrade-readiness/{phase}/node-0");
|
||||
@@ -571,7 +769,7 @@ mod upgrade_write_readiness_tests {
|
||||
// Metadata readiness does not prove that a data write can succeed.
|
||||
let premature = client
|
||||
.put_object()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.bucket(bucket)
|
||||
.key(&first_key)
|
||||
.body(ByteStream::from_static(b"upgrade write readiness"))
|
||||
.send()
|
||||
@@ -579,15 +777,15 @@ mod upgrade_write_readiness_tests {
|
||||
.expect_err("metadata readiness does not prove write readiness");
|
||||
assert_eq!(premature.raw_response().map(|response| response.status().as_u16()), Some(503));
|
||||
|
||||
wait_for_upgrade_write_readiness(&[client.clone(), client.clone()], phase, Duration::from_secs(5)).await?;
|
||||
wait_for_upgrade_write_readiness(&[client.clone(), client.clone()], bucket, phase, Duration::from_secs(5)).await?;
|
||||
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &first_key), 3);
|
||||
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &second_key), 2);
|
||||
assert!(target.has_object(MIXED_BUCKET, &first_key));
|
||||
assert!(target.has_object(MIXED_BUCKET, &second_key));
|
||||
assert!(target.has_object(bucket, &first_key));
|
||||
assert!(target.has_object(bucket, &second_key));
|
||||
assert!(
|
||||
client
|
||||
.list_objects_v2()
|
||||
.bucket(MIXED_BUCKET)
|
||||
.bucket(bucket)
|
||||
.prefix(format!("{phase}/"))
|
||||
.send()
|
||||
.await?
|
||||
@@ -617,9 +815,10 @@ mod upgrade_write_readiness_tests {
|
||||
let phase = format!("permanent-{}", status.as_u16());
|
||||
let key = format!(".upgrade-readiness/{phase}/node-0");
|
||||
target.inject_for_key(FakeTargetOperation::PutObject, &key, FaultAction::Status(status), 1);
|
||||
let error = wait_for_upgrade_write_readiness(std::slice::from_ref(&client), &phase, Duration::from_secs(5))
|
||||
.await
|
||||
.expect_err("a permanent error must not be retried into success");
|
||||
let error =
|
||||
wait_for_upgrade_write_readiness(std::slice::from_ref(&client), MIXED_BUCKET, &phase, Duration::from_secs(5))
|
||||
.await
|
||||
.expect_err("a permanent error must not be retried into success");
|
||||
assert!(error.to_string().contains("node 0 write readiness failed"), "{error}");
|
||||
assert_eq!(target.count_requests(FakeTargetOperation::PutObject, &key), 1);
|
||||
assert!(!target.has_object(MIXED_BUCKET, &key));
|
||||
@@ -642,7 +841,7 @@ mod upgrade_write_readiness_tests {
|
||||
FaultAction::Status(StatusCode::SERVICE_UNAVAILABLE),
|
||||
10,
|
||||
);
|
||||
let error = wait_for_upgrade_write_readiness(&[client], phase, Duration::from_secs(1))
|
||||
let error = wait_for_upgrade_write_readiness(&[client], MIXED_BUCKET, phase, Duration::from_secs(1))
|
||||
.await
|
||||
.expect_err("persistent unavailability must exhaust the shared deadline");
|
||||
// Transport scheduling consumes the same budget; do not require a
|
||||
@@ -661,10 +860,134 @@ mod upgrade_write_readiness_tests {
|
||||
let phase = "stalled";
|
||||
let key = format!(".upgrade-readiness/{phase}/node-0");
|
||||
target.inject_for_key(FakeTargetOperation::PutObject, &key, FaultAction::Stall(Duration::from_secs(30)), 1);
|
||||
let error = wait_for_upgrade_write_readiness(&[client], phase, Duration::from_secs(1))
|
||||
let error = wait_for_upgrade_write_readiness(std::slice::from_ref(&client), MIXED_BUCKET, phase, Duration::from_secs(1))
|
||||
.await
|
||||
.expect_err("a stalled request must not outlive the readiness deadline");
|
||||
assert!(error.to_string().contains("deadline exceeded during PutObject"), "{error}");
|
||||
|
||||
let part = vec![2_u8; 64 * 1024];
|
||||
let successful_key = "stalled/multipart/02/00";
|
||||
mixed_multipart_before(
|
||||
Instant::now() + MIXED_MULTIPART_UPLOAD_TIMEOUT,
|
||||
2,
|
||||
successful_key,
|
||||
write_multipart(&client, MIXED_BUCKET, successful_key, std::slice::from_ref(&part)),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(read_object(&client, MIXED_BUCKET, successful_key, None).await?.1, part);
|
||||
|
||||
let stalled_key = "stalled/multipart/02/01";
|
||||
target.inject_for_key(
|
||||
FakeTargetOperation::CompleteMultipartUpload,
|
||||
stalled_key,
|
||||
FaultAction::Stall(Duration::from_secs(30)),
|
||||
1,
|
||||
);
|
||||
let error = {
|
||||
let upload = write_multipart(&client, MIXED_BUCKET, stalled_key, std::slice::from_ref(&part));
|
||||
tokio::pin!(upload);
|
||||
tokio::select! {
|
||||
result = &mut upload => panic!("stalled completion unexpectedly returned: {result:?}"),
|
||||
observed = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while target.count_requests(FakeTargetOperation::CompleteMultipartUpload, stalled_key) == 0
|
||||
|| !target.has_object(MIXED_BUCKET, stalled_key)
|
||||
{
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}) => observed.expect("multipart completion must reach and commit on the fake peer"),
|
||||
}
|
||||
// An already committed object is not an acknowledged client success.
|
||||
// Expire only after observing the real completion request, so host
|
||||
// scheduling cannot turn this into a timeout before admission.
|
||||
tokio::time::timeout(Duration::from_secs(1), mixed_multipart_before(Instant::now(), 2, stalled_key, upload))
|
||||
.await
|
||||
.expect("expired deadline must stop the in-flight completion")
|
||||
.expect_err("unacknowledged completion must fail even if the peer committed")
|
||||
};
|
||||
assert!(error.to_string().contains("node 2"), "{error}");
|
||||
assert!(error.to_string().contains(stalled_key), "{error}");
|
||||
assert!(error.to_string().contains("acknowledgement deadline exceeded"), "{error}");
|
||||
assert_eq!(target.count_requests(FakeTargetOperation::CompleteMultipartUpload, stalled_key), 1);
|
||||
|
||||
let failure = error
|
||||
.downcast_ref::<MultipartAcknowledgementDeadline>()
|
||||
.expect("the timeout must retain typed writer/object context");
|
||||
assert_eq!(failure.node, 2);
|
||||
assert_eq!(failure.key, stalled_key);
|
||||
let original_message = error.to_string();
|
||||
// These four probe addresses share an in-process peer. This verifies
|
||||
// bounded diagnostic fan-out, not a four-node RustFS cluster.
|
||||
let mut probes = RustFSTestClusterEnvironment::new(MIXED_NODE_COUNT).await?;
|
||||
probes.access_key = FAKE_ACCESS_KEY.to_owned();
|
||||
probes.secret_key = FAKE_SECRET_KEY.to_owned();
|
||||
for node in &mut probes.nodes {
|
||||
node.url = target.endpoint().to_owned();
|
||||
}
|
||||
let log_dir = tempfile::tempdir()?;
|
||||
target.inject_for_key(
|
||||
FakeTargetOperation::HeadObject,
|
||||
stalled_key,
|
||||
FaultAction::Stall(Duration::from_secs(30)),
|
||||
1,
|
||||
);
|
||||
let error = tokio::time::timeout(
|
||||
Duration::from_secs(7),
|
||||
observe_mixed_multipart_failure(&probes, phase, error, Some(log_dir.path())),
|
||||
)
|
||||
.await
|
||||
.expect("the shared diagnostic budget must include artifact writing")
|
||||
.expect_err("observing committed data cannot acknowledge the stalled upload");
|
||||
assert_eq!(error.to_string(), original_message);
|
||||
assert!(error.downcast_ref::<MultipartAcknowledgementDeadline>().is_some());
|
||||
let snapshot: Value =
|
||||
serde_json::from_slice(&tokio::fs::read(log_dir.path().join("mixed-multipart-failure.json")).await?)?;
|
||||
assert_eq!(snapshot["key"], stalled_key);
|
||||
assert_eq!(snapshot["writer_node"], 2);
|
||||
let heads = snapshot["heads"]
|
||||
.as_array()
|
||||
.expect("failure artifact must retain every probe");
|
||||
assert_eq!(heads.len(), MIXED_NODE_COUNT);
|
||||
assert_eq!(
|
||||
heads
|
||||
.iter()
|
||||
.filter(|head| head["timeout"] == true && head["observation"] == "unknown")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
heads
|
||||
.iter()
|
||||
.filter(|head| head["status"] == 200 && head["observation"] == "visible")
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
assert_eq!(target.count_requests(FakeTargetOperation::HeadObject, stalled_key), MIXED_NODE_COUNT);
|
||||
assert_eq!(target.count_requests(FakeTargetOperation::CompleteMultipartUpload, stalled_key), 1);
|
||||
assert_eq!(snapshot["top_locks"]["observation"], "unknown", "the fake peer has no admin lock API");
|
||||
|
||||
let locks: MixedLockSnapshot = serde_json::from_value(serde_json::json!({
|
||||
"total": 1, "truncated": true, "capability_note": "partial snapshot",
|
||||
"credentials": { "secret_key": "must not persist" },
|
||||
"locks": [{
|
||||
"resource": format!("{MIXED_BUCKET}/{stalled_key}"), "owner": "writer", "type": "WRITE",
|
||||
"elapsed_secs": 30, "ttl_secs": 1, "merged_config": { "secret_key": "must not persist" },
|
||||
}],
|
||||
}))?;
|
||||
let safe_locks = serde_json::to_value(locks)?;
|
||||
assert_eq!(safe_locks["total"], 1);
|
||||
assert_eq!(safe_locks["truncated"], true);
|
||||
assert_eq!(safe_locks["locks"][0]["owner"], "writer");
|
||||
assert_eq!(safe_locks["locks"][0]["elapsed_secs"], 30);
|
||||
assert!(safe_locks.get("credentials").is_none());
|
||||
assert!(safe_locks["locks"][0].get("merged_config").is_none());
|
||||
|
||||
let mut body = br#"{"locks":[]}"#.to_vec();
|
||||
body.resize(1024 * 1024, b' ');
|
||||
let mut at_limit = reqwest::Response::from(http::Response::new(body.clone()));
|
||||
assert!(read_mixed_lock_snapshot(&mut at_limit).await?.locks.is_empty());
|
||||
body.push(b' ');
|
||||
let mut oversized = reqwest::Response::from(http::Response::new(body));
|
||||
assert_eq!(read_mixed_lock_snapshot(&mut oversized).await.err(), Some("body_limit_exceeded"));
|
||||
target.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -935,6 +1258,11 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
|
||||
cluster.start_node_from_binary(3, ¤t_binary).await?;
|
||||
|
||||
for (node_idx, client) in cluster.create_all_clients()?.iter().enumerate() {
|
||||
assert_eq!(
|
||||
read_object(client, MIXED_BUCKET, PREVIOUS_RELEASE_SEED_KEY, None).await?.1,
|
||||
UPGRADE_READINESS_BODY,
|
||||
"current node {node_idx} must retain the previous-release seed"
|
||||
);
|
||||
for phase in ["one-current-node", "one-previous-node"] {
|
||||
wait_for_phase_listing(
|
||||
client,
|
||||
@@ -962,7 +1290,8 @@ async fn rolling_upgrade_from_rc2_preserves_mixed_version_contracts() -> TestRes
|
||||
"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"
|
||||
"all nodes list every mixed-version object after homogeneous-current convergence",
|
||||
"the previous-release seed survives the baseline restart and every upgrade phase"
|
||||
],
|
||||
}),
|
||||
)?;
|
||||
@@ -1067,12 +1396,23 @@ async fn put_object_through_quota_warmup(client: &Client, bucket: &str, key: &st
|
||||
|
||||
async fn get_bucket_quota(env: &RustFSTestEnvironment, bucket: &str) -> Result<Option<u64>, BoxError> {
|
||||
let path = format!("/rustfs/admin/v3/quota/{bucket}");
|
||||
let (status, response) = admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key).await?;
|
||||
if status != StatusCode::OK {
|
||||
return Err(format!("reading the quota of {bucket} failed: {status} {response}").into());
|
||||
let deadline = Instant::now() + QUOTA_USAGE_READINESS_TIMEOUT;
|
||||
loop {
|
||||
let (status, response) = tokio::time::timeout_at(
|
||||
deadline,
|
||||
admin_request(&env.url, Method::GET, &path, None, &env.access_key, &env.secret_key),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| format!("reading the quota of {bucket} exceeded the usage-readiness timeout"))??;
|
||||
if status == StatusCode::OK {
|
||||
let quota: serde_json::Value = serde_json::from_str(&response)?;
|
||||
return Ok(quota.get("quota").and_then(serde_json::Value::as_u64));
|
||||
}
|
||||
if status != StatusCode::SERVICE_UNAVAILABLE || Instant::now() >= deadline {
|
||||
return Err(format!("reading the quota of {bucket} failed: {status} {response}").into());
|
||||
}
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
let quota: serde_json::Value = serde_json::from_str(&response)?;
|
||||
Ok(quota.get("quota").and_then(serde_json::Value::as_u64))
|
||||
}
|
||||
|
||||
/// `GET /rustfs/admin/v3/list-remote-targets?bucket=...`.
|
||||
@@ -2157,9 +2497,9 @@ struct LayoutTransport {
|
||||
uploaded_parts: Vec<i32>,
|
||||
single_puts: usize,
|
||||
completes: usize,
|
||||
/// Raw per-key journal in target order: (sequence, operation, part number,
|
||||
/// upload id), so duplicate drives can be told apart from retries.
|
||||
journal: Vec<(u64, String, Option<i32>, Option<String>)>,
|
||||
/// Allowlisted request identity and prepared response metadata. A prepared
|
||||
/// response does not prove that the client received it.
|
||||
journal: Vec<Value>,
|
||||
}
|
||||
|
||||
/// Configure every layout bucket to replicate its existing objects to a fresh
|
||||
@@ -2221,12 +2561,28 @@ async fn replicate_layouts(
|
||||
journal: key_requests
|
||||
.iter()
|
||||
.map(|record| {
|
||||
(
|
||||
record.sequence,
|
||||
format!("{:?}", record.operation),
|
||||
record.part_number,
|
||||
record.upload_id.as_ref().map(|id| id.chars().take(12).collect()),
|
||||
)
|
||||
serde_json::json!({
|
||||
"sequence": record.sequence,
|
||||
"operation": format!("{:?}", record.operation),
|
||||
"part_number": record.part_number,
|
||||
"upload_id": record.upload_id,
|
||||
"requested_version_id": record.version_id,
|
||||
"source_version_id": record.source_version_id,
|
||||
"source_mtime": record.source_mtime,
|
||||
"source_etag": record.source_etag,
|
||||
"source_replication_request": record.source_replication_request,
|
||||
"request_content_length": record.content_length,
|
||||
"journaled_at_unix_millis": record.journaled_at_unix_millis,
|
||||
"prepared_response": record.prepared_response.as_ref().map(|response| serde_json::json!({
|
||||
"status": response.status,
|
||||
"version_id": response.version_id,
|
||||
"etag": response.etag,
|
||||
"last_modified": response.last_modified,
|
||||
"content_length": response.content_length,
|
||||
"prepared_at_unix_millis": response.prepared_at_unix_millis,
|
||||
"elapsed_secs": response.elapsed.as_secs_f64(),
|
||||
})),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
@@ -176,7 +176,7 @@ regex = { workspace = true }
|
||||
path-absolutize = { workspace = true }
|
||||
rmp.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
tokio-util = { workspace = true, features = ["io", "compat"] }
|
||||
tokio-util = { workspace = true, features = ["io", "compat", "rt"] }
|
||||
tokio-stream = { workspace = true, features = ["sync"] }
|
||||
hmac = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -208,7 +208,6 @@ aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a",
|
||||
urlencoding = { workspace = true }
|
||||
smallvec = { workspace = true, features = ["serde"] }
|
||||
shadow-rs = { workspace = true, default-features = false }
|
||||
async-recursion.workspace = true
|
||||
aws-credential-types = { workspace = true }
|
||||
aws-smithy-types = { workspace = true }
|
||||
aws-smithy-runtime-api = { workspace = true, features = ["http-1x"] }
|
||||
|
||||
@@ -307,6 +307,32 @@ fn bench_memory_patterns(c: &mut Criterion) {
|
||||
group.sample_size(10);
|
||||
group.measurement_time(Duration::from_secs(5));
|
||||
|
||||
// The reader owns an EC-sized buffer before encoding. Keep ingest outside
|
||||
// the timed region to isolate the borrowed-copy versus ownership transfer.
|
||||
let erasure = Erasure::new(data_shards, parity_shards, block_size);
|
||||
let payload = generate_test_data(block_size);
|
||||
for owned in [false, true] {
|
||||
group.bench_function(if owned { "owned_ingest" } else { "borrowed_ingest" }, |b| {
|
||||
b.iter_batched(
|
||||
|| {
|
||||
let mut buffer = bytes::BytesMut::with_capacity(erasure.shard_size() * (data_shards + parity_shards));
|
||||
buffer.extend_from_slice(&payload);
|
||||
buffer
|
||||
},
|
||||
|buffer| {
|
||||
let shards = if owned {
|
||||
erasure.encode_data_bytes_mut(buffer, block_size)
|
||||
} else {
|
||||
erasure.encode_data(&buffer)
|
||||
}
|
||||
.expect("encode benchmark block");
|
||||
black_box(shards)
|
||||
},
|
||||
criterion::BatchSize::PerIteration,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Test reusing the same Erasure instance
|
||||
group.bench_function("reuse_erasure_instance", |b| {
|
||||
let erasure = Erasure::new(data_shards, parity_shards, block_size);
|
||||
|
||||
@@ -240,7 +240,10 @@ pub mod bucket {
|
||||
}
|
||||
|
||||
pub mod quota {
|
||||
pub use crate::bucket::quota::{BucketQuota, QuotaCheckResult, QuotaError, QuotaOperation};
|
||||
pub use crate::bucket::quota::{
|
||||
BucketQuota, QUOTA_RESERVATION_PROTOCOL_V1, QUOTA_RESERVATION_PROTOCOL_V2, QuotaCheckResult, QuotaError,
|
||||
QuotaOperation,
|
||||
};
|
||||
|
||||
pub mod checker {
|
||||
pub use crate::bucket::quota::checker::QuotaChecker;
|
||||
@@ -362,10 +365,11 @@ pub mod config {
|
||||
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_config, delete_config_no_lock,
|
||||
is_server_config_corrupt_error, lookup_configs, read_config, read_config_no_lock, read_config_with_metadata,
|
||||
read_config_without_migrate, read_config_without_migrate_no_lock, read_existing_server_config_no_lock,
|
||||
read_server_config_snapshot, save_config, save_config_no_lock, save_config_with_opts, save_server_config,
|
||||
save_server_config_no_lock, save_server_config_snapshot, save_server_config_snapshot_with_generation,
|
||||
server_config_path, try_migrate_server_config, with_config_object_read_lock, with_config_object_write_lock,
|
||||
with_server_config_read_lock, with_server_config_write_lock,
|
||||
read_server_config_snapshot, save_config, save_config_no_lock, save_config_with_opts, save_config_with_opts_quiet,
|
||||
save_server_config, save_server_config_no_lock, save_server_config_snapshot,
|
||||
save_server_config_snapshot_with_generation, server_config_path, try_migrate_server_config,
|
||||
with_config_object_read_lock, with_config_object_write_lock, with_server_config_read_lock,
|
||||
with_server_config_write_lock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,6 +399,8 @@ pub mod data_usage {
|
||||
ScannerPauseBacklogRetirementPlanner, ScannerPauseBacklogRetirementReplica,
|
||||
register_scanner_pause_backlog_retirement_planner,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::data_usage::seed_bucket_usage_memory_for_test;
|
||||
pub use crate::data_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,
|
||||
@@ -406,8 +412,7 @@ pub mod data_usage {
|
||||
remove_bucket_usage_from_backend, replace_bucket_usage_memory_from_info, store_compression_total_in_backend,
|
||||
store_data_usage_in_backend,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
pub use crate::data_usage::{get_bucket_usage_memory, seed_bucket_usage_memory_for_test};
|
||||
pub use crate::data_usage::{get_bucket_usage_memory, lookup_degraded_bucket_usage_baseline};
|
||||
}
|
||||
|
||||
pub mod disk {
|
||||
@@ -467,7 +472,7 @@ pub mod event {
|
||||
pub mod global {
|
||||
pub use crate::runtime::global::{
|
||||
set_global_endpoints, set_global_region, set_global_rustfs_port, set_object_store_resolver, shutdown_background_services,
|
||||
update_erasure_type,
|
||||
update_erasure_type, wait_for_detached_mutations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -518,7 +523,7 @@ pub mod object {
|
||||
ObjectInfo, ObjectLockConfigSnapshot, ObjectMutationHook, ObjectOptions, PutObjReader, QuotaAdmission,
|
||||
RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode, ReadEncryptionRequest,
|
||||
SCANNER_PUBLICATION_LEASE_FENCE_METADATA_KEY, ScannerPublicationCommitScope, ScannerPublicationCommitStartError,
|
||||
ScannerPublicationCommitState, ShardIntegrityWriteMode, StreamConsumer, WriteCompletion,
|
||||
ScannerPublicationCommitState, ShardIntegrityWriteMode, StreamConsumer, WriteCommitGuard, WriteCompletion,
|
||||
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
|
||||
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
};
|
||||
|
||||
@@ -87,6 +87,7 @@ use rustfs_filemeta::{
|
||||
FileInfo, FileInfoOpts, NULL_VERSION_ID, RestoreStatus, RestoreStatusOps, TRANSITION_COMPLETE, get_file_info,
|
||||
is_restored_object_on_disk,
|
||||
};
|
||||
use rustfs_lock::{NamespaceLockGuard, error::LockError};
|
||||
use rustfs_scanner_metrics::metrics::{
|
||||
IlmAction, Metrics, ScannerLifecycleExpiryStateUpdate, ScannerLifecycleTransitionStateUpdate, global_metrics,
|
||||
};
|
||||
@@ -190,6 +191,8 @@ const TIER_FREE_VERSION_RECOVERY_BASE_INTERVAL: StdDuration = StdDuration::from_
|
||||
// before another full sweep can discover work persisted by another node.
|
||||
const TIER_FREE_VERSION_RECOVERY_MAX_IDLE_INTERVAL: StdDuration = StdDuration::from_secs(10 * 60);
|
||||
const TIER_FREE_VERSION_RECOVERY_JITTER_PERCENT: u64 = 10;
|
||||
const TIER_FREE_VERSION_RECOVERY_LEADER_LOCK: &str = "tier-free-version-recovery/leader.lock";
|
||||
const TIER_FREE_VERSION_RECOVERY_LOCK_TIMEOUT: StdDuration = StdDuration::from_secs(1);
|
||||
const DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS: i64 = 5;
|
||||
const EXPIRY_WORKER_QUEUE_CAPACITY: usize = 1000;
|
||||
const DEFAULT_MANUAL_TRANSITION_JOB_RECOVERY_LIMIT: usize = 100;
|
||||
@@ -1441,13 +1444,24 @@ pub(crate) async fn enqueue_committed_free_versions(api: &ECStore, free_versions
|
||||
queued
|
||||
}
|
||||
|
||||
async fn enqueue_recovered_free_version_with_state(state: &Arc<RwLock<ExpiryState>>, oi: ObjectInfo) -> bool {
|
||||
async fn enqueue_recovered_free_version_with_state(
|
||||
state: &Arc<RwLock<ExpiryState>>,
|
||||
oi: ObjectInfo,
|
||||
cancel: &CancellationToken,
|
||||
) -> bool {
|
||||
let task = FreeVersionTask(oi);
|
||||
let hash = task.op_hash();
|
||||
let (wrkr, stats) = {
|
||||
let state = state.read().await;
|
||||
let state = select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return false,
|
||||
state = state.read() => state,
|
||||
};
|
||||
(state.get_worker_ch(hash), Arc::clone(&state.stats))
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return false;
|
||||
}
|
||||
let Some(wrkr) = wrkr else {
|
||||
stats.increment_missed_freevers_tasks();
|
||||
stats.record_scanner_expiry_state();
|
||||
@@ -1497,9 +1511,13 @@ fn set_recovered_free_version_enqueue_observer(
|
||||
RecoveredFreeVersionEnqueueObserverGuard
|
||||
}
|
||||
|
||||
pub async fn enqueue_recovered_free_version(api: &ECStore, oi: ObjectInfo) -> bool {
|
||||
pub(super) async fn enqueue_recovered_free_version_with_cancel(
|
||||
api: &ECStore,
|
||||
oi: ObjectInfo,
|
||||
cancel: &CancellationToken,
|
||||
) -> bool {
|
||||
let expiry_state = api.ctx.expiry_state();
|
||||
let queued = enqueue_recovered_free_version_with_state(&expiry_state, oi).await;
|
||||
let queued = enqueue_recovered_free_version_with_state(&expiry_state, oi, cancel).await;
|
||||
|
||||
#[cfg(test)]
|
||||
if let Some(observer) = RECOVERED_FREE_VERSION_ENQUEUE_OBSERVER
|
||||
@@ -2937,20 +2955,43 @@ fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>, started: &OnceLock<(
|
||||
|
||||
Some(tokio::spawn(async move {
|
||||
let cancel_token = api.ctx.background_cancel_token().unwrap_or_default();
|
||||
let expiry_state = api.ctx.expiry_state();
|
||||
run_tier_free_version_recovery_loop(
|
||||
let acquire_api = Arc::clone(&api);
|
||||
run_tier_free_version_recovery_coordinator(
|
||||
cancel_token,
|
||||
expiry_state,
|
||||
jitter_tier_free_version_recovery_delay,
|
||||
move |bucket_marker, object_marker, recovery_cancel| {
|
||||
tier_free_version_recovery_leader_retry_delay,
|
||||
move || {
|
||||
let api = Arc::clone(&acquire_api);
|
||||
async move {
|
||||
let lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, TIER_FREE_VERSION_RECOVERY_LEADER_LOCK)
|
||||
.await?;
|
||||
match lock.get_write_lock_quiet(TIER_FREE_VERSION_RECOVERY_LOCK_TIMEOUT).await {
|
||||
Ok(guard) => Ok(Some(guard)),
|
||||
Err(LockError::Timeout { .. }) => Ok(None),
|
||||
Err(err) => Err(err.into()),
|
||||
}
|
||||
}
|
||||
},
|
||||
move |leader_cancel| {
|
||||
let api = Arc::clone(&api);
|
||||
async move {
|
||||
recover_tier_free_versions_with_cancel(
|
||||
api,
|
||||
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
|
||||
bucket_marker,
|
||||
object_marker,
|
||||
recovery_cancel,
|
||||
run_tier_free_version_recovery_loop(
|
||||
leader_cancel,
|
||||
api.ctx.expiry_state(),
|
||||
jitter_tier_free_version_recovery_delay,
|
||||
move |bucket_marker, object_marker, recovery_cancel| {
|
||||
let api = Arc::clone(&api);
|
||||
async move {
|
||||
recover_tier_free_versions_with_cancel(
|
||||
api,
|
||||
DEFAULT_FREE_VERSION_RECOVERY_LIMIT,
|
||||
bucket_marker,
|
||||
object_marker,
|
||||
recovery_cancel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2960,6 +3001,86 @@ fn spawn_tier_free_version_recovery_once(api: Arc<ECStore>, started: &OnceLock<(
|
||||
}))
|
||||
}
|
||||
|
||||
fn tier_free_version_recovery_leader_retry_delay() -> StdDuration {
|
||||
StdDuration::from_secs(rand::rng().random_range(15..=30))
|
||||
}
|
||||
|
||||
async fn run_tier_free_version_recovery_coordinator<A, AF, R, RF>(
|
||||
cancel: CancellationToken,
|
||||
retry_delay: fn() -> StdDuration,
|
||||
mut acquire: A,
|
||||
mut recover: R,
|
||||
) where
|
||||
A: FnMut() -> AF,
|
||||
AF: Future<Output = crate::error::Result<Option<NamespaceLockGuard>>>,
|
||||
R: FnMut(CancellationToken) -> RF,
|
||||
RF: Future<Output = ()>,
|
||||
{
|
||||
loop {
|
||||
let acquired = select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return,
|
||||
result = acquire() => result,
|
||||
};
|
||||
match acquired {
|
||||
Ok(Some(mut guard)) => {
|
||||
let leader_cancel = cancel.child_token();
|
||||
// Hold ownership across every page, cooldown and idle wait. A
|
||||
// follower taking over starts a fresh sweep of persisted markers.
|
||||
let recovery = recover(leader_cancel.clone());
|
||||
tokio::pin!(recovery);
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_WORKER_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
state = "free_version_recovery_leader_acquired",
|
||||
lock_name = TIER_FREE_VERSION_RECOVERY_LEADER_LOCK,
|
||||
"Tier free-version recovery leader acquired"
|
||||
);
|
||||
select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => {
|
||||
leader_cancel.cancel();
|
||||
recovery.await;
|
||||
}
|
||||
_ = guard.lock_lost_notified() => {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_WORKER_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
state = "free_version_recovery_leader_lost",
|
||||
lock_name = TIER_FREE_VERSION_RECOVERY_LEADER_LOCK,
|
||||
"Tier free-version recovery leader lost"
|
||||
);
|
||||
// Dropping the recovery future would detach its active
|
||||
// walk. Cancel it and poll the same future through cleanup.
|
||||
leader_cancel.cancel();
|
||||
recovery.await;
|
||||
}
|
||||
_ = &mut recovery => {}
|
||||
}
|
||||
leader_cancel.cancel();
|
||||
guard.release();
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => warn!(
|
||||
event = EVENT_LIFECYCLE_WORKER_STATE,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
state = "free_version_recovery_leader_acquire_failed",
|
||||
lock_name = TIER_FREE_VERSION_RECOVERY_LEADER_LOCK,
|
||||
error = %err,
|
||||
"Tier free-version recovery leader acquisition failed"
|
||||
),
|
||||
}
|
||||
select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return,
|
||||
_ = tokio::time::sleep(retry_delay()) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_tier_free_version_recovery_loop<F, Fut>(
|
||||
cancel_token: CancellationToken,
|
||||
expiry_state: Arc<RwLock<ExpiryState>>,
|
||||
@@ -2969,7 +3090,11 @@ async fn run_tier_free_version_recovery_loop<F, Fut>(
|
||||
F: FnMut(Option<String>, Option<String>, CancellationToken) -> Fut,
|
||||
Fut: Future<Output = crate::error::Result<FreeVersionRecoveryStats>>,
|
||||
{
|
||||
let recovery_notify = Arc::clone(&expiry_state.read().await.recovery_notify);
|
||||
let recovery_notify = select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => return,
|
||||
state = expiry_state.read() => Arc::clone(&state.recovery_notify),
|
||||
};
|
||||
let mut schedule = TierFreeVersionRecoverySchedule::default();
|
||||
|
||||
loop {
|
||||
@@ -2992,7 +3117,11 @@ async fn run_tier_free_version_recovery_loop<F, Fut>(
|
||||
schedule.record_success(&stats);
|
||||
rustfs_io_metrics::record_stage_duration("lifecycle_free_version_recovery", elapsed.as_secs_f64() * 1000.0);
|
||||
let (pending_tasks, active_tasks) = {
|
||||
let state = expiry_state.read().await;
|
||||
let state = select! {
|
||||
biased;
|
||||
_ = cancel_token.cancelled() => return,
|
||||
state = expiry_state.read() => state,
|
||||
};
|
||||
(state.pending_tasks(), state.stats.active_tasks())
|
||||
};
|
||||
debug!(
|
||||
@@ -4748,7 +4877,7 @@ async fn expire_transitioned_object_with_lock_lost_signal(
|
||||
bucket_incarnation_id: Uuid,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> Result<ObjectInfo, std::io::Error> {
|
||||
let publication_guard = lifecycle_expiry_publication_guard(&api, oi, bucket_incarnation_id)
|
||||
let publication_guard = lifecycle_expiry_publication_guard(&api, &oi.bucket, &oi.name, bucket_incarnation_id)
|
||||
.await
|
||||
.ok_or_else(|| std::io::Error::other("lifecycle expiry is not allowed for this bucket"))?;
|
||||
let snapshot = lifecycle_delete_config_snapshot(&api, oi)
|
||||
@@ -4838,10 +4967,14 @@ pub fn gen_transition_objname(bucket: &str) -> Result<String, Error> {
|
||||
pub async fn transition_object(api: Arc<ECStore>, oi: &ObjectInfo, lae: LcAuditEvent) -> Result<(), Error> {
|
||||
let time_ilm = Metrics::time_ilm(lae.event.action);
|
||||
|
||||
let (publication_guard, bucket_incarnation_id) = lifecycle_transition_publication_guard(&api, oi)
|
||||
.await
|
||||
.ok_or_else(|| Error::other("lifecycle transition is not allowed for this bucket"))?;
|
||||
|
||||
let etag = if let Some(etag) = &oi.etag { etag } else { "" };
|
||||
let etag = etag.to_string();
|
||||
|
||||
let opts = ObjectOptions {
|
||||
let mut opts = ObjectOptions {
|
||||
transition: TransitionOptions {
|
||||
status: lifecycle::TRANSITION_PENDING.to_string(),
|
||||
tier: lae.event.storage_class,
|
||||
@@ -4853,8 +4986,10 @@ pub async fn transition_object(api: Arc<ECStore>, oi: &ObjectInfo, lae: LcAuditE
|
||||
versioned: BucketVersioningSys::prefix_enabled(&oi.bucket, &oi.name).await,
|
||||
version_suspended: BucketVersioningSys::prefix_suspended(&oi.bucket, &oi.name).await,
|
||||
mod_time: oi.mod_time,
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
opts.add_namespace_lock_guard(&publication_guard);
|
||||
let result = api.transition_object(&oi.bucket, &oi.name, &opts).await;
|
||||
time_ilm(1)();
|
||||
result
|
||||
@@ -5458,20 +5593,26 @@ pub async fn apply_transition_rule(event: &lifecycle::Event, src: &LcEventSrc, o
|
||||
.await
|
||||
}
|
||||
|
||||
async fn lifecycle_expiry_publication_guard(
|
||||
async fn acquire_lifecycle_publication_guard(api: &ECStore, bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard, Error> {
|
||||
let lock = api
|
||||
.new_ns_lock(bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await?;
|
||||
let guard = lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(Error::other)?;
|
||||
if guard.is_lock_lost() {
|
||||
return Err(Error::other("table-bucket publication lock was lost before lifecycle admission"));
|
||||
}
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
pub(crate) async fn lifecycle_expiry_publication_guard(
|
||||
api: &ECStore,
|
||||
oi: &ObjectInfo,
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> Option<rustfs_lock::NamespaceLockGuard> {
|
||||
let result = async {
|
||||
let lock = api
|
||||
.new_ns_lock(&oi.bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await?;
|
||||
let guard = lock.get_read_lock(get_lock_acquire_timeout()).await.map_err(Error::other)?;
|
||||
if guard.is_lock_lost() {
|
||||
return Err(Error::other("table-bucket publication lock was lost before lifecycle delete admission"));
|
||||
}
|
||||
if !metadata_boundary::lifecycle_expiry_allowed(api, &oi.bucket, bucket_incarnation_id).await? {
|
||||
let result: std::result::Result<Option<rustfs_lock::NamespaceLockGuard>, Error> = async {
|
||||
let guard = acquire_lifecycle_publication_guard(api, bucket).await?;
|
||||
if !metadata_boundary::lifecycle_expiry_allowed(api, bucket, bucket_incarnation_id).await? {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(guard))
|
||||
@@ -5484,8 +5625,8 @@ async fn lifecycle_expiry_publication_guard(
|
||||
event = EVENT_LIFECYCLE_DELETE_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
bucket,
|
||||
object,
|
||||
operation = "authorize_lifecycle_expiry",
|
||||
error = %err,
|
||||
"Lifecycle delete admission failed"
|
||||
@@ -5495,6 +5636,34 @@ async fn lifecycle_expiry_publication_guard(
|
||||
}
|
||||
}
|
||||
|
||||
async fn lifecycle_transition_publication_guard(
|
||||
api: &ECStore,
|
||||
oi: &ObjectInfo,
|
||||
) -> Option<(rustfs_lock::NamespaceLockGuard, Uuid)> {
|
||||
let result: std::result::Result<(rustfs_lock::NamespaceLockGuard, Uuid), Error> = async {
|
||||
let guard = acquire_lifecycle_publication_guard(api, &oi.bucket).await?;
|
||||
let configs = metadata_boundary::get_expiry_configs(api, &oi.bucket).await?;
|
||||
Ok((guard, configs.bucket_incarnation_id))
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(state) => Some(state),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event = EVENT_LIFECYCLE_TIER_OPERATION_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket = %oi.bucket,
|
||||
object = %oi.name,
|
||||
operation = "authorize_lifecycle_transition",
|
||||
error = %err,
|
||||
"Lifecycle transition admission failed"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_expiry_on_transitioned_object(
|
||||
api: Arc<ECStore>,
|
||||
oi: &ObjectInfo,
|
||||
@@ -5551,7 +5720,8 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
bucket_incarnation_id: Uuid,
|
||||
lock_lost_signal: Option<Arc<rustfs_lock::distributed_lock::LockLostSignal>>,
|
||||
) -> bool {
|
||||
let Some(publication_guard) = lifecycle_expiry_publication_guard(&api, oi, bucket_incarnation_id).await else {
|
||||
let Some(publication_guard) = lifecycle_expiry_publication_guard(&api, &oi.bucket, &oi.name, bucket_incarnation_id).await
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let snapshot = match lifecycle_delete_config_snapshot(&api, oi).await {
|
||||
@@ -5606,8 +5776,15 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
|
||||
//debug!("lc_event.action: {:?}", lc_event.action);
|
||||
debug!("expiry_on_non_transitioned_objects opts: {:?}", opts);
|
||||
let usage_accounting = if lc_event.action == IlmAction::DeleteAction && !versioned && !version_suspended && !oi.is_dir {
|
||||
Some(crate::data_usage::begin_expiry_usage_accounting(&oi.bucket).await)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// The delete owns the publication guard from here on, so a cancelled
|
||||
// expiry worker cannot let table-bucket publication overtake it.
|
||||
let mut dobj = match api
|
||||
.delete_object_with_tier_delete_journal(&oi.bucket, &encode_dir_object(&oi.name), opts)
|
||||
.delete_object_with_tier_delete_journal_and_guards(&oi.bucket, &encode_dir_object(&oi.name), opts, [publication_guard])
|
||||
.await
|
||||
{
|
||||
Ok(dobj) => dobj,
|
||||
@@ -5625,6 +5802,14 @@ async fn apply_expiry_on_non_transitioned_objects_with_lock_lost_signal(
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if let Some(accounting) = usage_accounting
|
||||
&& !dobj.name.is_empty()
|
||||
&& !dobj.delete_marker
|
||||
&& !dobj.is_dir
|
||||
&& let Ok(size) = crate::data_usage::quota_object_size(&dobj)
|
||||
{
|
||||
accounting.commit(size).await;
|
||||
}
|
||||
schedule_lifecycle_replication_delete_if_needed(oi, &dobj).await;
|
||||
|
||||
// The object (or all its versions, for delete_all) was expired; evict any
|
||||
@@ -5831,6 +6016,7 @@ pub async fn apply_lifecycle_action(event: &lifecycle::Event, src: &LcEventSrc,
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::expiry_worker_count;
|
||||
use super::lifecycle_transition_publication_guard;
|
||||
use super::{
|
||||
DATE_EXPIRY_EXISTING_OBJECTS_GRACE_SECS, DEFAULT_TRANSITION_QUEUE_CAPACITY, DEFAULT_TRANSITION_WORKERS_ABSOLUTE_MAX,
|
||||
DEFAULT_TRANSITION_WORKERS_CAP, EVENT_LIFECYCLE_EVALUATION_FAILED, EVENT_LIFECYCLE_EXPIRED_DETECTED,
|
||||
@@ -5849,10 +6035,10 @@ mod tests {
|
||||
merge_stale_multipart_candidate, persist_manual_transition_job_progress_if_owned,
|
||||
persist_manual_transition_page_checkpoint, recover_manual_transition_job, recover_manual_transition_jobs,
|
||||
resolve_tier_free_version_recovery_enabled, resolve_transition_queue_capacity, resolve_transition_queue_send_timeout,
|
||||
resolve_transition_worker_count, resolve_transition_workers_absolute_max, run_tier_free_version_recovery_loop,
|
||||
select_restore_s3_location, set_lifecycle_observability_observer, set_recovered_free_version_enqueue_observer,
|
||||
should_defer_date_expiry_for_recent_config_update, transitioned_cleanup_tuple, transitioned_object_delete_opts,
|
||||
wait_for_tier_free_version_recovery,
|
||||
resolve_transition_worker_count, resolve_transition_workers_absolute_max, run_tier_free_version_recovery_coordinator,
|
||||
run_tier_free_version_recovery_loop, select_restore_s3_location, set_lifecycle_observability_observer,
|
||||
set_recovered_free_version_enqueue_observer, should_defer_date_expiry_for_recent_config_update,
|
||||
transitioned_cleanup_tuple, transitioned_object_delete_opts, wait_for_tier_free_version_recovery,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
use super::{delete_free_version_remote_object_then, encode_dir_object, get_transitioned_object_reader_with_tier_manager};
|
||||
@@ -5946,6 +6132,231 @@ mod tests {
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn recovery_test_lock() -> (Arc<rustfs_lock::NamespaceLock>, Vec<Arc<dyn rustfs_lock::LockClient>>) {
|
||||
let clients: Vec<Arc<dyn rustfs_lock::LockClient>> = (0..3)
|
||||
.map(|_| {
|
||||
Arc::new(rustfs_lock::LocalClient::with_manager(Arc::new(rustfs_lock::GlobalLockManager::new())))
|
||||
as Arc<dyn rustfs_lock::LockClient>
|
||||
})
|
||||
.collect();
|
||||
let lock =
|
||||
rustfs_lock::NamespaceLock::with_clients_and_quorum(format!("recovery-test-{}", Uuid::new_v4()), clients.clone(), 2);
|
||||
(Arc::new(lock), clients)
|
||||
}
|
||||
|
||||
async fn acquire_recovery_test_lock(
|
||||
lock: Arc<rustfs_lock::NamespaceLock>,
|
||||
owner: String,
|
||||
) -> crate::error::Result<Option<rustfs_lock::NamespaceLockGuard>> {
|
||||
// Keep this lease longer than the virtual-time cooldown/idle advances;
|
||||
// production uses the namespace wrapper's renewable 30-second lease.
|
||||
lock.lock_guard(
|
||||
rustfs_lock::ObjectKey::new(crate::disk::RUSTFS_META_BUCKET, super::TIER_FREE_VERSION_RECOVERY_LEADER_LOCK),
|
||||
&owner,
|
||||
StdDuration::from_millis(5),
|
||||
StdDuration::from_secs(3600),
|
||||
)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn recovery_test_retry_delay() -> StdDuration {
|
||||
StdDuration::from_secs(15)
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_eight_coordinators_hold_one_leader_and_take_over() {
|
||||
let (lock, _clients) = recovery_test_lock();
|
||||
let (pages_tx, mut pages_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut tasks = Vec::new();
|
||||
let mut cancels = Vec::new();
|
||||
for node in 0..8 {
|
||||
let cancel = CancellationToken::new();
|
||||
cancels.push(cancel.clone());
|
||||
let lock = Arc::clone(&lock);
|
||||
let pages_tx = pages_tx.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
run_tier_free_version_recovery_coordinator(
|
||||
cancel,
|
||||
recovery_test_retry_delay,
|
||||
move || acquire_recovery_test_lock(Arc::clone(&lock), format!("node-{node}")),
|
||||
move |leader_cancel| {
|
||||
let pages_tx = pages_tx.clone();
|
||||
async move {
|
||||
let mut pages = 0;
|
||||
run_tier_free_version_recovery_loop(
|
||||
leader_cancel,
|
||||
ExpiryState::new(),
|
||||
std::convert::identity,
|
||||
move |bucket, object, _| {
|
||||
pages += 1;
|
||||
pages_tx.send((node, bucket, object)).expect("page observer should be open");
|
||||
std::future::ready(Ok(free_version_recovery_stats(0, 0, pages == 1)))
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}));
|
||||
}
|
||||
let (leader, bucket, object) = pages_rx.recv().await.expect("one coordinator should start recovery");
|
||||
assert!(bucket.is_none() && object.is_none());
|
||||
// The same leader must survive both a truncated page's cooldown and
|
||||
// complete idle sweeps. All other coordinators keep probing the lock.
|
||||
let mut observed_pages = 1;
|
||||
for _ in 0..27 {
|
||||
tokio::time::advance(StdDuration::from_secs(60)).await;
|
||||
for _ in 0..100 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
while let Ok((node, bucket, object)) = pages_rx.try_recv() {
|
||||
assert_eq!(node, leader, "followers must not repeat a namespace sweep");
|
||||
if observed_pages == 1 {
|
||||
assert_eq!(bucket.as_deref(), Some("bucket"));
|
||||
assert_eq!(object.as_deref(), Some("object"));
|
||||
} else {
|
||||
assert!(bucket.is_none() && object.is_none(), "complete sweeps should reset their cursors");
|
||||
}
|
||||
observed_pages += 1;
|
||||
}
|
||||
}
|
||||
assert!(observed_pages >= 7, "the leader should finish a full ten-minute idle wait");
|
||||
cancels[leader].cancel();
|
||||
let (successor, bucket, object) = tokio::time::timeout(StdDuration::from_secs(60), pages_rx.recv())
|
||||
.await
|
||||
.expect("a follower should take over after leader shutdown")
|
||||
.expect("page observer should remain open");
|
||||
assert_ne!(successor, leader);
|
||||
assert!(bucket.is_none() && object.is_none(), "a new leader must restart discovery safely");
|
||||
for cancel in cancels {
|
||||
cancel.cancel();
|
||||
}
|
||||
for task in tasks {
|
||||
task.await.expect("all coordinators should shut down cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
#[serial]
|
||||
async fn tier_free_version_recovery_lock_loss_waits_for_active_page_cleanup() {
|
||||
let (lock, _clients) = recovery_test_lock();
|
||||
let cancel = CancellationToken::new();
|
||||
let loop_cancel = cancel.clone();
|
||||
let (page_tx, mut page_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let cleanup_started = Arc::new(tokio::sync::Notify::new());
|
||||
let finish_cleanup = Arc::new(tokio::sync::Notify::new());
|
||||
let observed_cleanup = Arc::clone(&cleanup_started);
|
||||
let release_cleanup = Arc::clone(&finish_cleanup);
|
||||
let task = tokio::spawn(async move {
|
||||
run_tier_free_version_recovery_coordinator(
|
||||
loop_cancel,
|
||||
recovery_test_retry_delay,
|
||||
move || acquire_recovery_test_lock(Arc::clone(&lock), "old-leader".to_string()),
|
||||
move |leader_cancel| {
|
||||
let page_tx = page_tx.clone();
|
||||
let cleanup_started = Arc::clone(&cleanup_started);
|
||||
let finish_cleanup = Arc::clone(&finish_cleanup);
|
||||
async move {
|
||||
run_tier_free_version_recovery_loop(
|
||||
leader_cancel,
|
||||
ExpiryState::new(),
|
||||
std::convert::identity,
|
||||
move |_, _, page_cancel| {
|
||||
let cleanup_started = Arc::clone(&cleanup_started);
|
||||
let finish_cleanup = Arc::clone(&finish_cleanup);
|
||||
page_tx.send(()).expect("page observer should remain open");
|
||||
async move {
|
||||
page_cancel.cancelled().await;
|
||||
cleanup_started.notify_one();
|
||||
finish_cleanup.notified().await;
|
||||
Err(std::io::Error::new(std::io::ErrorKind::Interrupted, "cancelled page").into())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
});
|
||||
page_rx.recv().await.expect("leader should start an active page");
|
||||
// Let authoritative backend leases expire before the next heartbeat
|
||||
// can renew them. A failed refresh must cancel the active page.
|
||||
tokio::time::advance(StdDuration::from_secs(3600)).await;
|
||||
tokio::time::timeout(StdDuration::from_secs(1), observed_cleanup.notified())
|
||||
.await
|
||||
.expect("expired backend leases must trigger recovery cancellation");
|
||||
assert!(!task.is_finished(), "lock loss must wait for the active page's cleanup");
|
||||
assert!(page_rx.try_recv().is_err(), "the old leader must not start another page");
|
||||
cancel.cancel();
|
||||
release_cleanup.notify_one();
|
||||
task.await.expect("coordinator should finish cleanup before exiting");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn tier_free_version_recovery_coordinator_retries_without_scanning_after_lock_error() {
|
||||
let cancel = CancellationToken::new();
|
||||
let loop_cancel = cancel.clone();
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let observed_attempts = Arc::clone(&attempts);
|
||||
let task = tokio::spawn(async move {
|
||||
run_tier_free_version_recovery_coordinator(
|
||||
loop_cancel,
|
||||
recovery_test_retry_delay,
|
||||
move || {
|
||||
observed_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(Err(rustfs_lock::LockError::InsufficientNodes {
|
||||
required: 2,
|
||||
available: 1,
|
||||
}
|
||||
.into()))
|
||||
},
|
||||
|_| async { panic!("a node without ownership must never start discovery") },
|
||||
)
|
||||
.await;
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
tokio::time::advance(StdDuration::from_secs(14)).await;
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1, "lock errors must not cause a tight retry loop");
|
||||
tokio::time::advance(StdDuration::from_secs(1)).await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
cancel.cancel();
|
||||
task.await.expect("a follower should respond to shutdown during retry wait");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tier_free_version_recovery_enqueue_cancels_while_waiting_for_state_lock() {
|
||||
let state = ExpiryState::new();
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
|
||||
let mut held = state.write().await;
|
||||
held.tasks_tx.push(tx);
|
||||
let cancel = CancellationToken::new();
|
||||
let enqueue_cancel = cancel.clone();
|
||||
let enqueue_state = Arc::clone(&state);
|
||||
let enqueue = tokio::spawn(async move {
|
||||
enqueue_recovered_free_version_with_state(&enqueue_state, ObjectInfo::default(), &enqueue_cancel).await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert!(!enqueue.is_finished(), "enqueue should be blocked on the state lock");
|
||||
cancel.cancel();
|
||||
let queued = tokio::time::timeout(StdDuration::from_secs(1), enqueue)
|
||||
.await
|
||||
.expect("cancellation must not wait for the state write guard")
|
||||
.expect("enqueue should return cleanly");
|
||||
assert!(!queued);
|
||||
assert_eq!(held.pending_tasks(), 0);
|
||||
assert_eq!(held.stats.missed_free_vers_tasks(), 0);
|
||||
assert!(rx.try_recv().is_err(), "a cancelled recovery must not enqueue a task");
|
||||
drop(held);
|
||||
assert!(!enqueue_recovered_free_version_with_state(&state, ObjectInfo::default(), &cancel).await);
|
||||
assert!(rx.try_recv().is_err(), "cancellation must win even when the lock is available");
|
||||
}
|
||||
|
||||
fn free_version_recovery_stats(enqueued: usize, failed: usize, truncated: bool) -> FreeVersionRecoveryStats {
|
||||
FreeVersionRecoveryStats {
|
||||
scanned: enqueued.saturating_add(failed),
|
||||
@@ -7625,7 +8036,7 @@ mod tests {
|
||||
assert_eq!(state.read().await.stats.pending_tasks(), 1);
|
||||
|
||||
// The single-slot queue is full for both enqueue paths.
|
||||
assert!(!enqueue_recovered_free_version_with_state(&state, oi.clone()).await);
|
||||
assert!(!enqueue_recovered_free_version_with_state(&state, oi.clone(), &CancellationToken::new()).await);
|
||||
assert_eq!(state.read().await.stats.pending_tasks(), 1);
|
||||
assert_eq!(state.read().await.stats.missed_free_vers_tasks(), 1);
|
||||
|
||||
@@ -7657,7 +8068,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let queued = enqueue_recovered_free_version_with_state(&state, oi).await;
|
||||
let queued = enqueue_recovered_free_version_with_state(&state, oi, &CancellationToken::new()).await;
|
||||
let state = state.read().await;
|
||||
|
||||
assert!(!queued);
|
||||
@@ -7786,8 +8197,8 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let first = enqueue_recovered_free_version_with_state(&state, oi.clone()).await;
|
||||
let second = enqueue_recovered_free_version_with_state(&state, oi).await;
|
||||
let first = enqueue_recovered_free_version_with_state(&state, oi.clone(), &CancellationToken::new()).await;
|
||||
let second = enqueue_recovered_free_version_with_state(&state, oi, &CancellationToken::new()).await;
|
||||
let state = state.read().await;
|
||||
|
||||
assert!(first);
|
||||
@@ -7888,7 +8299,7 @@ mod tests {
|
||||
};
|
||||
|
||||
assert!(
|
||||
super::enqueue_recovered_free_version(&ecstore, oi).await,
|
||||
super::enqueue_recovered_free_version_with_cancel(&ecstore, oi, &CancellationToken::new()).await,
|
||||
"the resized production worker queue should accept the task"
|
||||
);
|
||||
stop_tx.send(None).await.expect("worker stop signal should be delivered");
|
||||
@@ -12891,6 +13302,295 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn ordinary_expiry_accounts_the_committed_receipt_once() {
|
||||
use crate::bucket::quota::{QuotaOperation, checker::QuotaChecker};
|
||||
use crate::data_usage::{apply_bucket_usage_memory_overlay, replace_bucket_usage_memory_from_info};
|
||||
use rustfs_data_usage::{BucketUsageInfo, DataUsageInfo};
|
||||
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
for size in [0usize, 42] {
|
||||
let bytes = u64::try_from(size).expect("fixture size fits");
|
||||
let bucket = format!("expiry-accounting-{}", Uuid::new_v4().simple());
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
let mut reader = PutObjReader::from_vec(vec![0; size]);
|
||||
let mut queued = ecstore
|
||||
.put_object(&bucket, "expire", &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create expiring object");
|
||||
let mut reader = PutObjReader::from_vec(vec![0; size]);
|
||||
ecstore
|
||||
.put_object(&bucket, "keep", &mut reader, &ObjectOptions::default())
|
||||
.await
|
||||
.expect("create retained object");
|
||||
let mut baseline = DataUsageInfo {
|
||||
last_update: Some(std::time::SystemTime::now()),
|
||||
usage_snapshot_complete: true,
|
||||
buckets_count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
baseline.buckets_usage.insert(
|
||||
bucket.clone(),
|
||||
BucketUsageInfo {
|
||||
objects_count: 2,
|
||||
versions_count: 2,
|
||||
size: bytes * 2,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
baseline.bucket_sizes.insert(bucket.clone(), bytes * 2);
|
||||
baseline.calculate_totals();
|
||||
replace_bucket_usage_memory_from_info(&baseline).await;
|
||||
// Legacy quota admission consumes the same conservative overlay.
|
||||
// Durable reservations have their own storage-commit accounting.
|
||||
metadata_sys::update(
|
||||
&bucket,
|
||||
rustfs_config::QUOTA_CONFIG_FILE,
|
||||
format!(r#"{{"quota":{},"quota_type":"Hard"}}"#, bytes * 2).into_bytes(),
|
||||
)
|
||||
.await
|
||||
.expect("configure the legacy quota");
|
||||
let checker =
|
||||
QuotaChecker::new(Arc::new(tokio::sync::RwLock::new(metadata_sys::BucketMetadataSys::new(ecstore.clone()))));
|
||||
let attempted_size = bytes.max(1);
|
||||
let quota = checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, attempted_size)
|
||||
.await
|
||||
.expect("check quota before expiration");
|
||||
assert!(!quota.allowed);
|
||||
assert!(!quota.uses_durable_reservations);
|
||||
assert_eq!(quota.current_usage, Some(bytes * 2));
|
||||
// Queued metadata is not the accounting receipt from the storage commit.
|
||||
queued.size = 1;
|
||||
let event = lifecycle::Event {
|
||||
action: IlmAction::DeleteAction,
|
||||
..Default::default()
|
||||
};
|
||||
let incarnation = ecstore
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("bucket incarnation");
|
||||
for (attempt_incarnation, expected) in [(Uuid::new_v4(), false), (incarnation, true), (incarnation, false)] {
|
||||
assert_eq!(
|
||||
super::apply_expiry_on_non_transitioned_objects(
|
||||
ecstore.clone(),
|
||||
&queued,
|
||||
&event,
|
||||
&LcEventSrc::Scanner,
|
||||
attempt_incarnation,
|
||||
)
|
||||
.await,
|
||||
expected
|
||||
);
|
||||
let mut response = baseline.clone();
|
||||
apply_bucket_usage_memory_overlay(&mut response).await;
|
||||
let remaining = if attempt_incarnation == incarnation { 1 } else { 2 };
|
||||
assert_eq!(response.buckets_usage[&bucket].objects_count, remaining);
|
||||
assert_eq!(response.buckets_usage[&bucket].size, remaining * bytes);
|
||||
let quota = checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, attempted_size)
|
||||
.await
|
||||
.expect("check quota before scanner confirmation");
|
||||
assert!(!quota.allowed, "expiry must not release unconfirmed quota credit");
|
||||
assert_eq!(quota.current_usage, Some(bytes * 2));
|
||||
}
|
||||
let mut confirmed = baseline.clone();
|
||||
confirmed.last_update = Some(std::time::SystemTime::now());
|
||||
confirmed.scanner_epoch = Some(7);
|
||||
confirmed.scanner_cycle = Some(10);
|
||||
let usage = confirmed.buckets_usage.get_mut(&bucket).expect("bucket is in the snapshot");
|
||||
usage.size = bytes;
|
||||
usage.objects_count = 1;
|
||||
usage.versions_count = 0; // The scanner does not count unversioned objects as versions.
|
||||
confirmed.bucket_sizes.insert(bucket.clone(), bytes);
|
||||
confirmed.calculate_totals();
|
||||
replace_bucket_usage_memory_from_info(&confirmed).await;
|
||||
let quota = checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, attempted_size)
|
||||
.await
|
||||
.expect("check quota after scanner confirmation");
|
||||
assert_eq!(quota.allowed, bytes > 0);
|
||||
assert_eq!(quota.current_usage, Some(bytes));
|
||||
let too_large = checker
|
||||
.check_quota(&bucket, QuotaOperation::PutObject, bytes + 1)
|
||||
.await
|
||||
.expect("check the first byte above the quota limit");
|
||||
assert!(!too_large.allowed);
|
||||
assert!(
|
||||
ecstore
|
||||
.get_object_info(&bucket, "keep", &ObjectOptions::default())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn queued_noncurrent_batch_expiry_does_not_delete_from_table_bucket() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("table-noncurrent-lifecycle-{}", Uuid::new_v4().simple());
|
||||
let object = "tables/table-id/data/part-00001.parquet";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
metadata_sys::update_in(
|
||||
&ecstore.ctx,
|
||||
&bucket,
|
||||
BUCKET_VERSIONING_CONFIG,
|
||||
b"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>".to_vec(),
|
||||
)
|
||||
.await
|
||||
.expect("bucket versioning should be enabled");
|
||||
|
||||
let mut old_reader = PutObjReader::from_vec(b"old table data".to_vec());
|
||||
let old = ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut old_reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the historical table version should be created");
|
||||
let old_version_id = old.version_id.expect("the historical version should have an identity");
|
||||
let mut current_reader = PutObjReader::from_vec(b"current table data".to_vec());
|
||||
ecstore
|
||||
.put_object(
|
||||
&bucket,
|
||||
object,
|
||||
&mut current_reader,
|
||||
&ObjectOptions {
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("the current table version should be created");
|
||||
|
||||
let incarnation = ecstore
|
||||
.bucket_incarnation_id_from_disk(&bucket)
|
||||
.await
|
||||
.expect("bucket incarnation should be available");
|
||||
let publication_lock = ecstore
|
||||
.new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await
|
||||
.expect("table-bucket publication lock should be created");
|
||||
let enable_guard = publication_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("table-bucket enablement should acquire the publication lock");
|
||||
|
||||
let delete_store = ecstore.clone();
|
||||
let delete_bucket = bucket.clone();
|
||||
let delete_object = object.to_string();
|
||||
let mut delete = tokio::spawn(async move {
|
||||
let target = ObjectToDelete {
|
||||
object_name: delete_object,
|
||||
version_id: Some(old_version_id),
|
||||
..Default::default()
|
||||
};
|
||||
crate::bucket::lifecycle::object_handlers_common::delete_object_versions(
|
||||
&delete_store,
|
||||
&delete_bucket,
|
||||
std::slice::from_ref(&target),
|
||||
lifecycle::Event::default(),
|
||||
incarnation,
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
assert!(
|
||||
tokio::time::timeout(StdDuration::from_millis(100), &mut delete)
|
||||
.await
|
||||
.is_err(),
|
||||
"noncurrent lifecycle cleanup must wait for the table-bucket publication fence"
|
||||
);
|
||||
|
||||
let sys = metadata_sys::bucket_metadata_sys_of(&ecstore.ctx).expect("metadata system should be initialized");
|
||||
let sys = sys.read().await.clone();
|
||||
let mut metadata = (*sys.get(&bucket).await.expect("bucket metadata should exist")).clone();
|
||||
metadata.table_bucket_config_json = br#"{"enabled":true}"#.to_vec();
|
||||
sys.persist_and_set(metadata)
|
||||
.await
|
||||
.expect("table bucket marker should be persisted");
|
||||
sys.reload_from_store(&bucket)
|
||||
.await
|
||||
.expect("table bucket marker should become authoritative");
|
||||
drop(enable_guard);
|
||||
|
||||
let failed = tokio::time::timeout(StdDuration::from_secs(2), delete)
|
||||
.await
|
||||
.expect("noncurrent lifecycle cleanup should resume after the fence is released")
|
||||
.expect("noncurrent lifecycle cleanup task should join");
|
||||
assert_eq!(failed, 1, "table-bucket noncurrent cleanup must be rejected");
|
||||
assert!(
|
||||
ecstore
|
||||
.get_object_info(
|
||||
&bucket,
|
||||
object,
|
||||
&ObjectOptions {
|
||||
version_id: Some(old_version_id.to_string()),
|
||||
versioned: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_ok(),
|
||||
"table data must remain after queued noncurrent cleanup is rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn lifecycle_transition_admission_holds_publication_reader() {
|
||||
let (_disk_paths, ecstore) = setup_test_env().await;
|
||||
let bucket = format!("lifecycle-transition-{}", Uuid::new_v4().simple());
|
||||
let object = "tables/table-id/data/part-00001.parquet";
|
||||
create_test_bucket(&ecstore, &bucket).await;
|
||||
let object_info = ObjectInfo {
|
||||
bucket: bucket.clone(),
|
||||
name: object.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let publication_lock = ecstore
|
||||
.new_ns_lock(&bucket, rustfs_common::table_catalog::TABLE_BUCKET_PUBLICATION_LOCK_PATH)
|
||||
.await
|
||||
.expect("table-bucket publication lock should be created");
|
||||
let enable_guard = publication_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("publication writer should be acquired");
|
||||
let admission_store = ecstore.clone();
|
||||
let admission_object = object_info.clone();
|
||||
let mut admission =
|
||||
tokio::spawn(async move { lifecycle_transition_publication_guard(&admission_store, &admission_object).await });
|
||||
assert!(
|
||||
tokio::time::timeout(StdDuration::from_millis(100), &mut admission)
|
||||
.await
|
||||
.is_err(),
|
||||
"transition admission must wait for an in-progress table publication"
|
||||
);
|
||||
drop(enable_guard);
|
||||
|
||||
let (transition_guard, incarnation) = tokio::time::timeout(StdDuration::from_secs(2), admission)
|
||||
.await
|
||||
.expect("transition admission should resume after publication")
|
||||
.expect("transition admission task should join")
|
||||
.expect("transition admission should succeed");
|
||||
assert_ne!(incarnation, Uuid::nil(), "transition admission must bind a bucket incarnation");
|
||||
let blocked_writer = publication_lock.get_write_lock(StdDuration::from_millis(100)).await;
|
||||
assert!(blocked_writer.is_err(), "transition admission must retain the publication read lock");
|
||||
drop(transition_guard);
|
||||
publication_lock
|
||||
.get_write_lock(get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("publication writer should proceed after transition admission releases its guard");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn queued_delete_all_rechecks_a_same_id_rule_moved_into_the_future() {
|
||||
@@ -13552,13 +14252,14 @@ mod tests {
|
||||
};
|
||||
let mut recovery_rx = recovery_rx.lock().await;
|
||||
assert!(
|
||||
super::enqueue_recovered_free_version(
|
||||
super::enqueue_recovered_free_version_with_cancel(
|
||||
&ecstore,
|
||||
ObjectInfo {
|
||||
bucket: "prefill".to_string(),
|
||||
name: "prefill".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&CancellationToken::new(),
|
||||
)
|
||||
.await,
|
||||
"the production recovery queue should accept its first task"
|
||||
|
||||
@@ -36,6 +36,10 @@ pub async fn delete_object_versions(
|
||||
_lc_event: lifecycle::Event,
|
||||
bucket_incarnation_id: Uuid,
|
||||
) -> usize {
|
||||
if to_del.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if to_del.iter().any(|target| {
|
||||
target.version_id.is_none()
|
||||
|| (target.version_id.is_some_and(|version_id| version_id.is_nil()) && target.expected_identity.is_none())
|
||||
@@ -52,6 +56,25 @@ pub async fn delete_object_versions(
|
||||
return to_del.len();
|
||||
}
|
||||
|
||||
let Some(publication_guard) = super::bucket_lifecycle_ops::lifecycle_expiry_publication_guard(
|
||||
api,
|
||||
bucket,
|
||||
to_del[0].object_name.as_str(),
|
||||
bucket_incarnation_id,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
debug!(
|
||||
event = EVENT_LIFECYCLE_CLEANUP_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_LIFECYCLE,
|
||||
bucket,
|
||||
target_count = to_del.len(),
|
||||
reason = "publication_admission_rejected",
|
||||
"Skipped lifecycle noncurrent version cleanup"
|
||||
);
|
||||
return to_del.len();
|
||||
};
|
||||
let delete_config_snapshot = match ReplicationObjectBridge::delete_request_config(api, bucket).await {
|
||||
Ok(snapshot) => Arc::new(snapshot),
|
||||
Err(err) => {
|
||||
@@ -78,17 +101,13 @@ pub async fn delete_object_versions(
|
||||
remaining = &[];
|
||||
}
|
||||
|
||||
let (mut deleted_objs, errors) = api
|
||||
.delete_objects(
|
||||
bucket,
|
||||
to_del.to_vec(),
|
||||
ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let mut delete_options = ObjectOptions {
|
||||
delete_replication_config_snapshot: Some(Arc::clone(&delete_config_snapshot)),
|
||||
expected_bucket_incarnation_id: Some(bucket_incarnation_id),
|
||||
..Default::default()
|
||||
};
|
||||
delete_options.add_namespace_lock_guard(&publication_guard);
|
||||
let (mut deleted_objs, errors) = api.delete_objects(bucket, to_del.to_vec(), delete_options).await;
|
||||
failed += errors.iter().filter(|err| err.is_some()).count();
|
||||
|
||||
for (i, deleted_obj) in deleted_objs.iter_mut().enumerate() {
|
||||
|
||||
@@ -1122,9 +1122,11 @@ pub(crate) async fn resume_recovery_disposition(
|
||||
// Lock order is control record, then legacy source. Disposition revisions
|
||||
// use ETag CAS and never acquire either namespace lock internally.
|
||||
let control_lock = api.new_ns_lock(RUSTFS_META_BUCKET, &control_object).await?;
|
||||
let control_guard = control_lock
|
||||
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await?;
|
||||
let control_guard = Arc::new(
|
||||
control_lock
|
||||
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await?,
|
||||
);
|
||||
let source_lock = api
|
||||
.new_ns_lock(RUSTFS_META_BUCKET, &initial.disposition.identity.canonical_source_path)
|
||||
.await?;
|
||||
@@ -1504,7 +1506,7 @@ fn control_is_abandoned_successor(disposition: &IlmRecoveryDisposition, control:
|
||||
async fn abandon_recovery_control_fenced(
|
||||
api: Arc<ECStore>,
|
||||
disposition: &IlmRecoveryDisposition,
|
||||
control_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
control_guard: &Arc<rustfs_lock::NamespaceLockGuard>,
|
||||
source_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
proof: &IlmRecoveryExportFleetProofToken,
|
||||
) -> EcstoreResult<()> {
|
||||
@@ -1557,7 +1559,7 @@ async fn abandon_recovery_control_fenced(
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
options.add_namespace_lock_guard(control_guard);
|
||||
options.add_owned_write_lock(Arc::clone(control_guard), RUSTFS_META_BUCKET, &object);
|
||||
options.add_namespace_lock_guard(source_guard);
|
||||
let write_result = config_boundary::save_config_with_opts(api.clone(), &object, encoded, &options).await;
|
||||
let observed = match load_recovery_control_no_lock(api.clone(), disposition).await {
|
||||
@@ -1630,7 +1632,7 @@ async fn delete_exact_local_recovery_copy(
|
||||
api: Arc<ECStore>,
|
||||
copy: &IlmRecoverySourceCopy,
|
||||
source_schema: &str,
|
||||
control_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
control_guard: &Arc<rustfs_lock::NamespaceLockGuard>,
|
||||
source_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
) -> EcstoreResult<()> {
|
||||
let set = recovery_copy_set(&api, ©.authority)?;
|
||||
@@ -1704,7 +1706,7 @@ async fn save_recovery_disposition_step_fenced(
|
||||
api: Arc<ECStore>,
|
||||
current: &ObservedIlmRecoveryDisposition,
|
||||
next: &IlmRecoveryDisposition,
|
||||
control_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
control_guard: &Arc<rustfs_lock::NamespaceLockGuard>,
|
||||
source_guard: &rustfs_lock::NamespaceLockGuard,
|
||||
proof: &IlmRecoveryExportFleetProofToken,
|
||||
) -> EcstoreResult<ObservedIlmRecoveryDisposition> {
|
||||
|
||||
@@ -20,7 +20,7 @@ use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version;
|
||||
use crate::bucket::lifecycle::bucket_lifecycle_ops::enqueue_recovered_free_version_with_cancel;
|
||||
use crate::disk::RUSTFS_META_BUCKET;
|
||||
use crate::error::Result;
|
||||
use crate::object_api::ObjectInfo;
|
||||
@@ -201,7 +201,11 @@ pub(super) async fn recover_tier_free_versions_with_cancel(
|
||||
return Err(tier_free_version_recovery_cancelled());
|
||||
}
|
||||
retry_cursor.visit(&oi);
|
||||
if !record_recovered_free_version_enqueue(&mut stats, enqueue_recovered_free_version(&api, oi).await) {
|
||||
let queued = enqueue_recovered_free_version_with_cancel(&api, oi, &cancel_token).await;
|
||||
if cancel_token.is_cancelled() {
|
||||
return Err(tier_free_version_recovery_cancelled());
|
||||
}
|
||||
if !record_recovered_free_version_enqueue(&mut stats, queued) {
|
||||
let (bucket_marker, object_marker) = retry_cursor.retry_markers();
|
||||
stats.truncated = true;
|
||||
stats.next_bucket_marker = bucket_marker;
|
||||
|
||||
@@ -1210,6 +1210,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.notification_config = None;
|
||||
if !self.notification_config_xml.is_empty()
|
||||
&& let Err(e) = deserialize::<NotificationConfiguration>(&self.notification_config_xml)
|
||||
.map(|c| self.notification_config = Some(c))
|
||||
@@ -1224,6 +1225,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.lifecycle_config = None;
|
||||
if !self.lifecycle_config_xml.is_empty()
|
||||
&& let Err(e) =
|
||||
deserialize::<BucketLifecycleConfiguration>(&self.lifecycle_config_xml).map(|c| self.lifecycle_config = Some(c))
|
||||
@@ -1238,6 +1240,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.object_lock_config = None;
|
||||
if !self.object_lock_config_xml.is_empty()
|
||||
&& let Err(e) =
|
||||
deserialize::<ObjectLockConfiguration>(&self.object_lock_config_xml).map(|c| self.object_lock_config = Some(c))
|
||||
@@ -1267,6 +1270,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.sse_config = None;
|
||||
if !self.encryption_config_xml.is_empty()
|
||||
&& let Err(e) =
|
||||
deserialize::<ServerSideEncryptionConfiguration>(&self.encryption_config_xml).map(|c| self.sse_config = Some(c))
|
||||
@@ -1281,6 +1285,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.tagging_config = None;
|
||||
if !self.tagging_config_xml.is_empty()
|
||||
&& let Err(e) = deserialize::<Tagging>(&self.tagging_config_xml).map(|c| self.tagging_config = Some(c))
|
||||
{
|
||||
@@ -1346,6 +1351,7 @@ impl BucketMetadata {
|
||||
} else {
|
||||
self.bucket_target_config = Some(BucketTargets::default());
|
||||
}
|
||||
self.cors_config = None;
|
||||
if !self.cors_config_xml.is_empty()
|
||||
&& let Err(e) = deserialize::<CORSConfiguration>(&self.cors_config_xml).map(|c| self.cors_config = Some(c))
|
||||
{
|
||||
@@ -1359,6 +1365,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.logging_config = None;
|
||||
if !self.logging_config_xml.is_empty()
|
||||
&& let Err(e) = deserialize::<BucketLoggingStatus>(&self.logging_config_xml).map(|c| self.logging_config = Some(c))
|
||||
{
|
||||
@@ -1372,6 +1379,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.website_config = None;
|
||||
if !self.website_config_xml.is_empty()
|
||||
&& let Err(e) = deserialize::<WebsiteConfiguration>(&self.website_config_xml).map(|c| self.website_config = Some(c))
|
||||
{
|
||||
@@ -1385,6 +1393,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.accelerate_config = None;
|
||||
if !self.accelerate_config_xml.is_empty()
|
||||
&& let Err(e) =
|
||||
deserialize::<AccelerateConfiguration>(&self.accelerate_config_xml).map(|c| self.accelerate_config = Some(c))
|
||||
@@ -1399,6 +1408,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.request_payment_config = None;
|
||||
if !self.request_payment_config_xml.is_empty()
|
||||
&& let Err(e) = deserialize::<RequestPaymentConfiguration>(&self.request_payment_config_xml)
|
||||
.map(|c| self.request_payment_config = Some(c))
|
||||
@@ -1413,6 +1423,7 @@ impl BucketMetadata {
|
||||
"Failed to parse bucket metadata config"
|
||||
);
|
||||
}
|
||||
self.public_access_block_config = None;
|
||||
if !self.public_access_block_config_xml.is_empty()
|
||||
&& let Err(e) = deserialize::<PublicAccessBlockConfiguration>(&self.public_access_block_config_xml)
|
||||
.map(|c| self.public_access_block_config = Some(c))
|
||||
@@ -1973,6 +1984,156 @@ mod test {
|
||||
assert_eq!(bm.lifecycle_config_xml, malformed_xml, "raw bytes are retained");
|
||||
}
|
||||
|
||||
const REPARSE_XML_FIXTURES: [(&str, &[u8]); 13] = [
|
||||
(BUCKET_NOTIFICATION_CONFIG, br#"<NotificationConfiguration/>"#),
|
||||
(
|
||||
BUCKET_LIFECYCLE_CONFIG,
|
||||
br#"<LifecycleConfiguration><Rule><ID>keep</ID><Status>Enabled</Status><Expiration><Days>30</Days></Expiration></Rule></LifecycleConfiguration>"#,
|
||||
),
|
||||
(
|
||||
OBJECT_LOCK_CONFIG,
|
||||
br#"<ObjectLockConfiguration><ObjectLockEnabled>Enabled</ObjectLockEnabled><Rule><DefaultRetention><Mode>GOVERNANCE</Mode><Days>7</Days></DefaultRetention></Rule></ObjectLockConfiguration>"#,
|
||||
),
|
||||
(BUCKET_VERSIONING_CONFIG, br#"<VersioningConfiguration><Status>Enabled</Status></VersioningConfiguration>"#),
|
||||
(
|
||||
BUCKET_SSECONFIG,
|
||||
br#"<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>"#,
|
||||
),
|
||||
(BUCKET_TAGGING_CONFIG, br#"<Tagging><TagSet><Tag><Key>team</Key><Value>blue</Value></Tag></TagSet></Tagging>"#),
|
||||
(
|
||||
BUCKET_REPLICATION_CONFIG,
|
||||
br#"<ReplicationConfiguration><Role>arn:aws:iam::123456789012:role/replication-role</Role><Rule><ID>copy</ID><Status>Enabled</Status><Prefix>documents/</Prefix><Destination><Bucket>arn:aws:s3:::destination-bucket</Bucket></Destination></Rule></ReplicationConfiguration>"#,
|
||||
),
|
||||
(
|
||||
BUCKET_CORS_CONFIG,
|
||||
br#"<CORSConfiguration><CORSRule><AllowedOrigin>https://example.test</AllowedOrigin><AllowedMethod>GET</AllowedMethod></CORSRule></CORSConfiguration>"#,
|
||||
),
|
||||
(BUCKET_LOGGING_CONFIG, br#"<BucketLoggingStatus/>"#),
|
||||
(BUCKET_WEBSITE_CONFIG, br#"<WebsiteConfiguration><IndexDocument><Suffix>index.html</Suffix></IndexDocument></WebsiteConfiguration>"#),
|
||||
(BUCKET_ACCELERATE_CONFIG, br#"<AccelerateConfiguration><Status>Enabled</Status></AccelerateConfiguration>"#),
|
||||
(BUCKET_REQUEST_PAYMENT_CONFIG, br#"<RequestPaymentConfiguration><Payer>BucketOwner</Payer></RequestPaymentConfiguration>"#),
|
||||
(
|
||||
BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG,
|
||||
br#"<PublicAccessBlockConfiguration><BlockPublicAcls>true</BlockPublicAcls><IgnorePublicAcls>true</IgnorePublicAcls><BlockPublicPolicy>true</BlockPublicPolicy><RestrictPublicBuckets>true</RestrictPublicBuckets></PublicAccessBlockConfiguration>"#,
|
||||
),
|
||||
];
|
||||
|
||||
fn reparse_xml_state(bm: &BucketMetadata, config: &str) -> &'static str {
|
||||
fn state<T>(raw: &[u8], parsed: &Option<T>) -> &'static str {
|
||||
match ConfigState::of(raw, parsed) {
|
||||
ConfigState::Absent => "absent",
|
||||
ConfigState::Valid(_) => "valid",
|
||||
ConfigState::Unreadable { .. } => "unreadable",
|
||||
}
|
||||
}
|
||||
match config {
|
||||
BUCKET_NOTIFICATION_CONFIG => state(&bm.notification_config_xml, &bm.notification_config),
|
||||
BUCKET_LIFECYCLE_CONFIG => state(&bm.lifecycle_config_xml, &bm.lifecycle_config),
|
||||
OBJECT_LOCK_CONFIG => state(&bm.object_lock_config_xml, &bm.object_lock_config),
|
||||
BUCKET_VERSIONING_CONFIG => state(&bm.versioning_config_xml, &bm.versioning_config),
|
||||
BUCKET_SSECONFIG => state(&bm.encryption_config_xml, &bm.sse_config),
|
||||
BUCKET_TAGGING_CONFIG => state(&bm.tagging_config_xml, &bm.tagging_config),
|
||||
BUCKET_REPLICATION_CONFIG => state(&bm.replication_config_xml, &bm.replication_config),
|
||||
BUCKET_CORS_CONFIG => state(&bm.cors_config_xml, &bm.cors_config),
|
||||
BUCKET_LOGGING_CONFIG => state(&bm.logging_config_xml, &bm.logging_config),
|
||||
BUCKET_WEBSITE_CONFIG => state(&bm.website_config_xml, &bm.website_config),
|
||||
BUCKET_ACCELERATE_CONFIG => state(&bm.accelerate_config_xml, &bm.accelerate_config),
|
||||
BUCKET_REQUEST_PAYMENT_CONFIG => state(&bm.request_payment_config_xml, &bm.request_payment_config),
|
||||
BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG => state(&bm.public_access_block_config_xml, &bm.public_access_block_config),
|
||||
_ => panic!("unknown XML config: {config}"),
|
||||
}
|
||||
}
|
||||
|
||||
// A persisted payload can change without going through the request decoder.
|
||||
fn replace_xml_raw(bm: &mut BucketMetadata, config: &str, raw: Vec<u8>) {
|
||||
match config {
|
||||
BUCKET_NOTIFICATION_CONFIG => bm.notification_config_xml = raw,
|
||||
BUCKET_LIFECYCLE_CONFIG => bm.lifecycle_config_xml = raw,
|
||||
OBJECT_LOCK_CONFIG => bm.object_lock_config_xml = raw,
|
||||
BUCKET_VERSIONING_CONFIG => bm.versioning_config_xml = raw,
|
||||
BUCKET_SSECONFIG => bm.encryption_config_xml = raw,
|
||||
BUCKET_TAGGING_CONFIG => bm.tagging_config_xml = raw,
|
||||
BUCKET_REPLICATION_CONFIG => bm.replication_config_xml = raw,
|
||||
BUCKET_CORS_CONFIG => bm.cors_config_xml = raw,
|
||||
BUCKET_LOGGING_CONFIG => bm.logging_config_xml = raw,
|
||||
BUCKET_WEBSITE_CONFIG => bm.website_config_xml = raw,
|
||||
BUCKET_ACCELERATE_CONFIG => bm.accelerate_config_xml = raw,
|
||||
BUCKET_REQUEST_PAYMENT_CONFIG => bm.request_payment_config_xml = raw,
|
||||
BUCKET_PUBLIC_ACCESS_BLOCK_CONFIG => bm.public_access_block_config_xml = raw,
|
||||
_ => panic!("unknown XML config: {config}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparse_xml_rejects_stale_views_after_corruption() {
|
||||
let corrupt = b"<not-a-valid-document";
|
||||
for (config, valid) in REPARSE_XML_FIXTURES {
|
||||
let mut bm = BucketMetadata::new("reparse-corrupt");
|
||||
bm.update_config(config, valid.to_vec()).expect("store valid XML");
|
||||
bm.parse_all_configs().expect("parse valid XML");
|
||||
assert_eq!(reparse_xml_state(&bm, config), "valid", "{config}: valid setup");
|
||||
|
||||
replace_xml_raw(&mut bm, config, corrupt.to_vec());
|
||||
let before = bm.marshal_msg().expect("marshal corrupt raw metadata");
|
||||
bm.parse_all_configs()
|
||||
.expect("one corrupt config must not fail the metadata load");
|
||||
|
||||
assert_eq!(reparse_xml_state(&bm, config), "unreadable", "{config}: stale view accepted");
|
||||
assert_eq!(bm.xml_config_unreadable_len(config), Some(corrupt.len()), "{config}: missing refusal");
|
||||
assert_eq!(
|
||||
bm.marshal_msg().expect("marshal reparsed metadata"),
|
||||
before,
|
||||
"{config}: persisted bytes changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparse_xml_clears_stale_views_after_raw_is_removed() {
|
||||
for (config, valid) in REPARSE_XML_FIXTURES {
|
||||
let mut bm = BucketMetadata::new("reparse-removed");
|
||||
bm.update_config(config, valid.to_vec()).expect("store valid XML");
|
||||
bm.parse_all_configs().expect("parse valid XML");
|
||||
assert_eq!(reparse_xml_state(&bm, config), "valid", "{config}: valid setup");
|
||||
|
||||
replace_xml_raw(&mut bm, config, Vec::new());
|
||||
let before = bm.marshal_msg().expect("marshal absent raw metadata");
|
||||
bm.parse_all_configs().expect("parse removed XML");
|
||||
|
||||
assert_eq!(reparse_xml_state(&bm, config), "absent", "{config}: stale view survived removal");
|
||||
assert!(!bm.xml_config_unreadable(config), "{config}: absence is not corruption");
|
||||
assert_eq!(
|
||||
bm.marshal_msg().expect("marshal reparsed metadata"),
|
||||
before,
|
||||
"{config}: persisted bytes changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reparse_xml_recovers_after_corrupt_raw_is_repaired() {
|
||||
for (config, valid) in REPARSE_XML_FIXTURES {
|
||||
let mut bm = BucketMetadata::new("reparse-repaired");
|
||||
bm.update_config(config, valid.to_vec()).expect("store valid XML");
|
||||
bm.parse_all_configs().expect("parse valid XML");
|
||||
replace_xml_raw(&mut bm, config, b"<not-a-valid-document".to_vec());
|
||||
bm.parse_all_configs().expect("parse corrupt XML");
|
||||
assert_eq!(reparse_xml_state(&bm, config), "unreadable", "{config}: corrupt setup");
|
||||
|
||||
replace_xml_raw(&mut bm, config, valid.to_vec());
|
||||
let before = bm.marshal_msg().expect("marshal repaired raw metadata");
|
||||
bm.parse_all_configs().expect("parse repaired XML");
|
||||
|
||||
assert_eq!(reparse_xml_state(&bm, config), "valid", "{config}: repaired config must become readable");
|
||||
assert!(!bm.xml_config_unreadable(config), "{config}: repaired config is not corrupt");
|
||||
assert_eq!(
|
||||
bm.marshal_msg().expect("marshal reparsed metadata"),
|
||||
before,
|
||||
"{config}: persisted bytes changed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_update_config_clears_parsed_config_on_delete() {
|
||||
let mut bm = BucketMetadata::new("test-bucket");
|
||||
|
||||
@@ -576,6 +576,27 @@ pub(crate) async fn update_in(
|
||||
Box::pin(update_with_sys(require_bucket_metadata_sys_in(ctx)?, bucket, config_file, data)).await
|
||||
}
|
||||
|
||||
/// Validate a caller's fence after acquiring the lifecycle and metadata
|
||||
/// transaction locks, before changing the configuration.
|
||||
pub(crate) async fn update_validated_in<F>(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
config_file: &str,
|
||||
data: Vec<u8>,
|
||||
validate: F,
|
||||
) -> Result<OffsetDateTime>
|
||||
where
|
||||
F: FnOnce() -> Result<()> + Send,
|
||||
{
|
||||
Box::pin(async {
|
||||
let sys = require_bucket_metadata_sys_in(ctx)?;
|
||||
let guard = acquire_config_write_guard(sys.clone(), bucket).await?;
|
||||
validate()?;
|
||||
update_under_config_write_guard(sys, &guard, config_file, data, None).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete(bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
||||
delete_with_sys(get_bucket_metadata_sys()?, bucket, config_file).await
|
||||
}
|
||||
@@ -1132,7 +1153,7 @@ async fn acquire_transaction_lock_with_sys(
|
||||
/// agree on, so renaming it would leave a mixed-version cluster with two
|
||||
/// disjoint keys — and old and new nodes would stop excluding each other on
|
||||
/// the very writes that are serialized today.
|
||||
fn bucket_metadata_transaction_lock_key(bucket: &str) -> String {
|
||||
pub(crate) fn bucket_metadata_transaction_lock_key(bucket: &str) -> String {
|
||||
format!("bucket-targets/{bucket}/transaction.lock")
|
||||
}
|
||||
|
||||
@@ -1331,10 +1352,54 @@ pub(crate) async fn get_quota_config_and_incarnation_from_disk_in(
|
||||
let bucket_meta_sys_lock = bucket_metadata_sys_of(ctx)?;
|
||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await.clone();
|
||||
|
||||
match bucket_meta_sys
|
||||
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
|
||||
.await?
|
||||
quota_config_and_incarnation_from_authority(
|
||||
bucket,
|
||||
bucket_meta_sys
|
||||
.read_authoritative_metadata_from_disk_under_transaction_lock(bucket)
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn get_quota_config_and_incarnation_from_disk_for_options_in(
|
||||
ctx: &crate::runtime::instance::InstanceContext,
|
||||
bucket: &str,
|
||||
opts: &ObjectOptions,
|
||||
transaction_guard: &Arc<rustfs_lock::NamespaceLockGuard>,
|
||||
) -> Result<(Option<BucketQuota>, Uuid, OffsetDateTime)> {
|
||||
let Some(snapshot) = opts.object_lock_config_snapshot.as_ref() else {
|
||||
return get_quota_config_and_incarnation_from_disk_in(ctx, bucket).await;
|
||||
};
|
||||
let sys = bucket_metadata_sys_of(ctx)?.read().await.clone();
|
||||
let store = sys.object_store();
|
||||
let snapshot_guard = snapshot
|
||||
.metadata_transaction_guard_for(store.id, bucket, opts.expected_bucket_incarnation_id)
|
||||
.filter(|guard| Arc::ptr_eq(guard, transaction_guard))
|
||||
.ok_or_else(|| Error::other("quota snapshot does not hold this bucket's metadata transaction fence"))?;
|
||||
if snapshot_guard.is_released() {
|
||||
return Err(Error::other("quota snapshot metadata transaction fence was released"));
|
||||
}
|
||||
|
||||
// The snapshot carries the caller's lifecycle fence, excluding deletion.
|
||||
// Its transaction guard excludes config writes, so a same-name object
|
||||
// owner need not recursively acquire the bucket namespace here.
|
||||
let authority = sys
|
||||
.read_authoritative_metadata_from_disk_under_guard(bucket, transaction_guard)
|
||||
.await?;
|
||||
if snapshot_guard.is_lock_lost()
|
||||
|| snapshot_guard.is_released()
|
||||
|| matches!(&authority, BucketMetadataAuthority::Authoritative(metadata)
|
||||
if !snapshot.is_valid_for_destructive_put(store.id, bucket, metadata.bucket_incarnation_id))
|
||||
{
|
||||
return Err(Error::other("quota snapshot fence or bucket incarnation changed during the disk read"));
|
||||
}
|
||||
quota_config_and_incarnation_from_authority(bucket, authority)
|
||||
}
|
||||
|
||||
fn quota_config_and_incarnation_from_authority(
|
||||
bucket: &str,
|
||||
authority: BucketMetadataAuthority,
|
||||
) -> Result<(Option<BucketQuota>, Uuid, OffsetDateTime)> {
|
||||
match authority {
|
||||
BucketMetadataAuthority::Authoritative(metadata)
|
||||
if metadata.bucket_incarnation_sidecar && !metadata.bucket_incarnation_id.is_nil() =>
|
||||
{
|
||||
@@ -2703,29 +2768,30 @@ impl BucketMetadataSys {
|
||||
async fn read_authoritative_metadata_from_disk_under_transaction_lock(
|
||||
&self,
|
||||
bucket: &str,
|
||||
) -> Result<BucketMetadataAuthority> {
|
||||
let namespace_lock = self.object_store().new_ns_lock(bucket, bucket).await?;
|
||||
let namespace_guard = namespace_lock
|
||||
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await?;
|
||||
self.read_authoritative_metadata_from_disk_under_guard(bucket, &namespace_guard)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn read_authoritative_metadata_from_disk_under_guard(
|
||||
&self,
|
||||
bucket: &str,
|
||||
guard: &rustfs_lock::NamespaceLockGuard,
|
||||
) -> Result<BucketMetadataAuthority> {
|
||||
#[cfg(test)]
|
||||
if self.object_lock_disk_read_errors.write().await.remove(bucket) {
|
||||
return Err(Error::other(format!("injected Object Lock metadata disk read failure: {bucket}")));
|
||||
}
|
||||
|
||||
let namespace_lock = self.object_store().new_ns_lock(bucket, bucket).await?;
|
||||
let namespace_guard = namespace_lock
|
||||
.get_read_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await?;
|
||||
match await_bucket_namespace_operation(
|
||||
Some(&namespace_guard),
|
||||
bucket,
|
||||
"bucket metadata snapshot existence check",
|
||||
async {
|
||||
self.object_store()
|
||||
.get_bucket_info_from_sets_at_read_quorum(
|
||||
bucket,
|
||||
&crate::storage_api_contracts::bucket::BucketOptions::default(),
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
match await_bucket_namespace_operation(Some(guard), bucket, "bucket metadata snapshot existence check", async {
|
||||
self.object_store()
|
||||
.get_bucket_info_from_sets_at_read_quorum(bucket, &crate::storage_api_contracts::bucket::BucketOptions::default())
|
||||
.await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(_) => {}
|
||||
@@ -2734,7 +2800,7 @@ impl BucketMetadataSys {
|
||||
}
|
||||
|
||||
let (metadata, persisted) = await_bucket_namespace_operation(
|
||||
Some(&namespace_guard),
|
||||
Some(guard),
|
||||
bucket,
|
||||
"bucket metadata authoritative snapshot",
|
||||
load_bucket_metadata_parse_with_presence(self.object_store(), bucket, true),
|
||||
@@ -4121,6 +4187,114 @@ mod tests {
|
||||
sys.persist_new_and_set(bm).await.expect("initial metadata should persist");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reparse_xml_rmw_corruption_refuses_cached_and_reloaded_encryption() {
|
||||
use crate::bucket::metadata::{BUCKET_SSECONFIG, is_unreadable_config_error};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
let bucket = "reparse-encryption-corrupt";
|
||||
let valid = br#"<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>"#;
|
||||
let corrupt = b"<ServerSideEncryptionConfiguration";
|
||||
persist_bucket_with_raw_config(&sys, &dirs, bucket, BUCKET_SSECONFIG, valid).await;
|
||||
sys.get_sse_config(bucket).await.expect("valid encryption must be readable");
|
||||
|
||||
let updated = sys
|
||||
.update_config_with(bucket, BUCKET_SSECONFIG, |bm| {
|
||||
assert!(bm.sse_config.is_some(), "rewrite must start with parsed encryption");
|
||||
Ok(corrupt.to_vec())
|
||||
})
|
||||
.await
|
||||
.expect("persist future or corrupt raw encryption without failing the whole bucket");
|
||||
|
||||
let err = sys
|
||||
.get_sse_config(bucket)
|
||||
.await
|
||||
.expect_err("cached old encryption must not hide corrupt raw");
|
||||
assert!(is_unreadable_config_error(&err), "cached refusal must retain its type: {err}");
|
||||
let (cached, _) = sys.get_config(bucket).await.expect("bucket remains readable");
|
||||
assert_eq!(cached.encryption_config_xml, corrupt);
|
||||
assert_eq!(cached.encryption_config_updated_at, updated);
|
||||
|
||||
sys.metadata_map.write().await.clear();
|
||||
let err = sys
|
||||
.get_sse_config(bucket)
|
||||
.await
|
||||
.expect_err("reloaded encryption must still refuse corrupt raw");
|
||||
assert!(is_unreadable_config_error(&err), "reloaded refusal must retain its type: {err}");
|
||||
|
||||
let calls = AtomicUsize::new(0);
|
||||
let err = sys
|
||||
.update_config_with(bucket, BUCKET_SSECONFIG, |_| {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(Vec::new())
|
||||
})
|
||||
.await
|
||||
.expect_err("another rewrite must refuse before it consumes corrupt encryption");
|
||||
assert!(is_unreadable_config_error(&err));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0, "refused rewrite must not erase the original raw");
|
||||
let disk = sys
|
||||
.get_config_from_disk(bucket)
|
||||
.await
|
||||
.expect("read unchanged metadata from disk");
|
||||
assert_eq!(disk.encryption_config_xml, corrupt);
|
||||
assert_eq!(disk.encryption_config_updated_at, updated);
|
||||
|
||||
sys.update(bucket, BUCKET_SSECONFIG, valid.to_vec())
|
||||
.await
|
||||
.expect("explicit repair must remain possible");
|
||||
sys.get_sse_config(bucket)
|
||||
.await
|
||||
.expect("repaired cached encryption must be readable");
|
||||
sys.metadata_map.write().await.clear();
|
||||
sys.get_sse_config(bucket).await.expect("repair must survive a disk reload");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reparse_xml_rmw_removal_clears_cached_and_reloaded_encryption() {
|
||||
use crate::bucket::metadata::BUCKET_SSECONFIG;
|
||||
|
||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = BucketMetadataSys::new(ecstore);
|
||||
let bucket = "reparse-encryption-removed";
|
||||
let valid = br#"<ServerSideEncryptionConfiguration><Rule><ApplyServerSideEncryptionByDefault><SSEAlgorithm>AES256</SSEAlgorithm></ApplyServerSideEncryptionByDefault></Rule></ServerSideEncryptionConfiguration>"#;
|
||||
persist_bucket_with_raw_config(&sys, &dirs, bucket, BUCKET_SSECONFIG, valid).await;
|
||||
sys.get_sse_config(bucket).await.expect("valid encryption must be readable");
|
||||
|
||||
let updated = sys
|
||||
.update_config_with(bucket, BUCKET_SSECONFIG, |bm| {
|
||||
assert!(bm.sse_config.is_some(), "rewrite must start with parsed encryption");
|
||||
Ok(Vec::new())
|
||||
})
|
||||
.await
|
||||
.expect("removing encryption must persist");
|
||||
|
||||
assert_eq!(
|
||||
sys.get_sse_config(bucket)
|
||||
.await
|
||||
.expect_err("old cached encryption must be gone"),
|
||||
Error::ConfigNotFound
|
||||
);
|
||||
let (cached, _) = sys.get_config(bucket).await.expect("bucket remains readable");
|
||||
assert!(cached.encryption_config_xml.is_empty());
|
||||
assert_eq!(cached.encryption_config_updated_at, updated);
|
||||
|
||||
sys.metadata_map.write().await.clear();
|
||||
assert_eq!(
|
||||
sys.get_sse_config(bucket)
|
||||
.await
|
||||
.expect_err("removed encryption must stay absent after reload"),
|
||||
Error::ConfigNotFound
|
||||
);
|
||||
let disk = sys
|
||||
.get_config_from_disk(bucket)
|
||||
.await
|
||||
.expect("read removed encryption from disk");
|
||||
assert!(disk.encryption_config_xml.is_empty());
|
||||
assert_eq!(disk.encryption_config_updated_at, updated);
|
||||
}
|
||||
|
||||
/// rustfs/backlog#1734: a read-modify-write of a stored config that cannot
|
||||
/// be parsed must be refused before `mutate` runs. Otherwise `mutate` sees
|
||||
/// the unreadable config as absent, rebuilds it from nothing, and the
|
||||
@@ -4736,6 +4910,231 @@ mod tests {
|
||||
.expect("DeleteBucket should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_snapshot_reuses_same_name_owner_and_rereads_disk_authority() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "quota-same-name-owner";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("quota fixture bucket");
|
||||
let snapshot = store
|
||||
.object_lock_config_snapshot(bucket)
|
||||
.await
|
||||
.expect("guarded Object Lock snapshot");
|
||||
let incarnation = store.bucket_incarnation_id(bucket).await.expect("fixture incarnation");
|
||||
let transaction_guard = snapshot
|
||||
.metadata_transaction_guard_for(store.id, bucket, Some(incarnation))
|
||||
.expect("snapshot's actual transaction guard");
|
||||
let expected = get_quota_config_and_incarnation_from_disk_for_options_in(
|
||||
&store.ctx,
|
||||
bucket,
|
||||
&ObjectOptions::default(),
|
||||
&transaction_guard,
|
||||
)
|
||||
.await
|
||||
.expect("a reader without a snapshot must retain the ordinary namespace path");
|
||||
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("fixture metadata system");
|
||||
let sys = sys.read().await.clone();
|
||||
let mut stale = (*sys.get(bucket).await.expect("cached fixture metadata")).clone();
|
||||
let metadata_path = stale.save_file_path();
|
||||
stale.quota_config = Some(BucketQuota {
|
||||
quota: Some(123),
|
||||
..Default::default()
|
||||
});
|
||||
sys.set(bucket.to_string(), Arc::new(stale)).await;
|
||||
let namespace = store.new_ns_lock(bucket, bucket).await.expect("same-name object namespace");
|
||||
let _owner = namespace
|
||||
.get_write_lock(crate::set_disk::get_lock_acquire_timeout())
|
||||
.await
|
||||
.expect("same-name object write owner");
|
||||
let opts = ObjectOptions {
|
||||
object_lock_config_snapshot: Some(snapshot),
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
get_quota_config_and_incarnation_from_disk_for_options_in(&store.ctx, bucket, &opts, &transaction_guard)
|
||||
.await
|
||||
.expect("quota disk read must reuse the held lifecycle and transaction fences"),
|
||||
expected,
|
||||
"the cached quota must not replace disk authority"
|
||||
);
|
||||
|
||||
crate::config::com::save_config_with_opts(
|
||||
store.clone(),
|
||||
&metadata_path,
|
||||
b"corrupt bucket metadata".to_vec(),
|
||||
&ObjectOptions {
|
||||
write_completion: crate::object_api::WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("inject committed metadata corruption");
|
||||
assert!(
|
||||
get_quota_config_and_incarnation_from_disk_for_options_in(&store.ctx, bucket, &opts, &transaction_guard)
|
||||
.await
|
||||
.is_err(),
|
||||
"a valid snapshot must not hide corrupt disk metadata"
|
||||
);
|
||||
crate::config::com::delete_config(store.clone(), &metadata_path)
|
||||
.await
|
||||
.expect("remove the persisted metadata while retaining its sidecar");
|
||||
assert!(
|
||||
get_quota_config_and_incarnation_from_disk_for_options_in(&store.ctx, bucket, &opts, &transaction_guard)
|
||||
.await
|
||||
.is_err(),
|
||||
"a valid snapshot must not promote fabricated metadata to authority"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_snapshot_rejects_wrong_scope_incarnation_and_transaction_guard() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "quota-snapshot-scope";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("quota fixture bucket");
|
||||
let incarnation = store.bucket_incarnation_id(bucket).await.expect("fixture incarnation");
|
||||
let snapshot = store
|
||||
.object_lock_config_snapshot(bucket)
|
||||
.await
|
||||
.expect("guarded Object Lock snapshot");
|
||||
let transaction_guard = snapshot
|
||||
.metadata_transaction_guard_for(store.id, bucket, Some(incarnation))
|
||||
.expect("snapshot's actual transaction guard");
|
||||
let mut opts = ObjectOptions {
|
||||
object_lock_config_snapshot: Some(snapshot),
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
get_quota_config_and_incarnation_from_disk_for_options_in(&store.ctx, "another-bucket", &opts, &transaction_guard)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
opts.expected_bucket_incarnation_id = Some(Uuid::new_v4());
|
||||
assert!(
|
||||
get_quota_config_and_incarnation_from_disk_for_options_in(&store.ctx, bucket, &opts, &transaction_guard)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
opts.expected_bucket_incarnation_id = Some(incarnation);
|
||||
let other_guard = Arc::new(
|
||||
acquire_bucket_metadata_transaction_read_lock_in(&store.ctx, bucket)
|
||||
.await
|
||||
.expect("distinct real transaction read guard"),
|
||||
);
|
||||
assert!(
|
||||
get_quota_config_and_incarnation_from_disk_for_options_in(&store.ctx, bucket, &opts, &other_guard)
|
||||
.await
|
||||
.is_err(),
|
||||
"the same namespace key does not prove that this is the snapshot's actual guard"
|
||||
);
|
||||
let (_other_dirs, other_store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(other_store.clone(), Vec::new()).await;
|
||||
assert!(
|
||||
get_quota_config_and_incarnation_from_disk_for_options_in(&other_store.ctx, bucket, &opts, &transaction_guard)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
opts.object_lock_config_snapshot = Some(Arc::new(crate::object_api::ObjectLockConfigSnapshot::new(
|
||||
ObjectLockConfigState::ConfirmedAbsent,
|
||||
)));
|
||||
assert!(
|
||||
get_quota_config_and_incarnation_from_disk_for_options_in(&store.ctx, bucket, &opts, &transaction_guard)
|
||||
.await
|
||||
.is_err(),
|
||||
"an unguarded snapshot must not authorize namespace reuse"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn quota_snapshot_rejects_lifecycle_loss_during_read_and_changed_disk_incarnation() {
|
||||
let (_dirs, store) = isolated_store_over_temp_disks().await;
|
||||
init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||
let bucket = "quota-snapshot-loss";
|
||||
store
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("quota fixture bucket");
|
||||
let sys = bucket_metadata_sys_of(&store.ctx).expect("fixture metadata system");
|
||||
let sys = sys.read().await.clone();
|
||||
let mut metadata = (*sys.get(bucket).await.expect("fixture metadata")).clone();
|
||||
let incarnation = metadata.bucket_incarnation_id;
|
||||
let _lifecycle_guard = store
|
||||
.acquire_bucket_lifecycle_read_lock(bucket)
|
||||
.await
|
||||
.expect("real lifecycle owner");
|
||||
let transaction_guard = Arc::new(
|
||||
acquire_bucket_metadata_transaction_read_lock_in(&store.ctx, bucket)
|
||||
.await
|
||||
.expect("real metadata transaction owner"),
|
||||
);
|
||||
let (lifecycle_fence, lost) = crate::object_api::NamespaceLockFence::loss_handle_for_test();
|
||||
let snapshot = crate::object_api::ObjectLockConfigSnapshot::for_store_bucket_under_lifecycle_fence(
|
||||
store.id,
|
||||
bucket,
|
||||
incarnation,
|
||||
metadata.object_lock_config_updated_at,
|
||||
ObjectLockConfigState::ConfirmedAbsent,
|
||||
lifecycle_fence,
|
||||
Arc::clone(&transaction_guard),
|
||||
);
|
||||
let opts = ObjectOptions {
|
||||
object_lock_config_snapshot: Some(Arc::new(snapshot)),
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
..Default::default()
|
||||
};
|
||||
let mut read = Box::pin(get_quota_config_and_incarnation_from_disk_for_options_in(
|
||||
&store.ctx,
|
||||
bucket,
|
||||
&opts,
|
||||
&transaction_guard,
|
||||
));
|
||||
assert!(
|
||||
futures::poll!(read.as_mut()).is_pending(),
|
||||
"the authoritative disk read must be in flight"
|
||||
);
|
||||
lost.store(true, std::sync::atomic::Ordering::Release);
|
||||
assert!(read.await.is_err(), "a lifecycle fence lost during the disk read must reject its result");
|
||||
|
||||
let snapshot = store
|
||||
.object_lock_config_snapshot(bucket)
|
||||
.await
|
||||
.expect("fresh guarded snapshot");
|
||||
let transaction_guard = snapshot
|
||||
.metadata_transaction_guard_for(store.id, bucket, Some(incarnation))
|
||||
.expect("snapshot's actual transaction guard");
|
||||
let opts = ObjectOptions {
|
||||
object_lock_config_snapshot: Some(snapshot),
|
||||
expected_bucket_incarnation_id: Some(incarnation),
|
||||
..Default::default()
|
||||
};
|
||||
metadata.bucket_incarnation_id = Uuid::new_v4();
|
||||
save_bucket_incarnation(store.clone(), bucket, metadata.bucket_incarnation_id)
|
||||
.await
|
||||
.expect("inject a new disk incarnation");
|
||||
metadata
|
||||
.save_with_store_committed(store.clone())
|
||||
.await
|
||||
.expect("persist its matching metadata blob");
|
||||
assert!(
|
||||
get_quota_config_and_incarnation_from_disk_for_options_in(&store.ctx, bucket, &opts, &transaction_guard)
|
||||
.await
|
||||
.is_err(),
|
||||
"a matching sidecar and blob from another incarnation must not reuse the old snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
/// Writers on different nodes updating *different* config files of one
|
||||
/// bucket must both survive. Each rewrites the whole metadata blob, so
|
||||
/// without a lock spanning the read-modify-write the later save carries
|
||||
|
||||
@@ -280,11 +280,11 @@ where
|
||||
|
||||
debug!("Found {} migrating bucket metadata, migrating...", buckets.len());
|
||||
|
||||
let opts = ObjectOptions {
|
||||
let mut opts = ObjectOptions {
|
||||
max_parity: true,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
opts.use_offline_recovery_write();
|
||||
let h = HeaderMap::new();
|
||||
|
||||
for bucket in buckets {
|
||||
@@ -397,11 +397,11 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let opts = ObjectOptions {
|
||||
let mut opts = ObjectOptions {
|
||||
max_parity: true,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
opts.use_offline_recovery_write();
|
||||
let h = HeaderMap::new();
|
||||
let prefix = format!("{IAM_CONFIG_PREFIX}/");
|
||||
let mut continuation: Option<String> = None;
|
||||
|
||||
@@ -32,7 +32,10 @@ pub enum QuotaType {
|
||||
Hard,
|
||||
}
|
||||
|
||||
pub(crate) const QUOTA_RESERVATION_PROTOCOL_V1: u32 = 1;
|
||||
pub const QUOTA_RESERVATION_PROTOCOL_V1: u32 = 1;
|
||||
/// Sharded durable reservations. Older nodes reject this protocol instead of
|
||||
/// silently falling back to the single-ledger protocol.
|
||||
pub const QUOTA_RESERVATION_PROTOCOL_V2: u32 = 2;
|
||||
|
||||
/// Bucket quota configuration. quota_type defaults to Hard when omitted.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
@@ -89,7 +92,10 @@ impl<'de> Deserialize<'de> for BucketQuota {
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let wire = BucketQuotaWire::deserialize(deserializer)?;
|
||||
let quota = if wire.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1) {
|
||||
let quota = if matches!(
|
||||
wire.reservation_protocol,
|
||||
Some(QUOTA_RESERVATION_PROTOCOL_V1) | Some(QUOTA_RESERVATION_PROTOCOL_V2)
|
||||
) {
|
||||
Some(
|
||||
wire.reservation_quota
|
||||
.ok_or_else(|| D::Error::custom("reservation_quota is required for reservation protocol v1"))?,
|
||||
@@ -129,17 +135,33 @@ impl BucketQuota {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a quota using the sharded durable reservation protocol.
|
||||
/// Deployment must complete the mixed-version rollout before enabling it;
|
||||
/// older nodes fail closed when they see protocol v2.
|
||||
pub fn new_sharded(quota: u64) -> Self {
|
||||
let mut config = Self::new(Some(quota));
|
||||
config.reservation_protocol = Some(QUOTA_RESERVATION_PROTOCOL_V2);
|
||||
config
|
||||
}
|
||||
|
||||
pub fn get_quota_limit(&self) -> Option<u64> {
|
||||
self.quota
|
||||
}
|
||||
|
||||
pub fn uses_durable_reservations(&self) -> bool {
|
||||
self.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V1)
|
||||
matches!(
|
||||
self.reservation_protocol,
|
||||
Some(QUOTA_RESERVATION_PROTOCOL_V1) | Some(QUOTA_RESERVATION_PROTOCOL_V2)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn uses_sharded_reservations(&self) -> bool {
|
||||
self.reservation_protocol == Some(QUOTA_RESERVATION_PROTOCOL_V2)
|
||||
}
|
||||
|
||||
pub fn has_unsupported_reservation_protocol(&self) -> bool {
|
||||
self.reservation_protocol
|
||||
.is_some_and(|version| version != QUOTA_RESERVATION_PROTOCOL_V1)
|
||||
.is_some_and(|version| !matches!(version, QUOTA_RESERVATION_PROTOCOL_V1 | QUOTA_RESERVATION_PROTOCOL_V2))
|
||||
}
|
||||
|
||||
pub fn check_operation_allowed(&self, current_usage: u64, operation_size: u64) -> bool {
|
||||
@@ -322,9 +344,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_reservation_protocol_does_not_activate_v1() {
|
||||
fn sharded_quota_roundtrips_with_authoritative_reservation_quota() {
|
||||
let quota = BucketQuota::new_sharded(4096);
|
||||
let json = serde_json::to_value("a).expect("sharded quota should serialize");
|
||||
assert_eq!(json["quota"], 0);
|
||||
assert_eq!(json["reservation_protocol"], QUOTA_RESERVATION_PROTOCOL_V2);
|
||||
assert_eq!(json["reservation_quota"], 4096);
|
||||
let restored: BucketQuota = serde_json::from_value(json).expect("sharded quota should parse");
|
||||
assert_eq!(restored.quota, Some(4096));
|
||||
assert!(restored.uses_sharded_reservations());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_reservation_protocol_does_not_activate_any_known_protocol() {
|
||||
let quota: BucketQuota =
|
||||
serde_json::from_str(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":2,"reservation_quota":2048}"#)
|
||||
serde_json::from_str(r#"{"quota":0,"quota_type":"Hard","reservation_protocol":3,"reservation_quota":2048}"#)
|
||||
.expect("future protocol should remain parseable");
|
||||
|
||||
assert!(!quota.uses_durable_reservations());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,9 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::replication_error_boundary::Result;
|
||||
use super::replication_storage_boundary::{HTTPPreconditions, ObjectInfo, ObjectOptions, ReplicationObjectIO};
|
||||
use super::replication_storage_boundary::{
|
||||
HTTPPreconditions, ObjectInfo, ObjectOptions, ReplicationObjectIO, WriteCommitGuard, WriteCompletion,
|
||||
};
|
||||
use crate::config::{com, storageclass};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -77,13 +79,6 @@ impl ReplicationConfigStore {
|
||||
com::save_config(api, file, data).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_no_lock<S>(api: Arc<S>, file: &str, data: Vec<u8>) -> Result<()>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::save_config_no_lock(api, file, data).await
|
||||
}
|
||||
|
||||
pub(crate) async fn save_conditional<S>(
|
||||
api: Arc<S>,
|
||||
file: &str,
|
||||
@@ -111,21 +106,18 @@ impl ReplicationConfigStore {
|
||||
file: &str,
|
||||
data: Vec<u8>,
|
||||
http_preconditions: HTTPPreconditions,
|
||||
guard: &WriteCommitGuard,
|
||||
) -> Result<()>
|
||||
where
|
||||
S: ReplicationObjectIO,
|
||||
{
|
||||
com::save_config_with_opts_quiet(
|
||||
api,
|
||||
file,
|
||||
data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
no_lock: true,
|
||||
http_preconditions: Some(http_preconditions),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
let mut opts = ObjectOptions {
|
||||
max_parity: true,
|
||||
http_preconditions: Some(http_preconditions),
|
||||
write_completion: WriteCompletion::TailDrained,
|
||||
..Default::default()
|
||||
};
|
||||
opts.add_write_commit_guard(guard);
|
||||
com::save_config_with_opts_quiet(api, file, data, &opts).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ use super::replication_resyncer::{
|
||||
use super::replication_state::ReplicationStats;
|
||||
use super::replication_storage_boundary::{
|
||||
HTTPPreconditions, ObjectInfo, ObjectOptions, ObjectToDelete, ReplicationDeletedObject, ReplicationObjectIO,
|
||||
ReplicationStorage,
|
||||
ReplicationStorage, WriteCommitGuard,
|
||||
};
|
||||
use super::replication_target_boundary::{BucketTargetError, ReplicationTargetStore, replication_object_is_ssec_encrypted};
|
||||
use super::replication_versioning_boundary::ReplicationVersioningStore;
|
||||
@@ -513,7 +513,7 @@ where
|
||||
// Lock order is transaction namespace lock -> force-delete journal object lock.
|
||||
// Keep the transaction guard alive through the conditional write so legacy
|
||||
// writers cannot interleave a read-modify-write transition within this process.
|
||||
let guard = lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await?;
|
||||
let guard = WriteCommitGuard::acquire(&lock, ReplicationLockTiming::acquire_timeout()).await?;
|
||||
let (mut entries, preconditions, exists) = read_force_delete_intents(storage.clone(), file).await?;
|
||||
if !update(&mut entries, exists)? {
|
||||
return Ok(());
|
||||
@@ -565,7 +565,7 @@ async fn read_force_delete_intents<S: ReplicationObjectIO>(
|
||||
async fn save_force_delete_intents<S: ReplicationStorage>(
|
||||
storage: Arc<S>,
|
||||
file: &str,
|
||||
guard: &rustfs_lock::NamespaceLockGuard,
|
||||
guard: &WriteCommitGuard,
|
||||
entries: Vec<MrfReplicateEntry>,
|
||||
preconditions: HTTPPreconditions,
|
||||
) -> Result<(), EcstoreError> {
|
||||
@@ -628,7 +628,7 @@ async fn acknowledge_mrf_recovery<S: ReplicationStorage>(
|
||||
let lock = storage
|
||||
.new_ns_lock(ReplicationMetadataStore::rustfs_meta_bucket(), file)
|
||||
.await?;
|
||||
let guard = lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await?;
|
||||
let guard = WriteCommitGuard::acquire(&lock, ReplicationLockTiming::acquire_timeout()).await?;
|
||||
let current = ReplicationConfigStore::read_no_lock_with_metadata_preserve_empty(storage.clone(), file).await;
|
||||
let (current_data, current_etag, current_exists) = match current {
|
||||
Ok((data, object_info)) => (data, object_info.etag, true),
|
||||
@@ -658,7 +658,7 @@ async fn acknowledge_mrf_recovery<S: ReplicationStorage>(
|
||||
} else {
|
||||
encode_mrf_file(&retained)?
|
||||
};
|
||||
match ReplicationConfigStore::save_conditional_no_lock(storage.clone(), file, data, preconditions).await {
|
||||
match ReplicationConfigStore::save_conditional_no_lock(storage.clone(), file, data, preconditions, &guard).await {
|
||||
Ok(()) => return Ok(retained),
|
||||
Err(EcstoreError::PreconditionFailed) => continue,
|
||||
Err(error) => return Err(error),
|
||||
@@ -2522,7 +2522,7 @@ async fn quarantine_mrf_file<S: ReplicationStorage>(storage: &Arc<S>, data: &[u8
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let guard = match lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
||||
let guard = match WriteCommitGuard::acquire(&lock, ReplicationLockTiming::acquire_timeout()).await {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
@@ -2573,6 +2573,7 @@ async fn quarantine_mrf_file<S: ReplicationStorage>(storage: &Arc<S>, data: &[u8
|
||||
ReplicationMetadataStore::MRF_REPLICATION_FILE,
|
||||
Vec::new(),
|
||||
preconditions,
|
||||
&guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2627,13 +2628,12 @@ async fn recover_corrupt_mrf_generation<S: ReplicationStorage>(
|
||||
entries: &[MrfReplicateEntry],
|
||||
preconditions: Option<HTTPPreconditions>,
|
||||
storage: &Arc<S>,
|
||||
guard: &rustfs_lock::NamespaceLockGuard,
|
||||
guard: &WriteCommitGuard,
|
||||
pending_payload: &mut Option<PendingMrfAppend>,
|
||||
started: Instant,
|
||||
) -> Option<MrfAppendResult> {
|
||||
let quarantine_file = format!("{MRF_CORRUPT_FILE_PREFIX}.{}.bin", OffsetDateTime::now_utc().unix_timestamp_nanos());
|
||||
if let Err(error) = ReplicationConfigStore::save_no_lock(storage.clone(), &quarantine_file, corrupt_generation.to_vec()).await
|
||||
{
|
||||
if let Err(error) = ReplicationConfigStore::save(storage.clone(), &quarantine_file, corrupt_generation.to_vec()).await {
|
||||
warn!(
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION,
|
||||
@@ -2688,6 +2688,7 @@ async fn recover_corrupt_mrf_generation<S: ReplicationStorage>(
|
||||
ReplicationMetadataStore::MRF_REPLICATION_FILE,
|
||||
data,
|
||||
preconditions,
|
||||
guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2735,7 +2736,7 @@ async fn append_mrf_entries_to_disk<S: ReplicationStorage>(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let guard = match lock.get_write_lock(ReplicationLockTiming::acquire_timeout()).await {
|
||||
let guard = match WriteCommitGuard::acquire(&lock, ReplicationLockTiming::acquire_timeout()).await {
|
||||
Ok(guard) => guard,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
@@ -2862,6 +2863,7 @@ async fn append_mrf_entries_to_disk<S: ReplicationStorage>(
|
||||
ReplicationMetadataStore::MRF_REPLICATION_FILE,
|
||||
data,
|
||||
preconditions,
|
||||
&guard,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -19,7 +19,8 @@ use super::replication_error_boundary::Error;
|
||||
use super::replication_filemeta_boundary::{replication_state_from_filemeta, version_purge_status_from_filemeta};
|
||||
pub(crate) type ReplicationObjectStore = crate::store::ECStore;
|
||||
pub(crate) use crate::object_api::{
|
||||
GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader, ReplicationStatusWritebackCondition, ReplicationStatusWritebackMode,
|
||||
GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader, ReplicationStatusWritebackCondition,
|
||||
ReplicationStatusWritebackMode, WriteCommitGuard, WriteCompletion,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::object_api::{NamespaceLockFence, NamespaceLockSignalTestFence};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user