Compare commits

..

10 Commits

Author SHA1 Message Date
houseme e2fc2071f3 fix(heal): cleanup consumed MRF replay journals
Do not retain Accepted or Merged replay intents as startup anchors after they have been handed to the heal manager. Only refused or still-pending replay records keep the journal on disk until a successor snapshot can persist them.

This keeps successor snapshots limited to the pending queue, which lets successful replay remove both authoritative and legacy journal paths and restores the crash-boundary tests around successor flush.

Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
(cherry picked from commit d5b8f49c9d)
2026-09-08 02:12:33 +08:00
houseme 227a998cef fix(error): merge equivalent api message branches
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-08 01:59:31 +08:00
houseme d85b8a8931 test(scanner): report heal release gate status
Co-Authored-By: heihutu <heihutu@gmail.com>

Co-Authored-By: zhi22915 <qiuzgang@gmail.com>
2026-09-08 01:20:38 +08:00
Zhengchao An 7c85c72fd1 docs: scope agent guidance and consolidate review workflows (#7419) 2026-09-07 23:58:05 +08:00
Zhengchao An 27d167f7b6 ci(upgrade): run rc.5 multipart layout checks (#7421)
Wire the existing rc.5 multipart upgrade and diagnostic baseline tests into the upgrade matrix.
2026-09-07 23:58:02 +08:00
houseme 1a5e2b6256 test(scanner): bind segment proof generations (#7416)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 23:43:42 +08:00
houseme 474fcf78fb fix: align object version limit handling (#7415)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 23:43:19 +08:00
houseme 752d4a81ab test(scanner): prove restart quantum stages (#7414)
Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 23:22:02 +08:00
houseme 0686277ee4 fix(heal): retain MRF replay anchors until successor proof (#7413)
Keep replayed MRF records crash-replayable after manager admission until a later durable successor proof can tombstone them. Queue-full and transient replay submission failures now also preserve the old journal anchor instead of allowing cleanup to erase the only recovery source.

Co-authored-by: zhi22915 <qiuzgang@gmail.com>
2026-09-07 23:21:47 +08:00
cxymds 7373a5902e fix(ci): use test-domain facade in delete-marker regression (#7412) 2026-09-07 23:21:30 +08:00
78 changed files with 1341 additions and 4589 deletions
@@ -0,0 +1,33 @@
# Adversarial Review Shape
Use when root `AGENTS.md` triggers adversarial validation or for a substantial PR
review. Paths below are repository-relative. The root finding standard and
completion rule apply; selecting a lens does not require finding a defect.
Risk and review shape:
- **Exempt:** documentation, comments, formatting, or typos with no runtime,
build, test, or agent-execution effect.
- **Mechanical:** renames, moves, test/tooling-only changes, and agent-rule
changes. Run correctness and simplicity lenses.
- **Standard:** localized behavior changes. Run one integrated final-diff pass
covering correctness, simplicity, and test coverage; add only domain lenses
matched by the diff.
- **High risk / substantial PR review:** high risk includes locking,
erasure/quorum/heal, replication, multipart, RPC, lifecycle/tiering,
persistence/fsync, IAM/KMS/auth, cryptography, on-disk/on-wire formats, and
S3-visible semantics. Cover all applicable lenses using exactly two
independent reviewers when delegation is explicitly authorized. Split the
lenses between them. Otherwise perform two fresh sequential passes.
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
the remote SDK configuration sends to every replication or migration target
is high risk for every target class even when the change fixes one. Follow
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
run the outbound target matrix, document each new env knob in the same PR,
and list verified and unverified target classes in the PR Impact section.
Available domain lenses are security, concurrency/durability, compatibility,
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
explicit adversarial request, a high-risk change, or a substantial PR review;
then read only its matching role references. A routine standard pass does not
load the playbook unless the reviewer needs a RustFS-specific probe.
+65
View File
@@ -0,0 +1,65 @@
# Implementation Rules
Applies when changing code or running artifact-heavy work. Paths below
are repository-relative. Read only the relevant sections during read-only review.
## Worktree and Disk Hygiene
- Start implementation from the latest `origin/main` and confirm the requested
change is not already present.
- An existing clean, isolated task worktree is sufficient. Create another
worktree only when the current checkout is shared, dirty with unrelated work,
or belongs to another task.
- Never commit from a shared checkout.
- Use a task-specific branch named `<type>/<topic>`, such as `fix/...`,
`feat/...`, `test/...`, or `docs/...`, unless the user specifies a name.
- Do not include agent, tool, contributor, account, or organization names in
branch names.
- Push to the user-requested remote or the repository's configured push remote.
Do not hard-code or infer a remote from an account name.
- Check free space before artifact-heavy builds, tests, coverage, or downloads.
Re-check before a broad gate when space is tight.
- Remove only task-owned temporary/build artifacts. Never delete another task's
worktree or uncommitted data.
- At handoff, mention disk or cleanup details only when they affected execution
or artifacts/worktrees remain intentionally.
## Change Style
- Preserve existing control flow unless changing it is required for correctness.
- Prefer a direct local edit over new files, wrappers, managers, or speculative
abstractions.
- Add a helper only when it removes current duplication, names a real domain
boundary, or isolates a non-trivial invariant.
- Remove an in-scope path superseded by the change. If compatibility requires it,
adapt at the boundary to one canonical core and use the repository's
`RUSTFS_COMPAT_TODO` policy.
- Comments explain non-obvious invariants or reasons. Do not narrate code or
record change history.
- Mention unrelated problems when useful; do not fix them in a narrow task.
## Reuse and Boundary Rules
- Before adding helpers, constants, fixtures, or wrappers, search the touched
crate, the domain-owning crate, `crates/utils`, `crates/common`, and relevant
direct dependencies.
- Reuse requires matching semantics: normalization, error types, deadlines,
durability, and compatibility must fit the call site. A narrowly named local
helper is better than forced reuse with different semantics.
- Validate untrusted input at its trust boundary, then trust the validated type.
Values crossing disk, RPC, persistence, or version boundaries remain
untrusted at every consumer.
- Re-check boundary values immediately before destructive actions such as
delete, overwrite, or quorum decisions.
- Every new branch needs a concrete triggering input/state. For decoded or peer
data, corruption and mixed-version input are valid triggers.
- Required values must return a typed error when absent or corrupt; do not use a
default that converts corruption into a plausible result.
- Attach error context once where it is actionable. Do not erase typed errors
below aggregation or quorum layers.
## Naming
Use Rust API naming: `SCREAMING_SNAKE_CASE` constants/statics, `snake_case`
functions/variables, and `PascalCase` types. Do not rename unrelated existing
violations.
+51
View File
@@ -0,0 +1,51 @@
# Git and Pull Request Rules
Applies to commits, pushes, PRs, and issue/discussion actions. Paths below are
repository-relative. User authorization and root `AGENTS.md` still govern scope.
## Final PR Preflight
Before creating or updating a PR, reuse completed review and verification:
- Verify the actual base (normally `origin/main`) and the complete task diff,
including file names and whitespace. Exclude secrets, logs, generated
artifacts, and unrelated edits. Retain an existing PR's base unless requested.
- Confirm the final diff passed the root verification tier; fix task-owned
failures and run missing scoped checks. Report unresolved required checks or
authority without expanding the task. Do not start another general review.
- Keep the English Conventional Commit title at most 72 characters. Use the
template headings, actual checks, material risks, and rollback notes.
- Immediately before writing to GitHub, confirm the head and task diff are
unchanged. Rerun only checks invalidated by edits or relevant state changes.
## Pull Request Lifecycle
- Creating or updating a PR includes one immediate snapshot of checks,
mergeability, reviews, and unresolved threads.
- Unless the user explicitly requests monitoring, a release workflow requires
it, or an automation already owns it, hand off after the PR is open with the
current state and next event to watch. Do not delay ordinary handoff with
fixed quiet-period sleeps.
- For requested monitoring, use event-driven or bounded waits. Report only state
changes, actionable failures, or a meaningful prolonged delay.
- Investigate failures/comments before changing code. Fix task-attributable
issues, rerun affected verification, push, reply or resolve the thread, then
resume the requested monitor.
- Never merge without required reviewer approval or explicit authority.
- After an observed merge, verify the commit reached the base, then clean the
task worktree/branch when safe. Preserve unmerged work for closed PRs unless
deletion was explicitly authorized.
## Git and PR Baseline
- Follow Conventional Commits; keep the subject at most 72 characters.
- Source comments, commits, PR titles, and PR bodies are in English.
- Keep every heading from `.github/pull_request_template.md`; use `N/A` where
needed and include commands actually run.
- Use `--body-file` for multiline `gh pr create`/`gh pr edit` content.
- PR/issue/discussion content must not contain the literal sequence `\n` or
hard-wrapped prose paragraphs.
- Do not include local absolute paths or tool-specific labels/prefixes in GitHub
content.
- Resolve review threads after the underlying issue is fixed. If declining a
suggestion, reply with a short evidence-based reason.
+11 -8
View File
@@ -1,11 +1,11 @@
---
name: adversarial-validation
description: Review a final RustFS diff adversarially when the user requests adversarial review, the root AGENTS.md classifies the change as high risk, or a substantial PR is being reviewed. Do not use for ordinary questions, diagnosis, planning, status, documentation-only work, or routine low-risk implementation.
description: Review RustFS diffs or designs for explicit adversarial requests, high-risk changes under the repository review policy, or substantial PR reviews. Skip ordinary questions, diagnosis, planning, status, routine low-risk implementation, and prose with no execution effect.
---
# RustFS Adversarial Validation
Use the risk tier and review shape defined in the root `AGENTS.md`. This skill
Use the [repository risk tiers and review shape](../../references/adversarial-validation.md). This skill
routes a review to RustFS-specific probes without loading unrelated domains.
## Select Lenses
@@ -31,15 +31,18 @@ adversarial review.
## Review Protocol
1. Freeze the exact final diff/head and list the selected lenses.
2. Run the review shape required by root `AGENTS.md`.
1. Freeze the exact final diff/head (or the design under review) and list the
selected lenses.
2. Run the review shape required by the repository risk tier.
3. For each selected lens, either report a concrete finding or a null verdict
naming the attacks performed.
4. A finding needs `file:line`, a triggering input/state/interleaving, the wrong
outcome, and a focused fix or missing regression check.
5. Fix or rebut every finding with code-path, test, or invariant evidence.
4. Apply root `AGENTS.md`'s finding standard. Test each candidate against callers,
existing coverage, and invariants before accepting it; an adversarial role
does not have to produce a defect.
5. Fix or rebut supported findings with code-path, test, or invariant evidence.
6. After a non-trivial edit, rerun only lenses affected by that edit against the
new exact diff.
Do not turn a null verdict into a long checklist. Record concise evidence that
the relevant failure classes were attacked.
the relevant failure classes were attacked, then stop under the root completion
rule. Keep the required per-lens verdicts for high-risk PRs.
@@ -3,9 +3,10 @@
- For every behavior claim, name the focused test/check that fails if the
changed hunk is reverted. If none is practical, require the reason and
residual risk.
- Confirm tests exercise the real production path and assert returned values,
exact bytes, stored state, or the specific error variant—not only success,
`is_err()`, or no panic.
- Confirm tests exercise the real production path and distinguish the intended
behavior from the named regression. A success, `is_err()`, or no-panic check
can be sufficient when that is the actual contract; require exact values,
bytes, state, or error variants when those distinctions matter to the change.
- For new flags/modes, verify each branch and ask which test fails if the branch
is inverted.
- For new error propagation, inject the failure and assert the caller observes
+6 -4
View File
@@ -1,12 +1,13 @@
---
name: arch-checks
description: Resolve failures from the repository's architecture guard scripts — check_layer_dependencies.sh, check_architecture_migration_rules.sh, check_unsafe_code_allowances.sh, check_logging_guardrails.sh, check_doc_paths.sh. Use when make pre-commit / pre-pr or CI fails on one of these checks.
description: Diagnose failures from check_layer_dependencies.sh, check_architecture_migration_rules.sh, check_unsafe_code_allowances.sh, check_logging_guardrails.sh, check_doc_paths.sh, or check_no_planning_docs.sh. Use when one of these guards fails, not for every architecture question or documentation edit.
---
# Architecture Guard Checks
All five run in `make pre-commit` / `make pre-pr` and in CI. Fix the cause;
never weaken a check to get green.
Read only the section for the failing guard. Use `.config/make/` and the current
workflow to verify its wiring; not every guard is part of every gate. Fix the
cause and rerun the failed guard; never weaken a check to get green.
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
@@ -54,7 +55,8 @@ Instruction docs (`AGENTS.md`, `CLAUDE.md`, `ARCHITECTURE.md`) and every
Markdown file under `docs/` (architecture, operations, testing, index) must not
reference repo file paths that no longer exist. If your refactor moved code,
update the docs that point at it — the error message lists `doc -> stale-path`
pairs. Cite paths plus symbol names, never line numbers (see `docs/README.md`).
pairs. In durable docs, cite paths plus symbol names rather than line numbers
(see `docs/architecture/README.md`). Review findings still need `file:line`.
## `check_no_planning_docs.sh`
@@ -8,19 +8,11 @@ description: Review a commit, PR, or merged patch when the user requests ordinar
Use this skill for an ordinary requested review. If the root policy or user calls
for adversarial validation, use `adversarial-validation` instead of running both.
## Quick Start
1. Read the scope: commit, PR, patch, or file list.
2. Map each changed area by risk and user impact.
3. Inspect each risky change in context.
4. Report findings first, ordered by severity.
5. Close with residual risks and verification recommendations.
## Core Workflow
### 1) Scope and assumptions
- Confirm change source (diff, commit, PR, files), target branch, language/runtime, and version.
- If context is missing, state assumptions before deeper analysis.
- Derive the change source, target branch, and relevant runtime/version from the
supplied diff and metadata. Ask only when missing context could change the verdict.
- Focus only on requested scope; avoid reviewing unrelated files.
### 2) Risk map
@@ -40,43 +32,20 @@ for adversarial validation, use `adversarial-validation` instead of running both
- unchecked assumptions and null/empty/error-path handling
- stale tests, fixtures, and configs
- hidden coupling to shared helpers/constants/features
- If a point is uncertain, mark it as an open question instead of guessing.
- Apply root `AGENTS.md`'s finding standard: try to disprove a candidate before
reporting it. Mention an unresolved question only when it could materially
change the verdict; do not fill the report with speculative possibilities.
#### Rust-specific checks (apply to all Rust changes)
#### Rust-specific checks
Run the full checklist in [rust-code-quality](../rust-code-quality/SKILL.md) — the canonical Rust review checklist for the unwrap/casting/cloning/locking/recursion/error-type/serde/test rules and the reuse-and-necessity checks (duplicated helpers, defensive branches without a nameable trigger, redundant error wrapping). Do not restate those rules here; carry its P0P3 ratings over unchanged and use this skill's output format.
For changed Rust behavior, use the matching sections of [rust-code-quality](../rust-code-quality/SKILL.md). Reuse checks already performed by the selected review workflow. Comment-only or formatting-only Rust diffs do not require the full Rust checklist. Carry its P0P3 ratings over unchanged and use this skill's output format.
### 4) Findings-first output
- Order findings by severity:
- P0: critical failure, security breach, or data loss risk
- P1: high-impact regression
- P2: medium risk correctness gap
- P3: low risk/quality debt
- For each finding include:
- Severity
- `path:line` reference
- concise issue statement
- impact and likely failure mode
- specific fix or mitigation
- validation step to confirm
- If no issues exist, explicitly state `No findings` and why.
- Order supported findings by P0P3 severity; preserve the Rust ratings above.
Include `path:line`, the failure and impact, a focused fix, and its validation.
- If no supported issues remain, state `No findings` with the reviewed scope and
any material verification limitation. Do not append optional improvements to
make a clean review look productive.
### 5) Close
- Report assumptions and unknowns.
- Suggest targeted checks (tests, canary checks, logs/metrics, migration validation).
## Output Template
1. Findings
2. No findings (if applicable)
3. Assumptions / Unknowns
4. Recommended verification steps
## Finding Template
- `[P1] Missing timeout for downstream call`
- Location: `path/to/file.rs:123`
- Issue: ...
- Impact: ...
- Fix suggestion: ...
- Validation: ...
Close after the required review. Recommend additional verification only for an
identified unresolved risk or required gate; reuse evidence for unchanged code.
+28 -32
View File
@@ -1,6 +1,6 @@
---
name: issue-triage
description: Triage a GitHub issue — determine if it is already fixed, needs implementation, or should be closed. Searches related commits and PRs, verifies implementation status, and posts a triage comment or closes the issue. Use when the user provides an issue URL and asks whether it can be closed or needs work.
description: Assess whether a GitHub issue is fixed, needs implementation, or can be closed by checking related work and current code. Use for issue completion/triage requests. Status questions are read-only; comment, close, or change labels only when the conversation authorizes that action.
---
# Issue Triage
@@ -20,6 +20,8 @@ Read the issue body to understand what was requested. Extract:
- Any linked PRs or commits mentioned in the body or comments.
- Any 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:
@@ -29,26 +31,29 @@ git log --oneline --all --grep="<N>" | head -30
Search for related PRs:
```bash
gh pr list --search "fixes #<N> OR closes #<N> OR #<N>" --state all --json number,title,state,mergedAt
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> --json state,mergedAt,title
gh pr view <PR_N> --repo <implementation-repo> --json state,mergedAt,title,mergeCommit,baseRefName
```
### 3. Verify implementation
For each linked or related PR that is merged, verify the fix is actually present on the current main branch:
Fetch the implementation repository's current base branch. For each merged candidate, verify its merge commit is present and inspect the current code for the claimed behavior; a commit message match alone is not proof:
```bash
git log --oneline main | grep -i "<keyword>"
# or
git log --oneline main --grep="<PR_N>"
git fetch <implementation-remote> <base-branch>
git merge-base --is-ancestor <merge-commit> <implementation-remote>/<base-branch>
```
If the issue describes a specific defect, check the relevant code to confirm the fix is in place:
If the issue describes a specific defect, inspect the fetched base's code rather than assuming the current checkout contains it:
```bash
grep -n "<pattern>" crates/<relevant>/src/<file>.rs
git show <implementation-remote>/<base-branch>:crates/<relevant>/src/<file>.rs
```
For issues with checklists, verify each item individually. If sub-items are tracked as separate issues, check those too:
@@ -58,13 +63,15 @@ gh issue view <SUB_N> --repo <owner/repo> --json state
### 4. Determine verdict
- **All items fixed and merged**: Close with a summary comment listing what was fixed and which PRs.
- **Some items fixed, some remaining**: Comment with status of each item. Do not close.
- **Not yet implemented**: Comment with a summary of what remains. Do not close.
- **Superseded or no longer relevant**: Close with explanation.
- **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>"
@@ -75,9 +82,9 @@ Comment without closing:
gh issue comment <N> --repo <owner/repo> --body-file /tmp/triage.md
```
Update issue labels if needed:
Update labels only when label changes are authorized, using existing repository labels; never add tool-specific labels:
```bash
gh issue edit <N> --repo <owner/repo> --add-label "completed" --remove-label "needs-triage"
gh issue edit <N> --repo <owner/repo> --add-label "<existing-label>"
```
Always use `--body-file` for multiline content, never inline `--body`.
@@ -85,27 +92,16 @@ Always use `--body-file` for multiline content, never inline `--body`.
### 6. Handle multi-issue batches
When the user asks to check multiple issues (e.g., "check all issues by user X" or "scan backlog for closable issues"):
1. List the issues: `gh issue list --repo <repo> --author <user> --state open --json number,title,updatedAt`
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.
## Output format
## Report
### Issue Triage: #<N> — <title>
**State**: OPEN / CLOSED
**Linked PRs**: <list with merge status>
#### Assessment
<what was requested vs what is implemented>
#### Verdict
- Close — all items resolved by <PR list>
- Keep open — <remaining items>
- Not started — <what needs to be done>
#### Action taken
- Closed with comment / Commented / No action
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
@@ -1,6 +1,6 @@
---
name: plugin-contract-guard
description: Invariants and change procedure for the target-plugin / extension system — plugin manifests, admin plugin/extension catalog and instance APIs, secret redaction, external-plugin install policy. Use when editing crates/targets (manifest, plugin, control_plane, catalog, runtime), crates/extension-schema, or rustfs/src/admin plugin_contract.rs / plugins_*.rs / extensions.rs / target_descriptor.rs.
description: Guard changes to target-plugin manifests, extension schemas, admin catalog/instance contracts, secret redaction, and external-plugin install policy. Use when a diff changes those contracts in crates/targets, crates/extension-schema, or admin plugin/extension handlers; path membership alone, comments, and unrelated runtime internals do not trigger it.
---
# Plugin & Extension Contract Guard
@@ -1,46 +0,0 @@
---
name: pr-creation-checker
description: Perform the final RustFS PR preflight and draft compliant English title/body metadata immediately before creating or updating a PR. Do not use during implementation or as a second general code review.
---
# PR Creation Checker
Use this skill only at the PR boundary. Reuse completed diff review and
verification evidence; do not reread the repository or rerun equivalent checks.
## Preflight
1. Confirm the branch is based on current `origin/main` and contains only the
intended task diff.
2. Inspect `git diff --stat`, `git diff --check`, and changed file names for
secrets, logs, generated artifacts, or unrelated edits.
3. Confirm the checks selected by root `AGENTS.md` passed on the final diff.
Do not replace focused behavioral tests with a generic gate or rerun checks
already covered by an unchanged umbrella run.
4. Read `.github/pull_request_template.md`. Consult `Makefile`, `.config/make/`,
or CI only when the required command/current gate is uncertain.
5. Return `BLOCKED` for an unclean scope, missing required evidence, failed
required checks, or non-compliant metadata.
## Metadata
- Title: English Conventional Commit, at most 72 characters, with no tool
prefix.
- Body: English, exact template headings, `N/A` where needed, concise rationale,
actual verification commands, and material risks/rollback notes.
- Use repository-relative paths; never include local absolute paths.
- Keep prose paragraphs on one logical line and never include the literal
sequence `\n`.
- Use a temporary body file with `gh pr create --body-file` or
`gh pr edit --body-file`; never pass multiline Markdown inline.
## Output
- Status: `READY` or `BLOCKED`.
- Title.
- Complete PR body.
- Verification commands and results.
- Risks or `N/A`.
Immediately before the GitHub write, repeat only the five preflight checks above
against the final head.
@@ -1,4 +0,0 @@
interface:
display_name: "PR Creation Checker"
short_description: "Draft RustFS-ready PRs with checks, template, and blockers."
default_prompt: "Use $pr-creation-checker for final PR preflight and compliant English title/body metadata."
+30 -84
View File
@@ -1,147 +1,93 @@
---
name: pr-review
description: Review a GitHub PR end-to-end from a URL or number — fetch metadata, inspect the diff, run multi-role adversarial review, check CI status, and post the review comment. Use when the user provides a PR link and asks to review it.
description: Review a GitHub PR from a URL or number using its actual base/head and risk-appropriate code review. Use when the user asks for a PR review, not a status lookup or PR wording edit. Publish a review only when authorized; delegation and monitoring follow the requested scope and root AGENTS.md.
---
# PR Review
Use this skill when the user provides a GitHub PR URL or number and asks to review it. This covers the full review lifecycle: data gathering, code review, CI verification, and posting the result.
Use this skill for PR context and review delivery. An ordinary review request is read-only unless the conversation also authorizes posting or fixes. Reuse that authorization without asking again; prepare the review before requesting any missing publication approval.
## Prerequisites
- Read `AGENTS.md` for the repository's adversarial validation policy and change-style rules.
- The `adversarial-validation` skill handles the review role playbooks; this skill orchestrates the workflow around it.
- 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> --json title,author,state,body,additions,deletions,changedFiles,commits,baseRefName,headRefName
gh pr diff <N> --name-only
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> --json title,body,state
gh issue view <ISSUE> --repo <issue-owner/repo> --json title,body,state
```
### 2. Fetch the diff and classify the change
```bash
git fetch origin pull/<N>/head:pr-<N>
git diff main...pr-<N> --stat
git fetch <repo-remote> <baseRefName> refs/pull/<N>/head
git diff <baseRefOid>...<headRefOid> --stat
```
Classify the change by risk tier (per AGENTS.md):
- **Exempt**: docs/comments/instruction-only, formatting, typos.
- **Mechanical**: renames, file moves, test-only or tooling changes.
- **Standard** (default): any behavior change.
- **High risk**: locking, erasure coding, quorum/heal, replication, multipart, RPC, lifecycle/tiering, metadata formats, persistence/fsync, IAM/KMS/auth, on-disk/on-wire formats, S3 API-visible behavior.
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. Cluster changed files and delegate review
### 3. Review the changed behavior
Group the changed files into logical clusters (by crate or functional area). For each cluster, spawn a subagent with a focused review prompt that includes:
- The cluster's changed files and their diffs.
- The applicable adversarial role probes (from the `adversarial-validation` skill).
- The repository's AGENTS.md rules relevant to that domain.
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.
For standard-tier changes: correctness adversary + simplicity adversary + test-coverage skeptic, plus every role whose domain the diff touches.
For high-risk changes: run all seven roles.
Each subagent must produce findings (concrete failure scenario with file:line) or a null report ("attacked X, Y, Z — no break found").
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>
gh pr checks <N> --repo <owner/repo>
```
If any checks fail, investigate:
Investigate a failed check when it bears on a finding or the user requested CI diagnosis/merge readiness:
```bash
gh run view --log-failed --job=<JOB_ID>
gh run view --repo <owner/repo> --log-failed --job=<JOB_ID>
```
Determine whether failures are pre-existing (on main), flaky, or caused by the PR.
Use current evidence to distinguish pre-existing, flaky, and PR-caused failures. Do not classify them by guesswork or turn a code-only review into unrelated CI repair.
### 5. Synthesize findings
Combine all subagent findings into a structured review:
- **Summary**: one-paragraph overview of the change and overall assessment.
- **Findings**: each finding with severity (critical/major/minor/nit), file:line, concrete failure scenario, and suggested fix.
- **CI status**: pass/fail with notes on any failures.
- **Verdict**: APPROVE, REQUEST_CHANGES, or COMMENT.
Report the PR, reviewed base/head, and risk tier, then summarize the assessment.
Use the selected review's P0P3 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
Write the review body to a temp file and post via CLI:
Only when posting is authorized, write the review body to a temp file and post via CLI. Refresh the PR head first; if it changed, review the delta and update the verdict before posting:
```bash
# Request changes
gh pr review <N> --request-changes --body-file /tmp/pr_review.md
gh pr review <N> --repo <owner/repo> --request-changes --body-file /tmp/pr_review.md
# Approve
gh pr review <N> --approve --body-file /tmp/pr_review.md
gh pr review <N> --repo <owner/repo> --approve --body-file /tmp/pr_review.md
# Comment only (no verdict)
gh pr review <N> --comment --body-file /tmp/pr_review.md
gh pr review <N> --repo <owner/repo> --comment --body-file /tmp/pr_review.md
```
For inline comments on specific lines, use the GitHub API:
```bash
cat > /tmp/pr_review.json <<'EOF'
{
"body": "review body",
"event": "REQUEST_CHANGES",
"comments": [
{
"path": "crates/foo/src/bar.rs",
"line": 42,
"body": "finding description"
}
]
}
EOF
gh api --method POST /repos/{owner}/{repo}/pulls/<N>/reviews --input /tmp/pr_review.json
```
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
If the review requests changes:
- Monitor for new commits: `gh pr view <N> --json commits`
- Re-review changed files only: `git diff pr-<N>..origin/pull/<N>/head`
- Update the review when findings are addressed.
If CI was failing due to pre-existing main breakage:
- Comment on the PR noting the failure is pre-existing.
- Suggest updating the branch: `gh pr update-branch <N>`
## Output format
### PR Review: #<N> — <title>
**Author**: <author>
**Risk tier**: exempt | mechanical | standard | high-risk
**Changed files**: <count> across <cluster count> clusters
#### Summary
<one-paragraph overview>
#### Findings
| Severity | Location | Finding |
|----------|----------|---------|
| critical | file:line | concrete failure scenario |
#### CI Status
- All checks pass / Failing: <details>
#### Verdict
APPROVE / REQUEST_CHANGES / COMMENT
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 (>50 files), cluster aggressively and delegate in parallel to keep review time reasonable.
- For very large PRs, batch the review by functional area while keeping the same bounded review shape.
@@ -0,0 +1,22 @@
# Inline PR Review Submission
Read only when an inline review is authorized. Recheck the PR head before posting and bind the review to the reviewed commit.
For inline comments on specific lines, use the GitHub API:
```bash
cat > /tmp/pr_review.json <<'EOF'
{
"commit_id": "<reviewed-head-sha>",
"body": "review body",
"event": "REQUEST_CHANGES",
"comments": [
{
"path": "crates/foo/src/bar.rs",
"line": 42,
"body": "finding description"
}
]
}
EOF
gh api --method POST /repos/{owner}/{repo}/pulls/<N>/reviews --input /tmp/pr_review.json
```
+16 -23
View File
@@ -1,6 +1,6 @@
---
name: rust-code-quality
description: Run a focused Rust quality review when the user requests one, when reviewing a Rust PR/commit, or when another selected review workflow delegates Rust-specific checks. Do not auto-load for every implementation edit.
description: Run a focused Rust quality review when the user requests one or a selected review workflow needs Rust-specific checks for changed behavior. Do not auto-load for every implementation edit, comment-only or formatting-only Rust diff, or repeat an already completed review.
---
# Rust Code Quality Gate
@@ -8,12 +8,18 @@ description: Run a focused Rust quality review when the user requests one, when
Use this skill for a dedicated Rust review to cover rules that `cargo clippy`
does not catch.
Search matches and checklist items are candidates, not findings. Apply the root
finding standard; distinguish a demonstrated bug, an explicit rule violation,
and an optional preference. P2/P3 suggestions do not need to be invented or
included in an otherwise clean correctness review.
## Quick Start
1. Identify changed `.rs` files.
2. Run automated checks on changed files.
3. Run manual review checklist on the diff.
4. Resolve or rebut every finding with evidence; P0/P1 findings cannot be deferred.
2. Run the matching candidate searches on changed files.
3. Apply the manual checklist sections whose behavior the diff touches.
4. Report or rebut every finding with evidence; P0/P1 findings block approval.
Fix them when implementation is authorized; a read-only review reports them.
## Automated Checks
@@ -35,7 +41,7 @@ rg -n 'Result<.*String>' <changed-files>
rg -n 'Box<dyn.*Error' <changed-files>
# 5. println/eprintln in production
rg -n 'println!\|eprintln!' <changed-files>
rg -n 'println!|eprintln!' <changed-files>
# 6. Ordering::Relaxed usage (verify each is intentional)
rg -n 'Ordering::Relaxed' <changed-files>
@@ -80,7 +86,7 @@ For the Rust diff under review, verify:
- [ ] Test volume and line count are never treated as production-code growth
### Serde
- [ ] Structs from untrusted input have `#[serde(deny_unknown_fields)]`
- [ ] Structs from untrusted input reject unknown fields where the compatibility contract permits; otherwise validate security-critical fields explicitly and test the supported input shape
- [ ] `#[serde(default)]` not used on security-critical fields without validation
### Code Hygiene
@@ -104,20 +110,7 @@ For the Rust diff under review, verify:
## Output Template
```
## Rust Code Quality Report
### Automated Scan
- unwrap/expect candidates inspected: N
- numeric-cast candidates inspected: N
- error-type candidates inspected: N
- output-macro candidates inspected: N
### Findings
- [P1] `path:line` — description
- Fix: ...
- Validation: ...
### Verdict
PASS / BLOCKED (list blocking findings)
```
Use the calling review's output format. For a standalone review, report supported
findings with severity, location, impact, fix, and validation, or `No findings`.
Include only material unverified checks. Candidate counts are not a quality
metric and do not need a separate scan report.
+13 -104
View File
@@ -4,9 +4,14 @@ description: "Run the end-to-end RustFS console gate, version bump, preview vali
---
# RustFS Release Publish (preview-validated pipeline)
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (invoked here with the authorized commit/push/PR scope) with a mandatory preview-tag validation loop before the final tag is published.
Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. A preview tag creates a visible GitHub Release marked Prerelease and uploads versioned assets, but it never becomes GitHub Latest and never updates `*-latest`, `latest.json`, R2, Docker, or Helm channels. That Release is temporary: `build.yml` deletes it automatically once the final tag's Release is published, so the Releases page ends up carrying deliverables only while the `-preview.N` tags stay behind as the traceability record. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and preview classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships.
The binary reports its build tag (`build::TAG` via shadow_rs; `SHORT_VERSION` in
`rustfs/src/config/cli.rs`), and `build.yml` derives asset names and preview
classification from that tag. Cargo.toml supplies only the no-tag fallback.
Preview and final tags must therefore share the validated source commit;
their tag-dependent version and asset names differ. The channel and cleanup
constraints are defined once under Preview tag naming and Hard rules below.
Pipeline shape:
@@ -29,9 +34,9 @@ On validation failure: fix lands on main via normal PR (version files are alread
- Final target version, for example `1.0.0-beta.10`.
- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '<target>-preview.*'` after `git fetch --tags`).
If the target version is missing or ambiguous, stop and ask before doing anything (see the semver gate below).
If the target version is missing or ambiguous, collect the current release/tag baseline and ask before version edits or publication. Continue independent read-only preflight while the answer is pending (see the semver gate below).
## Semver gate — confirm the target version before touching anything
## Semver gate — resolve the target before version edits or publication
Versions follow [SemVer 2.0.0](https://semver.org/). Precedence reminder:
@@ -43,7 +48,7 @@ Numeric prerelease identifiers compare numerically (`beta.9 < beta.10`), not lex
Rules:
- A request like "发个版" / "release the next version" without an exact version string is ALWAYS ambiguous. Derive the current latest tag (`git tag --sort=-v:refname | head`), then ask the user to choose via AskUserQuestion with concrete candidates, e.g. from `1.0.0-beta.10`: next prerelease `1.0.0-beta.11`, promote to `1.0.0-rc.1`, promote to stable `1.0.0`. Never guess between these — they have very different meanings (channel promotion vs. iteration) and different CI classification consequences.
- A request like "发个版" / "release the next version" without an exact version string is ALWAYS ambiguous. Derive the current latest tag (`git tag --sort=-v:refname | head`), then ask the user to choose with concrete candidates, e.g. from `1.0.0-beta.10`: next prerelease `1.0.0-beta.11`, promote to `1.0.0-rc.1`, promote to stable `1.0.0`. Never guess between these — they have very different meanings (channel promotion vs. iteration) and different CI classification consequences.
- After a stable `X.Y.Z` exists, the next version must state which component bumps: patch `X.Y.(Z+1)` for fixes only, minor `X.(Y+1).0` for backward-compatible features, major `(X+1).0.0` for breaking changes. If the user names a bump type but not a number, compute it from the latest stable tag and echo the exact resulting version back for confirmation.
- Echo the final confirmed version string verbatim in your first status report; every later phase must use exactly that string. If at any point the user's wording and the confirmed version diverge, stop and re-confirm.
@@ -77,58 +82,7 @@ Rules:
### Console release gate
Complete this gate before changing any RustFS version file or creating any RustFS tag. RustFS `build.yml` downloads the asset returned by `repos/rustfs/console/releases/latest`, so a successful Console build alone is insufficient.
1. Read the latest published Console tag and compare it with Console `main`:
```bash
CONSOLE_REPO="rustfs/console"
CONSOLE_LATEST=$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)
gh api "repos/${CONSOLE_REPO}/compare/${CONSOLE_LATEST}...main" \
--jq '{status, ahead_by, behind_by, commits: [.commits[] | {sha, message: .commit.message}]}'
```
- `ahead_by == 0`: no merged Console change is waiting for release. Still verify the current latest asset using step 4, then continue to Phase 1.
- `ahead_by > 0` and `behind_by == 0`: publish Console before continuing. Report the merged commits and select the next unused `vX.Y.Z` tag. Default to the next patch version when the changes are fixes or backward-compatible UI work; stop for confirmation if a minor/major bump is plausible.
- Any diverged history or `behind_by > 0`: stop and resolve the Console release baseline explicitly. Do not guess a range or publish RustFS.
2. Clone/fetch `rustfs/console` into a scratch directory and record its exact `main` commit. Before creating a tag, check for a `v*` tag or Release workflow already associated with that hash. If one is in progress, wait for it instead of creating another version:
```bash
CONSOLE_SCRATCH=$(mktemp -d)
gh repo clone "$CONSOLE_REPO" "$CONSOLE_SCRATCH/console"
git -C "$CONSOLE_SCRATCH/console" fetch origin main --tags
CONSOLE_HASH=$(git -C "$CONSOLE_SCRATCH/console" rev-parse origin/main)
git -C "$CONSOLE_SCRATCH/console" tag --points-at "$CONSOLE_HASH" 'v*'
gh run list -R "$CONSOLE_REPO" --workflow release.yml --commit "$CONSOLE_HASH" --limit 5
```
If no release exists or is running for `CONSOLE_HASH`, create the selected annotated tag at that exact hash and push it:
```bash
git -C "$CONSOLE_SCRATCH/console" tag -a "<console-tag>" -m "Release <console-tag>" "$CONSOLE_HASH"
git -C "$CONSOLE_SCRATCH/console" push origin "<console-tag>"
```
Console tags include the `v` prefix. Pushing the tag triggers `.github/workflows/release.yml` (`🚀 Release`). Remove `CONSOLE_SCRATCH` after the gate completes.
3. Find the exact tag run and wait for completion:
```bash
gh run list -R "$CONSOLE_REPO" --workflow release.yml --branch "<console-tag>" --limit 1
gh run watch -R "$CONSOLE_REPO" "<console-run-id>" --exit-status
```
4. Block until the published Release is non-draft, the latest endpoint returns the expected tag, and `rustfs-console-<console-tag>.zip` is uploaded, non-empty, and carries a `sha256:` digest:
```bash
gh release view -R "$CONSOLE_REPO" "<console-tag>" --json isDraft,isPrerelease,assets,url
test "$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)" = "<console-tag>"
test "$(gh api "repos/${CONSOLE_REPO}/releases/tags/<console-tag>" \
--jq '[.assets[] | select(.name == "rustfs-console-<console-tag>.zip" and .state == "uploaded" and .size > 0 and (.digest | startswith("sha256:")))] | length')" -eq 1
```
Treat a missing/mismatched asset, digest, latest tag, or failed/cancelled workflow as BLOCKED. Do not start Phase 1 until the Console gate passes. Record `CONSOLE_TAG`, `CONSOLE_HASH`, Console run URL, and Release URL for the final report.
Read and complete [the Console gate](references/console-gate.md) before Phase 1. Verify the latest published Console asset and exact commit; if Console main is ahead, complete its release and asset verification first. A successful build alone does not satisfy this gate.
## Phase 1 — Version bump to the final target (once)
@@ -164,54 +118,9 @@ On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first
- Record `PREVIOUS_DELIVERABLE`, selected from published Releases by `publishedAt` after excluding the current tag and every `-preview.N` tag. Verify `gh release view "<preview-tag>" --json body --jq .body` contains `## What's Changed` and, when `PREVIOUS_DELIVERABLE` exists, `**Full Changelog**: https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<preview-tag>`. For a repository with no previous deliverable, verify a Full Changelog link exists and record the GitHub baseline fallback.
- Confirm preview-triggered Docker and Helm jobs are skipped. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; Docker image construction and Helm publication are deferred to the final tag because the Dockerfiles consume GitHub Release assets.
## Phase 4 — Run the artifact locally, verify the console
## Phases 45Local artifact, Console, and rc acceptance
Work inside the session scratchpad directory; never leave stray data dirs.
```bash
gh release download "<preview-tag>" -p "rustfs-macos-aarch64-v<preview-tag>.zip" -D "$SCRATCH"
cd "$SCRATCH" && unzip -o rustfs-*.zip
./rustfs --version # must report the PREVIEW TAG (build::TAG), not the Cargo.toml version, plus expected short SHA
mkdir -p data
RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin ./rustfs ./data
```
Defaults: S3 endpoint `:9000`, embedded console `:9001`.
Checks (all must pass):
- `./rustfs --version` reports the preview tag name and the short SHA of `PREVIEW_HASH`. Reporting `<target>` without the `-preview.N` suffix means the build did not embed the tag — treat as FAIL and investigate before proceeding.
- `curl -fsS http://localhost:9000/health/ready` returns ready.
- Startup log shows the embedded console being served (this was the regression that `fix(release): require embedded console assets` guards).
- Open `http://localhost:9001` in the browser: login with `rustfsadmin`/`rustfsadmin`; dashboard renders without JS console errors; create a bucket, upload a file, download it back (byte-identical), delete the object and bucket. Keep the server running for Phase 5.
## Phase 5 — Validate with the latest rc client
`rc` is the RustFS CLI client from <https://github.com/rustfs/cli>.
- Ensure the latest release is installed: compare `rc --version` against `gh api repos/rustfs/cli/releases/latest --jq .tag_name`; update via `brew upgrade rustfs/tap/rc` (or download the release binary).
- Point it at the preview server and run the command matrix, recording PASS/FAIL per command:
```bash
rc alias set preview http://localhost:9000 rustfsadmin rustfsadmin
rc ls preview/
rc mb preview/rel-check
rc cp <local-file> preview/rel-check/
rc stat preview/rel-check/<file>
rc cat preview/rel-check/<file> # matches source
rc cp preview/rel-check/<file> ./out && cmp <local-file> ./out
rc cp -r <local-dir>/ preview/rel-check/dir/
rc find preview/rel-check --name "*"
rc share download preview/rel-check/<file> --expire 1h # presigned URL fetchable via curl
rc rm preview/rel-check/<file> && rc rm -r --force preview/rel-check/dir
rc rb preview/rel-check
rc admin user list preview/
rc admin user add preview/ relcheckuser relchecksecret12
rc admin user remove preview/ relcheckuser
rc alias remove preview
```
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
Read and complete [preview acceptance](references/preview-acceptance.md): verify the downloaded binary's tag/SHA and readiness, exercise Console CRUD with byte-identical download, and pass the full latest-rc command matrix. Any failure blocks final publication. Retain the results for the confirmation gate below.
### Manual confirmation gate
@@ -0,0 +1,58 @@
# Console Release Gate
Read during Phase 0, before changing RustFS version files or tags. Follow the parent skill's release scope and authorization rules.
### Console release gate
Complete this gate before changing any RustFS version file or creating any RustFS tag. RustFS `build.yml` downloads the asset returned by `repos/rustfs/console/releases/latest`, so a successful Console build alone is insufficient.
1. Read the latest published Console tag and compare it with Console `main`:
```bash
CONSOLE_REPO="rustfs/console"
CONSOLE_LATEST=$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)
gh api "repos/${CONSOLE_REPO}/compare/${CONSOLE_LATEST}...main" \
--jq '{status, ahead_by, behind_by, commits: [.commits[] | {sha, message: .commit.message}]}'
```
- `ahead_by == 0`: no merged Console change is waiting for release. Still verify the current latest asset using step 4, then continue to Phase 1.
- `ahead_by > 0` and `behind_by == 0`: publish Console before continuing. Report the merged commits and select the next unused `vX.Y.Z` tag. Default to the next patch version when the changes are fixes or backward-compatible UI work; stop for confirmation if a minor/major bump is plausible.
- Any diverged history or `behind_by > 0`: stop and resolve the Console release baseline explicitly. Do not guess a range or publish RustFS.
2. Clone/fetch `rustfs/console` into a scratch directory and record its exact `main` commit. Before creating a tag, check for a `v*` tag or Release workflow already associated with that hash. If one is in progress, wait for it instead of creating another version:
```bash
CONSOLE_SCRATCH=$(mktemp -d)
gh repo clone "$CONSOLE_REPO" "$CONSOLE_SCRATCH/console"
git -C "$CONSOLE_SCRATCH/console" fetch origin main --tags
CONSOLE_HASH=$(git -C "$CONSOLE_SCRATCH/console" rev-parse origin/main)
git -C "$CONSOLE_SCRATCH/console" tag --points-at "$CONSOLE_HASH" 'v*'
gh run list -R "$CONSOLE_REPO" --workflow release.yml --commit "$CONSOLE_HASH" --limit 5
```
If no release exists or is running for `CONSOLE_HASH`, create the selected annotated tag at that exact hash and push it:
```bash
git -C "$CONSOLE_SCRATCH/console" tag -a "<console-tag>" -m "Release <console-tag>" "$CONSOLE_HASH"
git -C "$CONSOLE_SCRATCH/console" push origin "<console-tag>"
```
Console tags include the `v` prefix. Pushing the tag triggers `.github/workflows/release.yml` (`🚀 Release`). Remove `CONSOLE_SCRATCH` after the gate completes.
3. Find the exact tag run and wait for completion:
```bash
gh run list -R "$CONSOLE_REPO" --workflow release.yml --branch "<console-tag>" --limit 1
gh run watch -R "$CONSOLE_REPO" "<console-run-id>" --exit-status
```
4. Block until the published Release is non-draft, the latest endpoint returns the expected tag, and `rustfs-console-<console-tag>.zip` is uploaded, non-empty, and carries a `sha256:` digest:
```bash
gh release view -R "$CONSOLE_REPO" "<console-tag>" --json isDraft,isPrerelease,assets,url
test "$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)" = "<console-tag>"
test "$(gh api "repos/${CONSOLE_REPO}/releases/tags/<console-tag>" \
--jq '[.assets[] | select(.name == "rustfs-console-<console-tag>.zip" and .state == "uploaded" and .size > 0 and (.digest | startswith("sha256:")))] | length')" -eq 1
```
Treat a missing/mismatched asset, digest, latest tag, or failed/cancelled workflow as BLOCKED. Do not start Phase 1 until the Console gate passes. Record `CONSOLE_TAG`, `CONSOLE_HASH`, Console run URL, and Release URL for the final report.
@@ -0,0 +1,52 @@
# Preview Artifact Acceptance
Read after Phase 3 succeeds. Complete every check below before the parent skill's manual confirmation gate. These checks cover the downloaded artifact, embedded Console, and latest rc client.
## Phase 4 — Run the artifact locally, verify the console
Work inside the session scratchpad directory; never leave stray data dirs.
```bash
gh release download "<preview-tag>" -p "rustfs-macos-aarch64-v<preview-tag>.zip" -D "$SCRATCH"
cd "$SCRATCH" && unzip -o rustfs-*.zip
./rustfs --version # must report the PREVIEW TAG (build::TAG), not the Cargo.toml version, plus expected short SHA
mkdir -p data
RUSTFS_ACCESS_KEY=rustfsadmin RUSTFS_SECRET_KEY=rustfsadmin ./rustfs ./data
```
Defaults: S3 endpoint `:9000`, embedded console `:9001`.
Checks (all must pass):
- `./rustfs --version` reports the preview tag name and the short SHA of `PREVIEW_HASH`. Reporting `<target>` without the `-preview.N` suffix means the build did not embed the tag — treat as FAIL and investigate before proceeding.
- `curl -fsS http://localhost:9000/health/ready` returns ready.
- Startup log shows the embedded console being served (this was the regression that `fix(release): require embedded console assets` guards).
- Open `http://localhost:9001` in the browser: login with `rustfsadmin`/`rustfsadmin`; dashboard renders without JS console errors; create a bucket, upload a file, download it back (byte-identical), delete the object and bucket. Keep the server running for Phase 5.
## Phase 5 — Validate with the latest rc client
`rc` is the RustFS CLI client from <https://github.com/rustfs/cli>.
- Ensure the latest release is installed: compare `rc --version` against `gh api repos/rustfs/cli/releases/latest --jq .tag_name`; update via `brew upgrade rustfs/tap/rc` (or download the release binary).
- Point it at the preview server and run the command matrix, recording PASS/FAIL per command:
```bash
rc alias set preview http://localhost:9000 rustfsadmin rustfsadmin
rc ls preview/
rc mb preview/rel-check
rc cp <local-file> preview/rel-check/
rc stat preview/rel-check/<file>
rc cat preview/rel-check/<file> # matches source
rc cp preview/rel-check/<file> ./out && cmp <local-file> ./out
rc cp -r <local-dir>/ preview/rel-check/dir/
rc find preview/rel-check --name "*"
rc share download preview/rel-check/<file> --expire 1h # presigned URL fetchable via curl
rc rm preview/rel-check/<file> && rc rm -r --force preview/rel-check/dir
rc rb preview/rel-check
rc admin user list preview/
rc admin user add preview/ relcheckuser relchecksecret12
rc admin user remove preview/ relcheckuser
rc alias remove preview
```
- Any FAIL blocks the release. Afterwards stop the server and delete the scratch data directory.
@@ -4,17 +4,16 @@ description: "Prepare the version-file and release-asset bump for an exact RustF
---
# RustFS Release Version Bump
Use this skill to publish a RustFS release (alpha, beta, or stable) with a minimal, auditable diff and a complete ship flow (`edit -> verify -> commit -> push -> PR`).
Use this skill to prepare and verify release version files. Commit, push, and PR steps apply only when included in the user's delivery scope; publishing release tags belongs to `rustfs-release-publish`.
Validated baseline: release pattern used in PR `#2957`.
## Required inputs
- Exact target version, for example `1.0.0-beta.4`.
- Delivery scope:
- Local only (`edit/verify`).
- Local + git (`commit/push`).
- Full GitHub flow (`commit/push/PR`).
- Delivery scope: local (`edit/verify`), git (`commit/push`), or GitHub
(`commit/push/PR`). Derive it from the conversation; when unspecified, prepare
and verify locally without blocking on a delivery question.
If target version is missing or ambiguous, stop and ask before editing.
@@ -23,7 +22,7 @@ Reject any target version containing `-preview`: preview identifiers are tag-onl
## Read before editing
- `AGENTS.md` (root and nearest path-specific files).
- `.github/pull_request_template.md`.
- `.github/pull_request_template.md` only when preparing a PR.
- Current branch status and diff against `origin/main`.
## Default release file scope
@@ -50,8 +49,7 @@ Only drop a file when the current repository release process clearly no longer r
## Step-by-step workflow
1. Confirm intent and isolate scope
- Confirm target version string exactly.
- Confirm whether user requested local-only or full GitHub flow.
- Use the exact target and delivery scope already supplied; ask only for a missing or ambiguous target or a material release-policy choice.
- Inspect current branch and ensure only release-related files are touched for this task.
2. Update workspace versions
@@ -82,18 +80,18 @@ Only drop a file when the current repository release process clearly no longer r
4. Verify before shipping
- Run:
- `make pre-commit`
- If `make pre-commit` fails, return `BLOCKED` with root cause and do not silently widen scope to fix unrelated issues unless user asks.
- If `make pre-commit` fails, fix task-attributable failures and rerun affected checks. Report unresolved required checks as `BLOCKED`; do not silently widen scope to fix unrelated issues.
5. Commit strategy
5. Commit strategy (only when committing is authorized)
- Preferred split when both parts changed:
- `chore(release): prepare <version>` for `Cargo.toml` and `Cargo.lock`.
- `chore(release): align release assets for <version>` for docs and packaging files.
- If user asks for one commit, use one commit.
- Stage only intended release files; do not include unrelated working tree changes.
6. Push and PR
6. Push and PR (only for the authorized delivery scope)
- Push branch:
- `git push -u origin <branch>` (first push), or `git push` (tracking already exists).
- Use the user-requested or configured push remote: `git push -u <push-remote> <branch>` (first push), or `git push` when tracking is already configured.
- Create PR with template headings unchanged:
- `gh pr create --base main --head <branch> --title ... --body-file ...`
- PR title/body must be English.
@@ -12,8 +12,9 @@ matched security surface, the concise security reference under
## Workflow
1. Freeze the exact diff/head and identify the changed trust boundaries.
2. Read [advisory-patterns.md](references/advisory-patterns.md), then apply only
the matching sections. Useful headings are
2. Inspect the headings in [advisory-patterns.md](references/advisory-patterns.md),
then read the matching sections. Read the full map only for a broad security
audit. Useful headings are
auth/admin, IAM/STS/OIDC, policy/plugins, S3/copy/multipart, protocols, paths,
secrets/logging/RPC, browser/CORS/proxy, SSE, Object Lock, and serde.
3. Trace unauthenticated, low-privilege, wrong-action/owner/bucket, malformed,
@@ -108,9 +108,9 @@ Update this file only when an advisory adds or changes a reusable lesson, affect
### Serde deserialization and input validation
- No `#[serde(deny_unknown_fields)]` found across the entire codebase. Lesson: all structs deserialized from untrusted input (S3 API XML/JSON, lifecycle rules, bucket policies, replication configs) should have `#[serde(deny_unknown_fields)]` to reject malformed or adversarial payloads.
- Reject unknown fields in untrusted S3 API XML/JSON, lifecycle, policy, and replication input where compatibility permits. Check the current type and supported payload fixtures; do not infer the repository's current coverage from an older audit. Where extra fields are part of the compatibility contract, validate security-critical values explicitly.
- `#[serde(default)]` on security-critical fields silently accepts missing values as zero/empty. Lesson: when a field has security implications (retention days, permissions, limits), validate the deserialized value explicitly rather than relying on defaults.
- Integer fields deserialized from user input and cast with `as` (e.g., `i32 as u32`) can wrap negative values to large positives. Lesson: validate ranges before casting; use `try_into()` or clamp.
- Integer fields deserialized from user input and cast with `as` (e.g., `i32 as u32`) can wrap negative values to large positives. Lesson: validate ranges before casting; use `try_into()` with a typed error, or clamp only when the domain explicitly requires saturation.
- XML config typos (e.g., `"NoncurentDays"` instead of `"NoncurrentDays"`) are silently accepted when `deny_unknown_fields` is absent. Lesson: strict deserialization prevents silent misconfiguration that could cause data loss or unexpected retention behavior.
## Useful Search Seeds
+28 -37
View File
@@ -1,6 +1,6 @@
---
name: test-coverage-improver
description: Run project coverage checks, rank high-risk gaps, and propose high-impact tests to improve regression confidence for changed and critical code paths before release.
description: Analyze a supplied coverage report or perform an explicitly requested RustFS coverage assessment, rank uncovered risks, and propose focused tests. Do not trigger for ordinary implementation verification, a single regression test, documentation wording, or release preparation without a coverage request.
---
# Test Coverage Improver
@@ -9,58 +9,49 @@ Use this skill when you need a prioritized, risk-aware plan to improve tests fro
## Usage assumptions
- Focus scope is either changed lines/files, a module, or the whole repository.
- Coverage artifact must be generated or provided in a supported format.
- Reuse a supplied coverage artifact when its revision, scope, and format match.
- If required context is missing, call out assumptions explicitly before proposing work.
## Workflow
1. Define scope and baseline
- Confirm target language, framework, and branch.
- Confirm whether the scope is changed files only or full-repo.
- Derive the revision and scope from the request, diff, or supplied report.
- Default to the affected files/module; whole-workspace coverage requires that
scope in the request. Ask only if a wrong scope would change the result.
2. Produce coverage snapshot
- Rust: `cargo llvm-cov` (or `cargo tarpaulin`) with existing repo config.
- JavaScript/TypeScript: `npm test -- --coverage` and read `coverage/coverage-final.json`.
- Python: `pytest --cov=<pkg> --cov-report=json` and read `coverage.json`.
- Collect total, per-file, and changed-line coverage.
2. Obtain coverage evidence
- First inspect a matching existing artifact; do not regenerate it merely
because this skill was selected.
- If measurement is needed, read the Coverage section of
[the testing guide](../../../docs/testing/README.md#coverage), check disk
space/tool availability, and select package/test-scoped `cargo llvm-cov`
using the repository's nextest configuration. `make coverage` measures the
whole workspace (excluding E2E) and is only for that requested scope.
- Collect only metrics the report supports. Missing branch/changed-line
coverage is unknown, not zero.
- If measurement cannot run, continue with code-based test proposals and
mark measured coverage unverified; do not invent a coverage percentage.
3. Rank highest-risk gaps
- Prioritize changed code, branch coverage gaps, and low-confidence boundaries.
- Apply the risk rubric in [coverage-prioritization.md](references/coverage-prioritization.md).
- Keep shortlist to 58 gaps.
- Report up to 58 evidenced gaps; do not pad a small scope.
- For each gap, capture: file, lines, uncovered branches, and estimated risk score.
4. Propose high-impact tests
- For each shortlisted gap, output:
- Intent and expected behavior.
- Normal, edge, and failure scenarios.
- Assertions and side effects to verify.
- Setup needs (fixtures, mocks, integration dependencies).
- Estimated effort (`S/M/L`).
- For each gap, name the behavior and regression, distinguishing assertions,
relevant normal/edge/failure cases, necessary setup, and estimated effort.
- Include only scenarios and setup that apply; reuse shared fixture details.
5. Close with validation plan
- State which gaps remain after proposals.
- Provide concrete verification command and acceptance threshold.
- Give a scoped verification command and behavior-based acceptance criterion;
use a coverage threshold only when the task or repository requires one.
- List assumptions or blockers (environment, fixtures, flaky dependencies).
## Output template
## Report
### Coverage Snapshot
- total / branch coverage
- changed-file coverage
- top missing regions by size
### Top Gaps (ranked)
- `path:line-range` | risk score | why critical
### Test Proposals
- `path:line-range`
- Test name
- scenarios
- assertions
- effort
### Validation Plan
- command
- pass criteria
- remaining risk
Summarize the supported metrics, then combine each ranked gap with its proposed
test and validation. Include source lines only when supplied or inspected;
mark missing metrics or locations as unknown. Do not duplicate gaps and tests
in separate templates or fill empty categories for an otherwise small report.
@@ -1,4 +1,4 @@
interface:
display_name: "Test Coverage Improver"
short_description: "Find top uncovered risk areas and propose high-impact tests."
default_prompt: "Run coverage checks, identify largest gaps, and recommend highest-impact test cases to improve risk coverage."
default_prompt: "Use $test-coverage-improver to analyze coverage for the requested scope, reuse matching reports, and propose tests for evidenced risks."
+5 -2
View File
@@ -5,8 +5,11 @@ description: Debug ILM tiering / lifecycle transition issues — NoSuchVersion o
# Tier / ILM Debugging
Full playbook: [docs/operations/tier-ilm-debugging.md](../../../docs/operations/tier-ilm-debugging.md)
— read it before changing tier code.
Playbook: [docs/operations/tier-ilm-debugging.md](../../../docs/operations/tier-ilm-debugging.md).
Read the section matching the symptom: metadata/`xl.meta`, runtime versionId,
manual jobs, or retained-record recovery. Read the local-first expiry invariant
before changing cleanup ordering. Before a reconcile/disposition action, read
its entire procedure and retain its exact-evidence and confirmation gates.
Quick moves:
+5
View File
@@ -0,0 +1,5 @@
# Bound individual tool outputs retained in context; retrieve relevant ranges
# from task-owned log files when more evidence is needed.
# https://learn.chatgpt.com/docs/config-file/config-reference
tool_output_token_limit = 4000
+2 -2
View File
@@ -1,2 +1,2 @@
sha256-darwin=a953d2e05cdb5169a051a12eef1c2390066dc3fb211fab28a05e4700ae450cee
sha256-linux=e6a93961b581dc40fe90dd7d1975ab548e80bff806f5e923304432d5ecf37347
sha256-darwin=53b05ac745905809d3828c6994bdd8ecf9d20b2b61a8a9d80fe15eb62f932193
sha256-linux=7c892afa4b9d1591b46bd79c976b647109a277284fddb3b98edced4b0297eda2
+2 -93
View File
@@ -582,59 +582,13 @@ jobs:
install-build-packaging-tools: 'false'
- name: Build debug binary
run: |
python3 - <<'PYBUILD'
import hashlib
import json
import os
import pathlib
import subprocess
def git(*args):
return subprocess.check_output(["git", *args], text=True).strip()
def sha256(path):
digest = hashlib.sha256()
with pathlib.Path(path).open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
argv = ["cargo", "build", "-p", "rustfs", "--bins", "--features", "e2e-test-hooks"]
commit, tree = git("rev-parse", "HEAD"), git("rev-parse", "HEAD^{tree}")
clean_before = not git("status", "--porcelain", "--untracked-files=normal")
if not clean_before:
raise SystemExit("hooks binary requires a clean build checkout")
lock_sha256 = sha256("Cargo.lock")
lock_git_blob = git("hash-object", "Cargo.lock")
rustc = subprocess.check_output(["rustc", "-vV"], text=True)
host = next(line.removeprefix("host: ") for line in rustc.splitlines() if line.startswith("host: "))
if os.environ.get("CARGO_BUILD_TARGET") or pathlib.Path(os.environ.get("CARGO_TARGET_DIR", "target")).resolve() != pathlib.Path("target").resolve():
raise SystemExit("this artifact requires the native target/debug output")
subprocess.run(argv, check=True)
clean_after = not git("status", "--porcelain", "--untracked-files=normal")
if not clean_after or commit != git("rev-parse", "HEAD") or tree != git("rev-parse", "HEAD^{tree}") or lock_sha256 != sha256("Cargo.lock"):
raise SystemExit("hooks binary source changed while building")
manifest = {
"schema": 1, "commit": commit, "tree": tree,
"clean_before": clean_before, "clean_after": clean_after,
"lock_sha256": lock_sha256, "lock_git_blob": lock_git_blob,
"argv": argv, "profile": "debug", "target": host,
"features": ["e2e-test-hooks"],
"rustc_verbose": rustc,
"build_flags": {key: os.environ[key] for key in ("RUSTFLAGS", "CARGO_ENCODED_RUSTFLAGS", "CARGO_BUILD_TARGET", "CARGO_TARGET_DIR", "RUSTUP_TOOLCHAIN") if key in os.environ},
"binary_sha256": sha256("target/debug/rustfs"),
}
pathlib.Path("target/debug/rustfs.e2e-startup-cas-build.json").write_text(json.dumps(manifest, indent=2) + "\n")
PYBUILD
run: cargo build -p rustfs --bins --features e2e-test-hooks
- name: Upload debug binary
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: rustfs-debug-binary
path: |
target/debug/rustfs
target/debug/rustfs.e2e-startup-cas-build.json
path: target/debug/rustfs
if-no-files-found: error
retention-days: 1
@@ -958,36 +912,6 @@ jobs:
- name: Make binary executable
run: chmod +x ./target/debug/rustfs
- name: Preserve startup CAS binary input
env:
STARTUP_CAS_INPUT: ${{ runner.temp }}/rustfs-startup-cas-input
run: |
python3 - <<'PYINPUT'
import hashlib
import json
import os
import pathlib
import shutil
import subprocess
source = pathlib.Path("target/debug/rustfs")
manifest_path = source.with_name("rustfs.e2e-startup-cas-build.json")
manifest = json.loads(manifest_path.read_text())
target = pathlib.Path(os.environ["STARTUP_CAS_INPUT"])
target.mkdir(parents=True, exist_ok=True)
binary = target / "rustfs"
shutil.copy2(source, binary)
digest = hashlib.sha256()
with binary.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
commit = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
if manifest["binary_sha256"] != digest.hexdigest() or manifest["commit"] != commit:
raise SystemExit("downloaded hooks binary identity mismatch")
shutil.copy2(manifest_path, target / manifest_path.name)
binary.chmod(0o755)
PYINPUT
- name: Verify e2e full membership
env:
NEXTEST_LISTING: ${{ runner.temp }}/rustfs-e2e-full-list.json
@@ -1000,10 +924,6 @@ jobs:
# extend that filter, never add ad-hoc e2e jobs here. Reuses the downloaded
# debug binary; each test spawns its own rustfs server on a random port.
- name: Run e2e full suite
env:
RUSTFS_E2E_STARTUP_CAS_BINARY: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs
RUSTFS_E2E_STARTUP_CAS_BUILD_MANIFEST: ${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
RUSTFS_E2E_STARTUP_CAS_ARTIFACT_DIR: ${{ runner.temp }}/rustfs-startup-cas-evidence
run: cargo nextest run --profile e2e-full -p e2e_test
- name: Upload junit
@@ -1016,17 +936,6 @@ jobs:
${{ runner.temp }}/rustfs-e2e-full-list.json
retention-days: 7
- name: Upload startup CAS evidence
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: fresh-startup-cas-evidence-${{ github.run_number }}
path: |
${{ runner.temp }}/rustfs-startup-cas-evidence
${{ runner.temp }}/rustfs-startup-cas-input/rustfs.e2e-startup-cas-build.json
if-no-files-found: warn
retention-days: 7
e2e-tests-rio-v2:
name: End-to-End Tests (rio-v2)
# Inherits the schedule/dispatch-only gate through needs: on every other
+8
View File
@@ -82,6 +82,14 @@ jobs:
cache_key: e2e-odm-config-rollback
test: rc5_rollback_requires_restoring_odm_configuration
artifact: odm-config-rollback
- name: Multipart layouts survive the rc.5 upgrade
cache_key: e2e-multipart-layout-upgrade
test: direct_upgrade_from_rc5_preserves_multipart_layouts
artifact: multipart-layout-upgrade
- name: rc.5 multipart replication baseline
cache_key: e2e-multipart-layout-baseline
test: rc5_baseline_replicates_multipart_layouts
artifact: multipart-layout-baseline
runs-on: ubuntu-latest
timeout-minutes: 60
env:
+38 -123
View File
@@ -7,8 +7,11 @@ This file contains repository-wide rules. Use the nearest subdirectory
1. System/developer instructions.
2. The current user request.
3. The nearest `AGENTS.md`.
4. This file.
3. Applicable `AGENTS.md` files, with the nearest file winning conflicts.
4. Selected skills and reference documents.
Nested instructions add to ancestor rules; they do not discard non-conflicting
rules. A skill cannot expand the user's requested scope or grant authorization.
## Operating Model
@@ -21,63 +24,29 @@ This file contains repository-wide rules. Use the nearest subdirectory
- Do not load every skill or inspect unrelated modules preemptively. Select a
skill only when its description directly matches the request or changed
surface.
- Resolve repository workflow skills under `.agents/skills/` when a global
skill has the same name, unless the user explicitly selects another path.
- Avoid repeated reads and equivalent verification commands once enough
evidence exists.
- Search for relevant symbols/headings before reading long files; return only
matching ranges. If output is truncated, narrow the query instead of repeating
a full read. Keep reusable raw logs in task artifacts and report the evidence.
- Reuse authorization already given in the conversation. Resolve routine choices
within that scope and continue independent work while a material question is
pending. Before requesting missing approval, prepare the concrete result that
is already authorized; retain explicit merge and release gates.
## Worktree and Disk Hygiene
## Task-Specific Guidance
- Start implementation from the latest `origin/main` and confirm the requested
change is not already present.
- An existing clean, isolated task worktree is sufficient. Create another
worktree only when the current checkout is shared, dirty with unrelated work,
or belongs to another task.
- Never commit from a shared checkout.
- Use a task-specific branch named `<type>/<topic>`, such as `fix/...`,
`feat/...`, `test/...`, or `docs/...`, unless the user specifies a name.
- Do not include agent, tool, contributor, account, or organization names in
branch names.
- Push to the user-requested remote or the repository's configured push remote.
Do not hard-code or infer a remote from an account name.
- Check free space before artifact-heavy builds, tests, coverage, or downloads.
Re-check before a broad gate when space is tight.
- Remove only task-owned temporary/build artifacts. Never delete another task's
worktree or uncommitted data.
- At handoff, mention disk or cleanup details only when they affected execution
or artifacts/worktrees remain intentionally.
Read only the reference needed for the current task, once per unchanged context:
## Change Style
- Preserve existing control flow unless changing it is required for correctness.
- Prefer a direct local edit over new files, wrappers, managers, or speculative
abstractions.
- Add a helper only when it removes current duplication, names a real domain
boundary, or isolates a non-trivial invariant.
- Remove an in-scope path superseded by the change. If compatibility requires it,
adapt at the boundary to one canonical core and use the repository's
`RUSTFS_COMPAT_TODO` policy.
- Comments explain non-obvious invariants or reasons. Do not narrate code or
record change history.
- Mention unrelated problems when useful; do not fix them in a narrow task.
## Reuse and Boundary Rules
- Before adding helpers, constants, fixtures, or wrappers, search the touched
crate, the domain-owning crate, `crates/utils`, `crates/common`, and relevant
direct dependencies.
- Reuse requires matching semantics: normalization, error types, deadlines,
durability, and compatibility must fit the call site. A narrowly named local
helper is better than forced reuse with different semantics.
- Validate untrusted input at its trust boundary, then trust the validated type.
Values crossing disk, RPC, persistence, or version boundaries remain
untrusted at every consumer.
- Re-check boundary values immediately before destructive actions such as
delete, overwrite, or quorum decisions.
- Every new branch needs a concrete triggering input/state. For decoded or peer
data, corruption and mixed-version input are valid triggers.
- Required values must return a typed error when absent or corrupt; do not use a
default that converts corruption into a plausible result.
- Attach error context once where it is actionable. Do not erase typed errors
below aggregation or quorum layers.
- Before code changes or artifact-heavy work, read [implementation rules](.agents/references/implementation.md).
For a read-only code review, use its change-style and boundary sections as needed.
- Before commits, pushes, PR creation/updates, or posting to PRs/issues/discussions,
read [Git and PR rules](.agents/references/pull-requests.md).
Reuse existing authorization; a reference does not authorize posting, merging, or publishing.
- Preserve unrelated work. Never commit from a shared checkout or delete another task's artifacts.
- Source comments, commits, PR titles, and PR bodies are in English.
## Sources of Truth
@@ -147,73 +116,25 @@ requested adversarial/design reviews, and agent-instruction changes that alter
execution. Ordinary questions, diagnoses, status reports, non-adversarial code
reviews, and low-risk planning do not trigger it.
Risk and review shape:
For applicable work and substantial PR reviews, read the [risk tiers and review shape](.agents/references/adversarial-validation.md).
Load only the matching domain probes; ordinary reviews do not become adversarial
merely because this reference exists.
- **Exempt:** documentation, comments, formatting, or typos with no runtime,
build, test, or agent-execution effect.
- **Mechanical:** renames, moves, test/tooling-only changes, and agent-rule
changes. Run correctness and simplicity lenses.
- **Standard:** localized behavior changes. Run one integrated final-diff pass
covering correctness, simplicity, and test coverage; add only domain lenses
matched by the diff.
- **High risk / substantial PR review:** high risk includes locking,
erasure/quorum/heal, replication, multipart, RPC, lifecycle/tiering,
persistence/fsync, IAM/KMS/auth, cryptography, on-disk/on-wire formats, and
S3-visible semantics. Cover all applicable lenses using exactly two
independent reviewers when delegation is explicitly authorized. Split the
lenses between them. Otherwise perform two fresh sequential passes.
- **Outbound client defaults:** what `TargetClient`, `PutObjectOptions`, or
the remote SDK configuration sends to every replication or migration target
is high risk for every target class even when the change fixes one. Follow
the SOP in `docs/postmortems/2026-09-03-replication-checksum-default-regression.md`:
run the outbound target matrix, document each new env knob in the same PR,
and list verified and unverified target classes in the PR Impact section.
A review has no finding quota; `No findings` is a complete outcome. A request to
find problems is not evidence that a defect exists. Before reporting a candidate,
check callers, invariants, and existing tests for evidence that disproves it.
Findings need `file:line` and a concrete failure or violation of an explicit
requirement. Missing required tests/checks are verification gaps, not proof of a
runtime bug; name the unprotected behavior or unmet gate. Keep optional style or
refactoring preferences out of defect findings unless that review was requested.
Available domain lenses are security, concurrency/durability, compatibility,
and performance. Select `.agents/skills/adversarial-validation/SKILL.md` for an
explicit adversarial request, a high-risk change, or a substantial PR review;
then read only its matching role references. A routine standard pass does not
load the playbook unless the reviewer needs a RustFS-specific probe.
A finding must name a concrete input/state/interleaving and wrong outcome, or a
specific missing regression check, with `file:line`. Resolve it by fixing the
diff or rebutting it with code-path/test/invariant evidence. After a non-trivial
fix, rerun only affected lenses.
Fix or rebut supported findings within the authorized scope. Once the required
passes are complete, stop. Reopen only for changed code, new evidence, an
unresolved finding, or an explicit re-review request; an unchanged diff does not
need another pass at every conversation turn or workflow handoff.
For high-risk PRs, record one concise verdict per covered lens in the PR body.
## Pull Request Lifecycle
- Creating or updating a PR includes one immediate snapshot of checks,
mergeability, reviews, and unresolved threads.
- Unless the user explicitly requests monitoring, a release workflow requires
it, or an automation already owns it, hand off after the PR is open with the
current state and next event to watch. Do not delay ordinary handoff with
fixed quiet-period sleeps.
- For requested monitoring, use event-driven or bounded waits. Report only state
changes, actionable failures, or a meaningful prolonged delay.
- Investigate failures/comments before changing code. Fix task-attributable
issues, rerun affected verification, push, reply or resolve the thread, then
resume the requested monitor.
- Never merge without required reviewer approval or explicit authority.
- After an observed merge, verify the commit reached the base, then clean the
task worktree/branch when safe. Preserve unmerged work for closed PRs unless
deletion was explicitly authorized.
## Git and PR Baseline
- Follow Conventional Commits; keep the subject at most 72 characters.
- Source comments, commits, PR titles, and PR bodies are in English.
- Keep every heading from `.github/pull_request_template.md`; use `N/A` where
needed and include commands actually run.
- Use `--body-file` for multiline `gh pr create`/`gh pr edit` content.
- PR/issue/discussion content must not contain the literal sequence `\n` or
hard-wrapped prose paragraphs.
- Do not include local absolute paths or tool-specific labels/prefixes in GitHub
content.
- Resolve review threads after the underlying issue is fixed. If declining a
suggestion, reply with a short evidence-based reason.
## Security Baseline
- Never commit secrets, credentials, or key material.
@@ -250,12 +171,6 @@ Use `.agents/skills/rustfs-logging-governance/SKILL.md` for logging changes.
- `DataUsageCacheInfo` and `DataUsageEntry` keep their hand-written map
serialization and new fields remain `#[serde(default)]` for older readers.
## Naming
Use Rust API naming: `SCREAMING_SNAKE_CASE` constants/statics, `snake_case`
functions/variables, and `PascalCase` types. Do not rename unrelated existing
violations.
## Scoped Guidance
Before editing, locate the nearest instructions with:
@@ -265,4 +180,4 @@ git ls-files '*AGENTS.md'
```
The nearest file wins for domain invariants. Keep generic workflow and
validation policy in this root file.
validation policy in this root file and its task-specific references.
+2 -2
View File
@@ -13,7 +13,7 @@ what Claude Code needs on top: commands and pointers.
cargo build --release --bin rustfs # production binary
cargo check -p <crate> # fast type-check one crate
cargo test -p <crate> # test one crate
cargo fmt --all # format (required before PR)
cargo fmt --all --check # for Rust changes; see AGENTS.md verification tiers
make pre-commit # fast gate: fmt + arch checks + quick-check (NO clippy/tests)
make pre-pr # optional full gate for broad cross-module changes
make build-docker BUILD_OS=ubuntu22.04
@@ -42,5 +42,5 @@ make build-docker BUILD_OS=ubuntu22.04
Repo-wide domain invariants (dual internal metadata keys, defensive UUID
reads, unversioned tier buckets) live in [AGENTS.md](AGENTS.md) under
"Cross-Cutting Domain Invariants" — read them before touching metadata or
"Cross-Cutting Storage Invariants" — read them before touching metadata or
tiering code.
Generated
-1
View File
@@ -4057,7 +4057,6 @@ dependencies = [
"sha1 0.11.0",
"sha2 0.11.0",
"suppaftp",
"tempfile",
"time",
"tokio",
"tokio-stream",
+5 -2
View File
@@ -33,8 +33,11 @@ Applies to all paths under `crates/`.
## Type Casting
- Never use `as` for numeric conversions that may truncate or overflow. Use `try_into()` with explicit error handling, or clamp with `value.max(0) as usize` when the domain is bounded.
- `f64 as usize` saturates but is fragile; clamp to `[0, usize::MAX as f64]` first.
- Never use `as` for numeric conversions that may truncate or overflow. Use
`try_into()` with typed error handling; clamp or saturate only when the domain
explicitly requires it.
- Before converting floating-point input to an integer, validate finiteness,
sign, and the destination range. A lower-bound clamp alone is insufficient.
- Treat every `as` cast in a PR review as a potential bug; require justification.
## Testing
+5
View File
@@ -66,6 +66,11 @@ Current guidance:
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
## S3 API environment variables
- `RUSTFS_API_OBJECT_MAX_VERSIONS` caps the number of retained versions for a single object. It defaults to `9223372036854775807`, matching MinIO's practical-unlimited default. Set a positive integer to enforce a lower per-object metadata bound.
- `MINIO_API_OBJECT_MAX_VERSIONS` is accepted as a compatibility alias when the canonical RustFS variable is not set.
## Distributed endpoint locality
- `RUSTFS_LOCAL_ENDPOINT_HOST` identifies this server's host in a distributed `RUSTFS_VOLUMES` topology without resolving every peer during startup. Set it to exactly one host, without a scheme, port, or path. It is accepted only for orchestrated URL topologies and must match at least one endpoint on the RustFS server port; invalid or unmatched values fail startup. Leave it unset to retain DNS-based locality discovery.
+12
View File
@@ -90,3 +90,15 @@ pub const ENV_API_MAX_CONNECTIONS: &str = "RUSTFS_API_MAX_CONNECTIONS";
/// Default for `RUSTFS_API_MAX_CONNECTIONS` (`0` = unlimited).
pub const DEFAULT_API_MAX_CONNECTIONS: usize = 0;
/// Maximum retained versions per object.
///
/// The default follows MinIO and is effectively unlimited for practical
/// deployments. Operators can lower it to bound per-object metadata growth.
/// Environment variable: RUSTFS_API_OBJECT_MAX_VERSIONS
/// MinIO-compatible alias: MINIO_API_OBJECT_MAX_VERSIONS
/// Example: RUSTFS_API_OBJECT_MAX_VERSIONS=50000
pub const ENV_API_OBJECT_MAX_VERSIONS: &str = "RUSTFS_API_OBJECT_MAX_VERSIONS";
/// Default for `RUSTFS_API_OBJECT_MAX_VERSIONS`.
pub const DEFAULT_API_OBJECT_MAX_VERSIONS: u64 = 9_223_372_036_854_775_807;
-3
View File
@@ -144,6 +144,3 @@ russh = { workspace = true, features = ["serde"] }
russh-sftp = { workspace = true }
zip.workspace = true
clap = { workspace = true, features = ["derive", "env"] }
[dev-dependencies]
tempfile.workspace = true
File diff suppressed because it is too large Load Diff
-2
View File
@@ -118,8 +118,6 @@ hotpath-cpu = [
# injection, xl.meta transition assertions) via `api::tier::test_util`.
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
test-util = []
# Observes real startup CAS only in the dedicated E2E binary.
e2e-test-hooks = []
[dependencies]
hotpath.workspace = true
+2 -4
View File
@@ -384,8 +384,6 @@ pub mod data_usage {
pub mod disk {
pub use crate::disk::disk_store::get_object_disk_read_timeout;
pub use crate::disk::local::ScanGuard;
#[cfg(all(feature = "test-util", not(windows)))]
pub use crate::disk::os::{LocalPublicationPause, LocalPublicationStage};
pub use crate::disk::{
BATCH_READ_VERSION_MAX_ITEMS, BUCKET_META_PREFIX, BatchReadVersionItem, BatchReadVersionReq, BatchReadVersionResp,
CheckPartsResp, ConditionalFileUpdate, DeleteOptions, Disk, DiskAPI, DiskInfo, DiskInfoOptions, DiskLocation, DiskOption,
@@ -566,8 +564,8 @@ pub mod storage {
pub use crate::core::pools::HealLifecycleExpiryContext;
pub use crate::store::HealWalkVersion;
pub use crate::store::{
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk,
all_local_disk_path, find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk, all_local_disk_path,
find_local_disk_by_ref, init_local_disks, init_local_disks_with_instance_ctx, init_lock_clients,
prewarm_local_disk_id_map, prewarm_local_disk_id_map_with_instance_ctx,
};
}
+11 -142
View File
@@ -5283,49 +5283,7 @@ async fn read_pool_meta_replicas<S>(pools: Vec<Arc<S>>, no_lock: bool) -> Vec<Po
where
S: EcstoreObjectIO,
{
let reads = join_all(pools.into_iter().map(|pool| read_pool_meta_replica(pool, no_lock))).await;
#[cfg(feature = "e2e-test-hooks")]
if STARTUP_CAS_OBSERVATION.try_with(|_| ()).is_ok() {
let batch = uuid::Uuid::new_v4();
for (pool, read) in reads.iter().enumerate() {
let mut observation = serde_json::json!({
"kind": "replica-read", "object": POOL_META_NAME, "batch": batch, "pool": pool,
"cas": match &read.cas {
PoolMetaCasToken::Missing => "missing",
PoolMetaCasToken::Existing(_) => "existing",
PoolMetaCasToken::Unsafe => "unsafe",
},
"etag": match &read.cas { PoolMetaCasToken::Existing(etag) => Some(etag), _ => None },
});
match &read.replica {
PoolMetaReplica::Valid {
raw,
canonical,
meta,
revision,
committed,
..
} => {
observation["state"] = serde_json::json!("valid");
observation["committed"] = serde_json::json!(committed);
observation["version"] = serde_json::json!(revision.version);
observation["cluster_id"] = serde_json::json!(revision.cluster_id);
observation["epoch"] = serde_json::json!(revision.epoch);
observation["generation"] = serde_json::json!(revision.generation);
observation["transaction_id"] = serde_json::json!(revision.transaction_id);
observation["pool_count"] = serde_json::json!(meta.pools.len());
observation["payload_sha256"] = serde_json::json!(rustfs_utils::crypto::hex(Sha256::digest(canonical)));
observation["raw_sha256"] = serde_json::json!(rustfs_utils::crypto::hex(Sha256::digest(raw)));
}
PoolMetaReplica::Missing => observation["state"] = serde_json::json!("missing"),
PoolMetaReplica::Corrupt(_) => observation["state"] = serde_json::json!("corrupt"),
PoolMetaReplica::Incompatible(_) => observation["state"] = serde_json::json!("incompatible"),
PoolMetaReplica::Unreadable(_) => observation["state"] = serde_json::json!("unreadable"),
}
startup_cas_test_observe(observation);
}
}
reads
join_all(pools.into_iter().map(|pool| read_pool_meta_replica(pool, no_lock))).await
}
fn select_pool_meta_replicas_observing<R>(write_state: &mut PoolMetaWriteState, replicas: Vec<R>) -> Result<PoolMetaSelection>
@@ -5766,60 +5724,6 @@ fn pool_meta_cas_preconditions(token: &PoolMetaCasToken, object: &str) -> Result
}
}
#[cfg(feature = "e2e-test-hooks")]
struct StartupCasObservation {
attempt: uuid::Uuid,
phase: &'static str,
pools: Vec<usize>,
}
#[cfg(feature = "e2e-test-hooks")]
tokio::task_local! {
static STARTUP_CAS_OBSERVATION: StartupCasObservation;
}
// This scope follows only the directly polled startup future. Spawned work
// does not inherit it; receiver evidence retains its existing RPC tuple.
#[cfg(feature = "e2e-test-hooks")]
pub(crate) async fn startup_cas_test_scope<S, F: std::future::Future>(
attempt: uuid::Uuid,
phase: &'static str,
pools: &[Arc<S>],
future: F,
) -> F::Output {
STARTUP_CAS_OBSERVATION
.scope(
StartupCasObservation {
attempt,
phase,
// These identities are never dereferenced or logged. The
// caller and operation keep the same pool Arcs alive.
pools: pools.iter().map(|pool| Arc::as_ptr(pool) as usize).collect(),
},
future,
)
.await
}
// Direct JSON diagnostics are independent of the startup tracing subscriber.
#[cfg(feature = "e2e-test-hooks")]
pub(crate) fn startup_cas_test_observe(mut observation: serde_json::Value) {
let Some(nonce) = std::env::var("RUSTFS_E2E_STARTUP_CAS_NONCE")
.ok()
.and_then(|value| uuid::Uuid::parse_str(&value).ok())
else {
return;
};
observation["nonce"] = serde_json::json!(nonce);
observation["pid"] = serde_json::json!(std::process::id());
let _ = STARTUP_CAS_OBSERVATION.try_with(|scope| {
observation["attempt"] = serde_json::json!(scope.attempt);
observation["startup_phase"] = serde_json::json!(scope.phase);
});
let line = format!("RUSTFS_E2E_STARTUP_CAS {observation}\n");
let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), line.as_bytes());
}
async fn save_pool_meta_object_cas<S>(
pool: Arc<S>,
object: &str,
@@ -5841,20 +5745,6 @@ where
..Default::default()
};
fence.add_to_options(&mut opts);
#[cfg(feature = "e2e-test-hooks")]
let observation = std::env::var_os("RUSTFS_E2E_STARTUP_CAS_NONCE").map(|_| {
serde_json::json!({
"kind": "cas", "object": object, "phase": phase,
"pool": STARTUP_CAS_OBSERVATION.try_with(|scope| {
scope.pools.iter().position(|identity| *identity == Arc::as_ptr(&pool) as usize)
}).ok().flatten(),
"payload_sha256": rustfs_utils::crypto::hex(Sha256::digest(&data)),
"if_match": opts.http_preconditions.as_ref().and_then(|p| p.if_match.as_deref()),
"if_none_match": opts.http_preconditions.as_ref().and_then(|p| p.if_none_match.as_deref()),
"tail_drained": opts.write_completion == crate::object_api::WriteCompletion::TailDrained,
"no_lock": opts.no_lock,
})
});
// Cancellation can happen at the very first poll of the storage future.
// Arm before dispatch, but not during read/encode/fence preflight.
let previous_phase = transaction_arm.phase;
@@ -5864,37 +5754,23 @@ where
record_pool_meta_stale_write_rejection(phase);
transaction_arm.phase = previous_phase;
}
let result = match result {
Ok(object_info) => fence.ensure_held().map(|()| object_info),
let object_info = match result {
Ok(info) => info,
Err(err) => {
let source = Arc::new(err);
transaction_arm.source = Some(Arc::clone(&source));
if matches!(source.as_ref(), Error::PreconditionFailed) {
Err(Error::PreconditionFailed)
} else {
Err(Error::other(pool_metadata_error(
crate::error::PoolMetadataFailure::TransactionUnknown,
phase,
Some(source),
)))
return Err(Error::PreconditionFailed);
}
return Err(Error::other(pool_metadata_error(
crate::error::PoolMetadataFailure::TransactionUnknown,
phase,
Some(source),
)));
}
};
#[cfg(feature = "e2e-test-hooks")]
if let Some(mut observation) = observation {
observation["ok"] = serde_json::json!(result.is_ok());
observation["etag"] = serde_json::json!(result.as_ref().ok().and_then(|info| info.etag.as_deref()));
observation["mod_time"] = serde_json::json!(
result
.as_ref()
.ok()
.and_then(|info| info.mod_time)
.map(|time| time.unix_timestamp_nanos().to_string())
);
observation["error"] = serde_json::json!(result.as_ref().err().map(ToString::to_string));
startup_cas_test_observe(observation);
}
result
fence.ensure_held()?;
Ok(object_info)
}
async fn persist_pool_meta_identity<S>(
@@ -7426,13 +7302,6 @@ impl PoolMeta {
};
if confirmed.revision == revision && confirmed.canonical.as_ref() == Some(&durable) {
persist_pool_meta_identity(pools, write_state, true, fence, transaction_arm).await?;
#[cfg(feature = "e2e-test-hooks")]
startup_cas_test_observe(serde_json::json!({
"kind": "confirmed", "object": POOL_META_NAME,
"payload_sha256": rustfs_utils::crypto::hex(Sha256::digest(&durable)),
"generation": confirmed.revision.generation,
"transaction_id": confirmed.revision.transaction_id,
}));
return Ok(confirmed.meta);
}
if !commit_succeeded {
+1
View File
@@ -425,6 +425,7 @@ impl From<rustfs_filemeta::Error> for DiskError {
rustfs_filemeta::Error::FileVersionNotFound => DiskError::FileVersionNotFound,
rustfs_filemeta::Error::FileCorrupt => DiskError::FileCorrupt,
rustfs_filemeta::Error::MethodNotAllowed => DiskError::MethodNotAllowed,
rustfs_filemeta::Error::MaxVersionsExceeded => DiskError::MaxVersionsExceeded,
e => DiskError::other(e),
}
}
+4 -52
View File
@@ -263,7 +263,7 @@ pub(crate) mod fsync_dir_recorder {
}
/// Pause a real namespace mutation inside its physical executor.
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
#[cfg(all(test, not(windows)))]
pub(crate) mod prepared_publication_test_hooks {
use super::*;
@@ -272,9 +272,7 @@ pub(crate) mod prepared_publication_test_hooks {
PreparedRename,
Rename,
Remove,
#[cfg(test)]
Rollback,
#[cfg(test)]
DirFsync,
}
@@ -290,7 +288,6 @@ pub(crate) mod prepared_publication_test_hooks {
}
}
#[cfg(test)]
pub(crate) fn install(path: &Path, hook: impl FnOnce() + Send + 'static) -> Guard {
install_at(Stage::PreparedRename, path, hook)
}
@@ -349,51 +346,6 @@ pub(crate) mod prepared_publication_test_hooks {
}
}
/// Controlled application-test pause at an existing physical executor boundary.
#[cfg(all(feature = "test-util", not(windows)))]
pub struct LocalPublicationPause {
_hook: prepared_publication_test_hooks::Guard,
entered: oneshot::Receiver<()>,
_release: std::sync::mpsc::Sender<()>,
}
#[cfg(all(feature = "test-util", not(windows)))]
#[derive(Clone, Copy)]
pub enum LocalPublicationStage {
PreparedRename,
Rename,
Remove,
}
#[cfg(all(feature = "test-util", not(windows)))]
impl LocalPublicationPause {
pub fn install(disk: &crate::disk::Disk, volume: &str, path: &str, stage: LocalPublicationStage) -> Result<Self> {
let path = disk
.get_object_path_for_io_if_local(volume, path)
.ok_or(DiskError::DiskNotFound)??;
let stage = match stage {
LocalPublicationStage::PreparedRename => prepared_publication_test_hooks::Stage::PreparedRename,
LocalPublicationStage::Rename => prepared_publication_test_hooks::Stage::Rename,
LocalPublicationStage::Remove => prepared_publication_test_hooks::Stage::Remove,
};
let (entered_tx, entered) = oneshot::channel();
let (release, release_rx) = std::sync::mpsc::channel::<()>();
let hook = prepared_publication_test_hooks::install_at(stage, &path, move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
});
Ok(Self {
_hook: hook,
entered,
_release: release,
})
}
pub async fn entered(&mut self) -> std::result::Result<(), oneshot::error::RecvError> {
(&mut self.entered).await
}
}
#[cfg(all(test, windows))]
pub(crate) mod windows_rename_test_hooks {
use super::*;
@@ -2024,7 +1976,7 @@ pub(crate) async fn remove_file_with_owner(
let path = path.as_ref().to_path_buf();
let lease = acquire_namespace_mutation_lease_with_owner(&path, namespace_owner).await;
run_blocking_namespace_operation(lease, move || {
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
#[cfg(all(test, not(windows)))]
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Remove, &path);
std::fs::remove_file(path)
})
@@ -2284,7 +2236,7 @@ pub(crate) async fn rename_all_with_prepared_source(
move || {
validate_prepared_rename_source(&prepared_source, &src_file_path)?;
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
#[cfg(any(test, feature = "test-util"))]
#[cfg(test)]
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::PreparedRename, &dst_file_path);
rename_prepared(&src_file_path, &dst_file_path, &preparation)
}
@@ -2417,7 +2369,7 @@ async fn reliable_rename_inner_with_lease(
let preparation = prepare_rename_with_retry(&src_file_path, &dst_file_path, &base_dir, &publication_root)?;
#[cfg(all(test, not(windows)))]
prepared_publication_test_hooks::run_rename_destination(&src_file_path, &dst_file_path);
#[cfg(all(any(test, feature = "test-util"), not(windows)))]
#[cfg(all(test, not(windows)))]
{
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &src_file_path);
prepared_publication_test_hooks::run(prepared_publication_test_hooks::Stage::Rename, &dst_file_path);
+1
View File
@@ -588,6 +588,7 @@ impl From<rustfs_filemeta::Error> for StorageError {
rustfs_filemeta::Error::FileVersionNotFound => StorageError::FileVersionNotFound,
rustfs_filemeta::Error::FileCorrupt => StorageError::FileCorrupt,
rustfs_filemeta::Error::Unexpected => StorageError::Unexpected,
rustfs_filemeta::Error::MaxVersionsExceeded => StorageError::MaxVersionsExceeded,
rustfs_filemeta::Error::Io(io_error) => io_error.into(),
_ => StorageError::Io(std::io::Error::other(e)),
}
+4 -19
View File
@@ -630,27 +630,14 @@ impl ECStore {
.pools
.first()
.is_some_and(|pool| pool_first_endpoint_is_local(&pool.endpoints));
#[cfg(feature = "e2e-test-hooks")]
let startup_attempt = uuid::Uuid::new_v4();
let (meta, pool_meta_replica_state) = {
let mut write_state = self.pool_meta_save_gate.lock().await;
establish_pool_meta_bootstrap_identity_if_proven(self.pools.clone(), &mut write_state, should_persist_pool_meta)
.await
.map_err(|err| Error::other(format!("store init failed during establish_pool_meta_bootstrap_identity: {err}")))?;
let load = load_pool_meta_for_startup(self.pools.clone(), &mut write_state);
#[cfg(feature = "e2e-test-hooks")]
let load = crate::core::pools::startup_cas_test_scope(startup_attempt, "load", &self.pools, load);
load.await?
load_pool_meta_for_startup(self.pools.clone(), &mut write_state).await?
};
let update = meta.validate(self.pools.clone())?;
#[cfg(feature = "e2e-test-hooks")]
crate::core::pools::startup_cas_test_observe(serde_json::json!({
"kind": "startup-classifier", "attempt": startup_attempt,
"elected_writer": should_persist_pool_meta,
"needs_repair": pool_meta_replica_state.needs_repair,
"repair_write_safe": pool_meta_replica_state.repair_write_safe,
"topology_update": update,
}));
let endpoints = runtime_sources::endpoint_pools_or_default();
let mut installed_pool_meta = if update {
@@ -662,17 +649,15 @@ impl ECStore {
// distributed startup can race on the same lock and replay the prior init bug.
{
let mut write_state = self.pool_meta_save_gate.lock().await;
let persist = persist_pool_meta_for_startup_if_safe(
installed_pool_meta = persist_pool_meta_for_startup_if_safe(
&installed_pool_meta,
self.pools.clone(),
pool_meta_replica_state,
&mut write_state,
update,
should_persist_pool_meta,
);
#[cfg(feature = "e2e-test-hooks")]
let persist = crate::core::pools::startup_cas_test_scope(startup_attempt, "persist", &self.pools, persist);
installed_pool_meta = persist.await?;
)
.await?;
}
{
+2 -2
View File
@@ -442,7 +442,7 @@ pub(crate) mod utils;
use peer::init_local_peer;
pub use peer::{
BootstrapLocalTarget, all_local_disk, all_local_disk_path, find_local_disk_by_ref, get_disk_infos, init_local_disks,
all_local_disk, all_local_disk_path, find_local_disk_by_ref, get_disk_infos, init_local_disks,
init_local_disks_with_instance_ctx, init_lock_clients, prewarm_local_disk_id_map,
prewarm_local_disk_id_map_with_instance_ctx,
};
@@ -1812,7 +1812,7 @@ mod tests {
// Build a minimal ECStore carrying an explicit instance context. Empty
// pools/disks are sufficient: the Phase 5 accessors read only `self.ctx`.
pub(super) fn build_store_with_ctx(ctx: Arc<InstanceContext>) -> Arc<ECStore> {
fn build_store_with_ctx(ctx: Arc<InstanceContext>) -> Arc<ECStore> {
let endpoint_pools = EndpointServerPools::default();
Arc::new(ECStore {
id: uuid::Uuid::new_v4(),
+1 -859
View File
@@ -13,10 +13,7 @@
// limitations under the License.
use super::*;
use crate::bucket::utils::has_bad_path_component;
use crate::disk::error::{DiskError, Result as DiskResult};
use crate::disk::{DeleteOptions, Disk, RenameDataGuards, RenameDataResp};
use crate::runtime::instance::{InstanceContext, NamespaceCommitGuard};
use crate::runtime::instance::InstanceContext;
use crate::runtime::sources as runtime_sources;
use tracing::{debug, error};
@@ -25,204 +22,6 @@ const LOG_SUBSYSTEM_DISK_STARTUP: &str = "disk_startup";
const EVENT_LOCAL_DISK_ID_PREWARM_SKIPPED: &str = "local_disk_id_prewarm_skipped";
const EVENT_LOCK_CLIENT_INITIALIZATION_FAILED: &str = "lock_client_initialization_failed";
/// An instance-bound capability for internal writes before ECStore/IAM startup.
/// Its private context and volume checks cannot be replaced by a caller guard.
#[derive(Clone)]
pub struct BootstrapLocalTarget {
ctx: Arc<InstanceContext>,
}
impl BootstrapLocalTarget {
pub fn new(ctx: Arc<InstanceContext>) -> Self {
Self { ctx }
}
pub fn is_for_store(&self, store: &ECStore) -> bool {
Arc::ptr_eq(&self.ctx, &store.ctx)
}
pub async fn rename_local_data(
&self,
disk_ref: &str,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
scanner_token: Option<Uuid>,
) -> DiskResult<RenameDataResp> {
if scanner_token.is_some() {
return Err(DiskError::other("bootstrap rename cannot use a scanner publication lease"));
}
validate_bootstrap_volume(source.0)?;
validate_bootstrap_volume(destination.0)?;
rename_local_data_with_ctx(&self.ctx, disk_ref, source, fi, destination, RenameDataGuards::default()).await
}
pub async fn undo_local_write(
&self,
disk_ref: &str,
volume: &str,
path: &str,
fi: FileInfo,
opts: DeleteOptions,
) -> DiskResult<()> {
validate_bootstrap_volume(volume)?;
undo_local_write_with_ctx(&self.ctx, disk_ref, volume, path, fi, opts).await
}
}
fn validate_bootstrap_volume(volume: &str) -> DiskResult<()> {
// Prefix membership alone permits aliases such as .rustfs.sys/../bucket.
// Validate both raw rename volumes before any disk lookup or admission.
if has_bad_path_component(volume) || !is_meta_bucketname(volume) {
return Err(DiskError::FileAccessDenied);
}
Ok(())
}
impl ECStore {
/// Execute on this instance's active local disk through the physical owner.
pub async fn rename_local_data(
&self,
disk_ref: &str,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
scanner_token: Option<Uuid>,
) -> DiskResult<RenameDataResp> {
let external_guard: Option<Arc<dyn Send + Sync>> = if let Some(token) = scanner_token {
Some(Arc::new(
self.acquire_scanner_publication_lease_guard(token)
.await
.map_err(|err| DiskError::other(err.to_string()))?,
))
} else {
None
};
rename_local_data_with_ctx(
&self.ctx,
disk_ref,
source,
fi,
destination,
RenameDataGuards {
scanner_publication_lease_token: scanner_token,
external_guard,
namespace_owner: None,
},
)
.await
}
pub async fn undo_local_write(
&self,
disk_ref: &str,
volume: &str,
path: &str,
fi: FileInfo,
opts: DeleteOptions,
) -> DiskResult<()> {
undo_local_write_with_ctx(&self.ctx, disk_ref, volume, path, fi, opts).await
}
}
// The optional ID is a cold lookup to cache only after final admission.
async fn local_disk_candidate(ctx: &Arc<InstanceContext>, disk_ref: &str) -> DiskResult<(DiskStore, Option<Uuid>)> {
let map = ctx.local_disk_map();
if let Some(disk) = map.read().await.get(disk_ref).and_then(Option::as_ref).cloned() {
return Ok((disk, None));
}
let disk_id = Uuid::parse_str(disk_ref).map_err(|_| DiskError::DiskNotFound)?;
let cached_path = ctx.local_disk_id_map().read().await.get(&disk_id).cloned();
if let Some(path) = cached_path {
let cached_disk = map.read().await.get(&path).and_then(Option::as_ref).cloned();
if let Some(disk) = cached_disk
&& matches!(disk.as_ref(), Disk::Local(_))
&& disk.get_disk_id().await? == Some(disk_id)
{
return Ok((disk, None));
}
}
let disks: Vec<_> = map.read().await.values().filter_map(Clone::clone).collect();
// Disk identity may perform format I/O. No registry guard spans this await.
for disk in disks {
if matches!(disk.as_ref(), Disk::Local(_)) && disk.get_disk_id().await.ok().flatten() == Some(disk_id) {
return Ok((disk, Some(disk_id)));
}
}
Err(DiskError::DiskNotFound)
}
async fn admit_local_disk(
ctx: &Arc<InstanceContext>,
disk: &DiskStore,
disk_id: Option<Uuid>,
mutates_namespace: bool,
) -> DiskResult<Option<Arc<NamespaceCommitGuard>>> {
if !matches!(disk.as_ref(), Disk::Local(_)) {
return Err(DiskError::DiskNotFound);
}
let map = ctx.local_disk_map();
let active = map.read().await;
if !active
.get(&disk.endpoint().to_string())
.and_then(Option::as_ref)
.is_some_and(|current| Arc::ptr_eq(current, disk))
{
return Err(DiskError::DiskNotFound);
}
// Preserve registry -> ID-cache lock order; no filesystem I/O under either.
if let Some(disk_id) = disk_id {
ctx.local_disk_id_map()
.write()
.await
.insert(disk_id, disk.endpoint().to_string());
}
// Admission linearizes under the registry read: replacement/quarantine
// before this point rejects; later changes do not revoke physical I/O.
Ok(mutates_namespace.then(|| ctx.begin_namespace_commit()))
}
async fn rename_local_data_with_ctx(
ctx: &Arc<InstanceContext>,
disk_ref: &str,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
mut guards: RenameDataGuards,
) -> DiskResult<RenameDataResp> {
let (disk, disk_id) = local_disk_candidate(ctx, disk_ref).await?;
let mutates_namespace = !is_meta_bucketname(source.0) || !is_meta_bucketname(destination.0);
let owner = admit_local_disk(ctx, &disk, disk_id, mutates_namespace).await?;
guards.namespace_owner = owner.as_ref().map(|owner| owner.clone() as Arc<dyn Send + Sync>);
let result = disk
.rename_data_borrowed_with_fence_observed(source.0, source.1, fi, destination.0, destination.1, guards)
.await
.result;
drop(owner);
result
}
async fn undo_local_write_with_ctx(
ctx: &Arc<InstanceContext>,
disk_ref: &str,
volume: &str,
path: &str,
fi: FileInfo,
opts: DeleteOptions,
) -> DiskResult<()> {
if !opts.undo_write {
return Err(DiskError::other("target undo requires undo_write"));
}
let (disk, disk_id) = local_disk_candidate(ctx, disk_ref).await?;
let owner = admit_local_disk(ctx, &disk, disk_id, !is_meta_bucketname(volume)).await?;
let physical_owner = owner.as_ref().map(|owner| owner.clone() as Arc<dyn Send + Sync>);
let result = disk
.undo_write_with_namespace_owner(volume, path, fi, opts, physical_owner)
.await;
drop(owner);
result
}
async fn remember_local_disk_id(disk: &DiskStore) -> Option<Uuid> {
remember_local_disk_id_with_instance_ctx(&crate::runtime::global::current_ctx(), disk).await
}
@@ -466,663 +265,6 @@ mod tests {
}])
}
async fn target_disk(ctx: &Arc<InstanceContext>, root: &std::path::Path, id: Uuid) -> DiskStore {
let mut format = crate::layout::format::FormatV3::new(1, 1);
format.erasure.this = id;
format.erasure.sets[0][0] = id;
let meta = root.join(crate::disk::RUSTFS_META_BUCKET);
tokio::fs::create_dir_all(&meta).await.expect("create format volume");
tokio::fs::write(
meta.join(crate::disk::FORMAT_CONFIG_FILE),
serde_json::to_vec(&format).expect("encode format"),
)
.await
.expect("write real disk identity");
let mut endpoint = Endpoint::try_from(root.to_str().expect("UTF-8 root")).expect("endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
let disk = new_disk(
&endpoint,
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("open real local disk");
assert_eq!(disk.get_disk_id().await.expect("read disk format identity"), Some(id));
ctx.local_disk_map()
.write()
.await
.insert(disk.endpoint().to_string(), Some(disk.clone()));
disk
}
fn target_file_info(object: &str, version: Uuid, body: &'static [u8]) -> FileInfo {
let mut fi = FileInfo::new(object, 1, 0);
fi.erasure.index = 1;
fi.version_id = Some(version);
fi.mod_time = Some(OffsetDateTime::now_utc());
fi.size = i64::try_from(body.len()).expect("fixture length");
fi.parts = vec![rustfs_filemeta::ObjectPartInfo {
number: 1,
size: body.len(),
actual_size: fi.size,
..Default::default()
}];
fi.data = Some(bytes::Bytes::from_static(body));
fi.set_inline_data();
fi
}
async fn seed_target(disk: &DiskStore, volume: &str, object: &str, fi: FileInfo) -> Vec<u8> {
let dir = disk.path().join(volume);
tokio::fs::create_dir_all(&dir).await.expect("real fixture volume");
disk.write_metadata(volume, volume, object, fi.clone())
.await
.expect("seed real metadata");
let read = disk
.read_version(
volume,
volume,
object,
&fi.version_id.expect("fixture version").to_string(),
&crate::disk::ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("read fixture before mutation");
assert_eq!(read.data, fi.data, "fixture must contain readable inline bytes");
tokio::fs::read(dir.join(object).join(crate::disk::STORAGE_FORMAT_FILE))
.await
.expect("seeded metadata bytes")
}
#[tokio::test]
async fn target_uuid_lookup_binds_real_disk_and_owner_to_one_instance() {
for warm in [false, true] {
let ctx_a = Arc::new(InstanceContext::new());
let ctx_b = Arc::new(InstanceContext::new());
let a = tempfile::tempdir().expect("A root");
let b = tempfile::tempdir().expect("B root");
let id = Uuid::new_v4();
let disk_a = target_disk(&ctx_a, a.path(), id).await;
let disk_b = target_disk(&ctx_b, b.path(), id).await;
if warm {
assert!(record_local_disk_id_if_active(&ctx_a, &disk_a, id).await);
assert!(record_local_disk_id_if_active(&ctx_b, &disk_b, id).await);
}
let version = Uuid::new_v4();
let fi = target_file_info("destination", version, b"new-A");
for disk in [&disk_a, &disk_b] {
seed_target(disk, "target-bucket", "staged", fi.clone()).await;
}
let b_before = seed_target(
&disk_b,
"target-bucket",
"destination",
target_file_info("destination", version, b"old-B"),
)
.await;
let store = super::super::tests::build_store_with_ctx(ctx_a.clone());
store
.rename_local_data(&id.to_string(), ("target-bucket", "staged"), &fi, ("target-bucket", "destination"), None)
.await
.expect("rename on A");
let read = disk_a
.read_version(
"target-bucket",
"target-bucket",
"destination",
&version.to_string(),
&crate::disk::ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("read committed A");
assert_eq!(read.data, fi.data, "warm={warm}");
assert_eq!(
tokio::fs::read(b.path().join("target-bucket/destination/xl.meta"))
.await
.expect("B metadata"),
b_before
);
assert!(b.path().join("target-bucket/staged/xl.meta").exists());
assert!(ctx_a.namespace_commit_generation() > 0);
assert_eq!(ctx_b.namespace_commit_generation(), 0);
assert!(!ctx_a.namespace_commits_pending());
assert!(!ctx_b.namespace_commits_pending());
assert_eq!(ctx_a.local_disk_id_map().read().await.get(&id), Some(&disk_a.endpoint().to_string()));
}
}
#[tokio::test]
async fn target_admission_rejects_removed_quarantined_and_replaced_arcs() {
let ctx = Arc::new(InstanceContext::new());
let root = tempfile::tempdir().expect("root");
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
let endpoint = disk.endpoint().to_string();
for state in ["removed", "quarantined", "replaced"] {
let replacement = new_disk(
&disk.endpoint(),
&DiskOption {
cleanup: false,
health_check: false,
},
)
.await
.expect("separate active Arc");
let map = ctx.local_disk_map();
let mut entries = map.write().await;
match state {
"removed" => {
entries.remove(&endpoint);
}
"quarantined" => {
entries.insert(endpoint.clone(), None);
}
_ => {
entries.insert(endpoint.clone(), Some(replacement));
}
}
drop(entries);
assert!(
matches!(admit_local_disk(&ctx, &disk, None, true).await, Err(DiskError::DiskNotFound)),
"{state}"
);
assert!(!ctx.namespace_commits_pending());
assert_eq!(ctx.namespace_commit_generation(), 0);
}
}
#[tokio::test]
async fn target_uuid_cache_cannot_admit_a_different_format_at_the_same_path() {
let ctx = Arc::new(InstanceContext::new());
let root = tempfile::tempdir().expect("root");
let old_id = Uuid::new_v4();
let old = target_disk(&ctx, root.path(), old_id).await;
assert!(record_local_disk_id_if_active(&ctx, &old, old_id).await);
let replacement_id = Uuid::new_v4();
let replacement = target_disk(&ctx, root.path(), replacement_id).await;
assert!(!Arc::ptr_eq(&old, &replacement));
assert!(matches!(
local_disk_candidate(&ctx, &old_id.to_string()).await,
Err(DiskError::DiskNotFound)
));
let (candidate, verified) = local_disk_candidate(&ctx, &replacement_id.to_string())
.await
.expect("replacement UUID");
assert!(Arc::ptr_eq(&candidate, &replacement));
assert_eq!(verified, Some(replacement_id));
assert!(!ctx.namespace_commits_pending());
}
#[tokio::test]
async fn bootstrap_rejects_user_volumes_aliases_and_scanner_tokens_without_mutation() {
let ctx = Arc::new(InstanceContext::new());
let root = tempfile::tempdir().expect("root");
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
let target = BootstrapLocalTarget::new(ctx.clone());
let fi = target_file_info("destination", Uuid::new_v4(), b"body");
let user_before = seed_target(&disk, "victim", "staged", fi.clone()).await;
let meta_before = seed_target(&disk, ".rustfs.sys/tmp", "staged", fi.clone()).await;
for invalid in [
"victim",
".rustfs.sys/../victim",
".rustfs.sys/./tmp",
".rustfs.sys/ .. /victim",
".rustfs.sys\\..\\victim",
".minio.sys/../victim",
] {
for (src, dst) in [(invalid, ".rustfs.sys/tmp"), (".rustfs.sys/tmp", invalid)] {
assert!(
target
.rename_local_data(&disk.endpoint().to_string(), (src, "staged"), &fi, (dst, "destination"), None)
.await
.is_err(),
"src={src}, dst={dst}"
);
}
assert!(
target
.undo_local_write(
&disk.endpoint().to_string(),
invalid,
"staged",
fi.clone(),
DeleteOptions {
undo_write: true,
..Default::default()
}
)
.await
.is_err(),
"{invalid}"
);
}
assert!(
target
.rename_local_data(
&disk.endpoint().to_string(),
(".rustfs.sys/tmp", "staged"),
&fi,
(".rustfs.sys/tmp", "destination"),
Some(Uuid::new_v4())
)
.await
.is_err()
);
assert_eq!(
tokio::fs::read(root.path().join("victim/staged/xl.meta"))
.await
.expect("user source"),
user_before
);
assert_eq!(
tokio::fs::read(root.path().join(".rustfs.sys/tmp/staged/xl.meta"))
.await
.expect("metadata source"),
meta_before
);
assert!(!root.path().join("victim/destination").exists());
assert!(!root.path().join(".rustfs.sys/tmp/destination").exists());
assert_eq!(ctx.namespace_commit_generation(), 0);
assert!(!ctx.namespace_commits_pending());
}
#[tokio::test]
async fn bootstrap_allows_internal_multisegment_rename_without_namespace_owner() {
for volume in [".rustfs.sys/tmp", ".rustfs.sys/multipart", ".minio.sys/config"] {
let ctx = Arc::new(InstanceContext::new());
let root = tempfile::tempdir().expect("root");
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
let fi = target_file_info("destination", Uuid::new_v4(), b"internal-CAS-body");
seed_target(&disk, volume, "staged", fi.clone()).await;
BootstrapLocalTarget::new(ctx.clone())
.rename_local_data(&disk.endpoint().to_string(), (volume, "staged"), &fi, (volume, "destination"), None)
.await
.expect("legitimate bootstrap metadata write");
let read = disk
.read_version(
volume,
volume,
"destination",
&fi.version_id.expect("version").to_string(),
&crate::disk::ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("read bootstrap result");
assert_eq!(read.data, fi.data);
assert_eq!(ctx.namespace_commit_generation(), 0);
assert!(!ctx.namespace_commits_pending());
}
}
#[cfg(not(windows))]
#[tokio::test]
async fn target_user_source_to_internal_destination_retains_owner_after_cancellation() {
use crate::disk::os::prepared_publication_test_hooks as hooks;
use futures::FutureExt;
use std::time::Duration;
let ctx = Arc::new(InstanceContext::new());
let root = tempfile::tempdir().expect("source-volume root");
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
let store = super::super::tests::build_store_with_ctx(ctx.clone());
let version = Uuid::new_v4();
let fi = target_file_info("object", version, b"user-source-inline-body");
fi.validate_for_metadata_read().expect("valid real inline metadata");
let source_before = seed_target(&disk, "photos", "object", fi.clone()).await;
assert!(!source_before.is_empty());
tokio::fs::create_dir_all(root.path().join(crate::disk::RUSTFS_META_TMP_BUCKET))
.await
.expect("internal staging volume");
let destination = disk
.get_object_path_for_io_if_local(crate::disk::RUSTFS_META_TMP_BUCKET, "object")
.expect("local disk")
.expect("actual destination object key");
let destination_metadata = destination.join(crate::disk::STORAGE_FORMAT_FILE);
assert!(!destination_metadata.exists());
assert!(!ctx.namespace_commits_pending());
let generation = ctx.namespace_commit_generation();
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let _hook = hooks::install(&destination_metadata, move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
});
let disk_ref = disk.endpoint().to_string();
let rename_fi = fi.clone();
let mut rename = tokio::spawn(async move {
store
.rename_local_data(
&disk_ref,
("photos", "object"),
&rename_fi,
(crate::disk::RUSTFS_META_TMP_BUCKET, "object"),
None,
)
.await
});
let mut entered = false;
let mut joined = false;
let observations = std::panic::AssertUnwindSafe(async {
tokio::time::timeout(Duration::from_secs(10), async {
tokio::select! {
result = &mut rename => {
joined = true;
panic!("rename completed before prepared publication: {result:?}");
}
result = entered_rx => {
result.expect("real prepared rename must enter");
entered = true;
}
}
})
.await
.expect("bounded physical entry");
let at_entry = (ctx.namespace_commits_pending(), ctx.namespace_commit_generation());
assert!(!rename.is_finished(), "caller must still await the paused physical rename");
rename.abort();
let cancelled = tokio::time::timeout(Duration::from_secs(5), &mut rename)
.await
.expect("caller cancellation must finish while physical publication is paused");
joined = true;
let after_cancel = (ctx.namespace_commits_pending(), ctx.namespace_commit_generation());
(at_entry, after_cancel, cancelled)
})
.catch_unwind()
.await;
// Release on every observation failure. Pending alone is not a drain
// oracle: the implementation under test can fail to create the owner.
drop(release_tx);
if !joined {
rename.abort();
joined = tokio::time::timeout(Duration::from_secs(5), &mut rename).await.is_ok();
}
let physical_drained = tokio::time::timeout(Duration::from_secs(10), hooks::drain_namespace_key(&destination)).await;
let owner_drained = tokio::time::timeout(Duration::from_secs(10), async {
while ctx.namespace_commits_pending() {
tokio::task::yield_now().await;
}
})
.await;
if !entered || !joined || physical_drained.is_err() || owner_drained.is_err() {
// Without proven physical entry/drain, keep the root instead of
// deleting files that a detached local executor may still use.
let retained = root.keep();
eprintln!("source-volume cleanup incomplete: entered={entered}, joined={joined}, retained={retained:?}");
if let Err(panic) = observations {
std::panic::resume_unwind(panic);
}
panic!("source-volume physical cleanup did not finish: retained={retained:?}");
}
let (at_entry, after_cancel, cancelled) = match observations {
Ok(observations) => observations,
Err(panic) => std::panic::resume_unwind(panic),
};
let latest = tokio::time::timeout(
Duration::from_secs(5),
disk.read_version(
crate::disk::RUSTFS_META_TMP_BUCKET,
crate::disk::RUSTFS_META_TMP_BUCKET,
"object",
"",
&crate::disk::ReadOptions {
read_data: true,
..Default::default()
},
),
)
.await;
let source =
tokio::time::timeout(Duration::from_secs(5), tokio::fs::read(root.path().join("photos/object/xl.meta"))).await;
let after_drain = (ctx.namespace_commits_pending(), ctx.namespace_commit_generation());
assert!(cancelled.expect_err("caller must return cancellation").is_cancelled());
let latest = latest
.expect("latest read must finish")
.expect("late physical commit must be readable");
assert_eq!(latest.data, fi.data);
assert_eq!(latest.version_id, Some(version));
assert_eq!(
source
.expect("source observation must finish")
.expect_err("user source metadata must have moved")
.kind(),
std::io::ErrorKind::NotFound
);
assert_eq!(
(at_entry, after_cancel, after_drain),
((true, generation + 1), (true, generation + 1), (false, generation + 2)),
"a real user source mutation must remain counted through its cancelled caller and physical drain"
);
}
#[cfg(not(windows))]
#[tokio::test]
async fn target_rename_cancellation_retains_real_namespace_and_scanner_owners() {
use crate::disk::os::prepared_publication_test_hooks as hooks;
let ctx = Arc::new(InstanceContext::new());
let sibling = Arc::new(InstanceContext::new());
let root = tempfile::tempdir().expect("root");
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
let store = super::super::tests::build_store_with_ctx(ctx.clone());
let fi = target_file_info("destination", Uuid::new_v4(), b"physically-owned");
seed_target(&disk, "target-bucket", "staged", fi.clone()).await;
let (token, _) = store
.acquire_scanner_publication_lease(0, crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL)
.await
.expect("real scanner token in A");
let destination = disk
.get_object_path_for_io_if_local("target-bucket", "destination/xl.meta")
.expect("local disk")
.expect("destination IO path");
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let _hook = hooks::install(&destination, move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
});
let disk_ref = disk.endpoint().to_string();
let mut rename = Box::pin(store.rename_local_data(
&disk_ref,
("target-bucket", "staged"),
&fi,
("target-bucket", "destination"),
Some(token),
));
tokio::time::timeout(std::time::Duration::from_secs(10), async {
tokio::select! {
result = &mut rename => panic!("rename completed before physical pause: {result:?}"),
entered = entered_rx => entered.expect("physical rename entered"),
}
})
.await
.expect("bounded physical entry");
drop(rename);
assert!(store.scanner_data_usage_publication_blocked().await);
assert!(ctx.namespace_commits_pending());
assert!(!sibling.namespace_commits_pending());
assert!(
store
.rename_local_data(&disk_ref, ("target-bucket", "staged"), &fi, ("target-bucket", "another"), Some(token))
.await
.is_err(),
"real pending rename blocks another scanner publication"
);
assert!(store.release_scanner_publication_lease(token).await, "remove registered token");
let gate = ctx.data_movement_operation_gate();
assert!(
gate.clone().try_write_owned().is_err(),
"physical operation still owns the scanner read guard"
);
drop(release_tx);
let _drained = tokio::time::timeout(std::time::Duration::from_secs(10), gate.write_owned())
.await
.expect("physical tail must release scanner guard");
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while ctx.namespace_commits_pending() {
tokio::task::yield_now().await;
}
})
.await
.expect("namespace owner drains");
let read = disk
.read_version(
"target-bucket",
"target-bucket",
"destination",
&fi.version_id.expect("version").to_string(),
&crate::disk::ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("read actual late commit");
assert_eq!(read.data, fi.data);
assert!(ctx.namespace_commit_generation() >= 2);
assert_eq!(sibling.namespace_commit_generation(), 0);
}
#[tokio::test]
async fn target_ready_rejects_unknown_foreign_released_and_expired_scanner_tokens() {
let ctx = Arc::new(InstanceContext::new());
let other = Arc::new(InstanceContext::new());
let store = super::super::tests::build_store_with_ctx(ctx.clone());
let other_store = super::super::tests::build_store_with_ctx(other);
let root = tempfile::tempdir().expect("root");
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
let fi = target_file_info("destination", Uuid::new_v4(), b"unchanged");
let before = seed_target(&disk, "target-bucket", "staged", fi.clone()).await;
let ttl = crate::runtime::instance::SCANNER_PUBLICATION_LEASE_TTL;
let (foreign, _) = other_store.acquire_scanner_publication_lease(0, ttl).await.expect("B token");
let (released, _) = store.acquire_scanner_publication_lease(0, ttl).await.expect("A token");
assert!(store.release_scanner_publication_lease(released).await);
let (valid, _) = store.acquire_scanner_publication_lease(0, ttl).await.expect("new A token");
for token in [Uuid::new_v4(), foreign, released] {
assert!(
store
.rename_local_data(
&disk.endpoint().to_string(),
("target-bucket", "staged"),
&fi,
("target-bucket", "destination"),
Some(token)
)
.await
.is_err()
);
}
tokio::time::pause();
tokio::time::advance(ttl + std::time::Duration::from_secs(1)).await;
tokio::time::resume();
assert!(
store
.rename_local_data(
&disk.endpoint().to_string(),
("target-bucket", "staged"),
&fi,
("target-bucket", "destination"),
Some(valid)
)
.await
.is_err(),
"expired real token"
);
let _ = other_store.release_scanner_publication_lease(foreign).await;
assert_eq!(
tokio::fs::read(root.path().join("target-bucket/staged/xl.meta"))
.await
.expect("source bytes"),
before
);
assert!(!root.path().join("target-bucket/destination").exists());
assert!(!ctx.namespace_commits_pending());
}
#[cfg(not(windows))]
#[tokio::test]
#[serial_test::serial]
async fn target_ordinary_timeout_keeps_its_physical_namespace_owner() {
use crate::disk::os::prepared_publication_test_hooks as hooks;
temp_env::async_with_vars([(rustfs_config::ENV_DRIVE_MAX_TIMEOUT_DURATION, Some("1"))], async {
let ctx = Arc::new(InstanceContext::new());
let store = super::super::tests::build_store_with_ctx(ctx.clone());
let root = tempfile::tempdir().expect("root");
let disk = target_disk(&ctx, root.path(), Uuid::new_v4()).await;
let fi = target_file_info("destination", Uuid::new_v4(), b"timed-out-physical-commit");
seed_target(&disk, "target-bucket", "staged", fi.clone()).await;
let path = disk
.get_object_path_for_io_if_local("target-bucket", "destination/xl.meta")
.expect("local")
.expect("destination IO path");
let (entered_tx, entered_rx) = tokio::sync::oneshot::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let _hook = hooks::install(&path, move || {
let _ = entered_tx.send(());
let _ = release_rx.recv();
});
let disk_ref = disk.endpoint().to_string();
let mut rename = Box::pin(store.rename_local_data(
&disk_ref,
("target-bucket", "staged"),
&fi,
("target-bucket", "destination"),
None,
));
tokio::time::timeout(std::time::Duration::from_secs(10), async {
tokio::select! {
result = &mut rename => panic!("completed before physical pause: {result:?}"),
entered = entered_rx => entered.expect("physical entry"),
}
})
.await
.expect("bounded entry");
tokio::time::pause();
tokio::time::advance(std::time::Duration::from_secs(2)).await;
tokio::time::resume();
let result = tokio::time::timeout(std::time::Duration::from_secs(5), &mut rename)
.await
.expect("ordinary deadline remains enabled");
assert!(matches!(result, Err(DiskError::Timeout)), "{result:?}");
drop(rename);
assert!(ctx.namespace_commits_pending(), "timeout is not a physical drain");
drop(release_tx);
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while ctx.namespace_commits_pending() {
tokio::task::yield_now().await;
}
})
.await
.expect("late physical owner drains");
let read = disk
.read_version(
"target-bucket",
"target-bucket",
"destination",
&fi.version_id.expect("version").to_string(),
&crate::disk::ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("read actual timeout tail");
assert_eq!(read.data, fi.data);
})
.await;
}
#[test]
fn endpoint_rpc_authority_preserves_port_and_ipv6_brackets() {
let endpoint = Endpoint::try_from("https://127.0.0.1:9001/d1").expect("URL endpoint");
+5
View File
@@ -37,6 +37,9 @@ pub enum Error {
#[error("Method not allowed")]
MethodNotAllowed,
#[error("You've exceeded the limit on the number of versions you can create on this object")]
MaxVersionsExceeded,
#[error("Unexpected error")]
Unexpected,
@@ -86,6 +89,7 @@ impl PartialEq for Error {
(Error::FileCorrupt, Error::FileCorrupt) => true,
(Error::DoneForNow, Error::DoneForNow) => true,
(Error::MethodNotAllowed, Error::MethodNotAllowed) => true,
(Error::MaxVersionsExceeded, Error::MaxVersionsExceeded) => true,
(Error::FileNotFound, Error::FileNotFound) => true,
(Error::FileVersionNotFound, Error::FileVersionNotFound) => true,
(Error::VolumeNotFound, Error::VolumeNotFound) => true,
@@ -111,6 +115,7 @@ impl Clone for Error {
Error::FileCorrupt => Error::FileCorrupt,
Error::DoneForNow => Error::DoneForNow,
Error::MethodNotAllowed => Error::MethodNotAllowed,
Error::MaxVersionsExceeded => Error::MaxVersionsExceeded,
Error::VolumeNotFound => Error::VolumeNotFound,
Error::Io(e) => Error::Io(std::io::Error::new(e.kind(), e.to_string())),
Error::RmpSerdeDecode(s) => Error::RmpSerdeDecode(s.clone()),
+134 -14
View File
@@ -34,11 +34,14 @@ use rustfs_utils::http::{
};
use s3s::header::X_AMZ_RESTORE;
use serde::{Deserialize, Serialize};
#[cfg(test)]
use std::cell::Cell;
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::convert::TryFrom;
use std::hash::Hasher;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
use std::{collections::HashMap, io::Cursor};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
@@ -67,8 +70,46 @@ const _XL_FLAG_INLINE_DATA: u8 = 1 << 2;
const META_DATA_READ_DEFAULT: usize = 4 << 10;
const MSGP_UINT32_SIZE: usize = 5;
/// Max object versions per object, default is 10000
const DEFAULT_OBJECT_MAX_VERSIONS: usize = 10000;
/// Default max object versions per object, aligned with MinIO's default.
pub const DEFAULT_OBJECT_MAX_VERSIONS: usize = if usize::BITS >= 64 {
9_223_372_036_854_775_807
} else {
usize::MAX
};
static OBJECT_MAX_VERSIONS: AtomicUsize = AtomicUsize::new(DEFAULT_OBJECT_MAX_VERSIONS);
#[cfg(test)]
thread_local! {
static OBJECT_MAX_VERSIONS_OVERRIDE: Cell<Option<usize>> = const { Cell::new(None) };
}
#[inline]
pub fn object_max_versions() -> usize {
#[cfg(test)]
if let Some(limit) = OBJECT_MAX_VERSIONS_OVERRIDE.with(Cell::get) {
return limit;
}
OBJECT_MAX_VERSIONS.load(AtomicOrdering::Relaxed)
}
pub fn set_object_max_versions(limit: usize) -> Result<()> {
if limit == 0 {
return Err(Error::other("object max versions must be greater than 0"));
}
OBJECT_MAX_VERSIONS.store(limit, AtomicOrdering::Relaxed);
Ok(())
}
#[cfg(test)]
fn set_object_max_versions_override_for_test(limit: Option<usize>) -> Option<usize> {
OBJECT_MAX_VERSIONS_OVERRIDE.with(|override_limit| {
let previous = override_limit.get();
override_limit.set(limit);
previous
})
}
/// Returns the inline data map key for a version_id. "null" for null version.
pub(crate) fn data_key_for_version(version_id: Option<Uuid>) -> String {
@@ -460,18 +501,6 @@ impl FileMeta {
return Err(Error::other("file meta version invalid"));
}
// check max versions limit
if self.versions.len() + 1 > DEFAULT_OBJECT_MAX_VERSIONS {
return Err(Error::other(
"You've exceeded the limit on the number of versions you can create on this object",
));
}
if self.versions.is_empty() {
self.versions.push(FileMetaShallowVersion::try_from(version)?);
return Ok(());
}
let vid = version.get_version_id();
let vid_is_null = vid.is_none() || vid == Some(Uuid::nil());
let existing_idx = if vid_is_null {
@@ -490,6 +519,15 @@ impl FileMeta {
return self.set_idx(fidx, version);
}
if self.versions.len() >= object_max_versions() {
return Err(Error::MaxVersionsExceeded);
}
if self.versions.is_empty() {
self.versions.push(FileMetaShallowVersion::try_from(version)?);
return Ok(());
}
let new_shallow = FileMetaShallowVersion::try_from(version)?;
let insert_pos = self
.versions
@@ -1330,6 +1368,88 @@ mod test {
}
}
struct ObjectMaxVersionsRestore {
previous: Option<usize>,
}
impl Drop for ObjectMaxVersionsRestore {
fn drop(&mut self) {
set_object_max_versions_override_for_test(self.previous);
}
}
fn with_object_max_versions_for_test<R>(limit: usize, test: impl FnOnce() -> R) -> R {
let previous = set_object_max_versions_override_for_test(Some(limit));
let _restore = ObjectMaxVersionsRestore { previous };
test()
}
#[test]
fn add_version_filemata_rejects_new_version_above_configured_limit() {
with_object_max_versions_for_test(2, || {
let mut fm = FileMeta::new();
fm.add_version_filemata(valid_object_version(Uuid::from_u128(1), vec![10, 20]))
.expect("add first version within limit");
fm.add_version_filemata(valid_object_version(Uuid::from_u128(2), vec![10, 20]))
.expect("add second version at limit");
let err = fm
.add_version_filemata(valid_object_version(Uuid::from_u128(3), vec![10, 20]))
.expect_err("new version above limit must fail");
assert_eq!(err, Error::MaxVersionsExceeded);
assert_eq!(fm.versions.len(), 2, "failed insert must not mutate version list");
});
}
#[test]
fn add_version_filemata_allows_same_version_replacement_at_limit() {
with_object_max_versions_for_test(2, || {
let mut fm = FileMeta::new();
let target = Uuid::from_u128(10);
fm.add_version_filemata(valid_object_version(target, vec![10, 20]))
.expect("add target version");
fm.add_version_filemata(valid_object_version(Uuid::from_u128(20), vec![10, 20]))
.expect("add peer version at limit");
fm.add_version_filemata(valid_object_version(target, vec![30, 40]))
.expect("same version replacement at limit must succeed");
assert_eq!(fm.versions.len(), 2);
let replaced = fm
.versions
.iter()
.find(|version| version.header.version_id == Some(target))
.expect("target version must remain present")
.parse_version_meta()
.expect("parse replaced version");
assert_eq!(replaced.object.expect("object version").part_sizes, vec![30, 40]);
});
}
#[test]
fn add_version_allows_null_version_replacement_at_limit() {
with_object_max_versions_for_test(1, || {
let mut fm = FileMeta::new();
let mut first = FileInfo::new("object", 2, 2);
first.mod_time = Some(OffsetDateTime::now_utc());
first.version_id = None;
fm.add_version(first).expect("add initial null version");
let mut replacement = FileInfo::new("object", 2, 2);
replacement.mod_time = Some(OffsetDateTime::now_utc());
replacement.version_id = None;
replacement.size = 42;
fm.add_version(replacement)
.expect("null version replacement at limit must succeed");
assert_eq!(fm.versions.len(), 1);
assert_eq!(fm.versions[0].header.version_id, Some(Uuid::nil()));
let replaced = fm.versions[0].parse_version_meta().expect("parse null replacement");
assert_eq!(replaced.object.expect("object version").size, 42);
});
}
#[test]
fn add_version_filemata_uses_canonical_equal_time_order() {
let mod_time = OffsetDateTime::from_unix_timestamp(1_700_000_000).expect("valid test timestamp");
+26 -7
View File
@@ -524,9 +524,8 @@ struct MrfRuntime {
/// waiting out an admission backoff must not re-fsync every local disk
/// twice a second.
dirty: bool,
/// True while a journal snapshot exists on disk that no longer reflects
/// an all-consumed pending set; the next idle tick removes it (MinIO
/// deletes its `list.bin` after replay for the same reason).
/// True while a journal snapshot exists on disk that may still be needed
/// for replay or cleanup.
journal_on_disk: bool,
/// Earliest instant a full-admission retry may proceed.
backoff_until: Option<tokio::time::Instant>,
@@ -747,10 +746,26 @@ async fn replay_into(
if intent.attempts < MRF_MAX_ATTEMPTS {
queue.push_back(intent);
*backoff_until = Some(tokio::time::Instant::now());
} else {
rearm_incomplete = true;
counter!("rustfs_heal_mrf_dropped_total", "reason" => "attempts_exhausted").increment(1);
rustfs_common::mrf_channel::release_mrf_intent(&intent);
}
break;
}
Ok(HealAdmissionResult::Dropped(_)) => {}
Err(_) => {
intent.attempts = intent.attempts.saturating_add(1);
if intent.attempts < MRF_MAX_ATTEMPTS {
queue.push_back(intent);
*backoff_until = Some(tokio::time::Instant::now());
} else {
rearm_incomplete = true;
counter!("rustfs_heal_mrf_dropped_total", "reason" => "attempts_exhausted").increment(1);
rustfs_common::mrf_channel::release_mrf_intent(&intent);
}
break;
}
Ok(HealAdmissionResult::Dropped(_)) | Err(_) => {}
}
}
}
@@ -825,7 +840,11 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
}
}
_ = flush_tick.tick() => {
match tick_action(runtime.dirty, runtime.queue.depth(), runtime.journal_on_disk) {
match tick_action(
runtime.dirty,
runtime.queue.depth(),
runtime.journal_on_disk,
) {
TickAction::Flush => {
runtime.flush().await;
runtime.dispatch(manager.as_ref()).await;
@@ -838,8 +857,8 @@ async fn run_mrf_consumer(manager: Arc<HealManager>, mut receiver: mpsc::Receive
runtime.dispatch(manager.as_ref()).await;
}
TickAction::DeleteJournal => {
// All intents consumed: remove the journal so a restart
// replays nothing (mirrors MinIO's post-replay unlink).
// All replayed intents have either been accepted,
// merged, or replaced by a pending successor snapshot.
if delete_journals().await {
runtime.journal_on_disk = false;
gauge!("rustfs_heal_mrf_journal_bytes").set(0.0);
+6 -3
View File
@@ -51,14 +51,17 @@ prior key versions would go green.
The lane creates real keys under unique names (`behavior-kv2-*`,
`behavior-transit-*`) and does not remove them, so a dev Vault accumulates them
across runs. Clear them out periodically — against a dev server only:
across runs. On a dev server, remove only exact keys confirmed to belong to the
current task. A shared prefix does not prove ownership; preserve another run's
keys and leave ambiguous keys for the operator.
```bash
vault list -format=json transit/keys | jq -r '.[] | select(startswith("behavior-transit-"))' | while read -r k; do vault write "transit/keys/$k/config" deletion_allowed=true >/dev/null && vault delete "transit/keys/$k"; done
vault write transit/keys/<task-owned-transit-key>/config deletion_allowed=true
vault delete transit/keys/<task-owned-transit-key>
```
```bash
vault list -format=json secret/metadata/rustfs/kms/keys | jq -r '.[] | select(startswith("behavior-kv2-"))' | xargs -I{} vault kv metadata delete secret/rustfs/kms/keys/{}
vault kv metadata delete secret/rustfs/kms/keys/<task-owned-kv2-key>
```
## Local Key Export for SSE-S3 Migration Tests
+4 -7
View File
@@ -68,17 +68,14 @@ pub(super) fn rules() -> Vec<Rule> {
)
},
Rule {
anchors: strings(["Storage inventory probe failed; current drive health is unknown"]),
anchors: strings(["reporting peer disks offline after consecutive storage_info failures"]),
..base(
"peer-disks-offline",
P2Degraded,
"disk",
"peer 存储清单探测失败",
any([
contains("Storage inventory probe failed; current drive health is unknown"),
contains("reporting peer disks offline after consecutive storage_info failures"),
]),
"某 peer 的 storage_info 探测失败,当前磁盘健康状态未知。",
"peer 磁盘被整体判定离线",
contains("reporting peer disks offline after consecutive storage_info failures"),
"对某 peer 连续 storage_info 失败,判定其磁盘整体离线。",
"检查该 peer 节点存活与 RPC 端口可达。",
)
},
+1 -5
View File
@@ -110,7 +110,7 @@ fn every_rule_has_a_positive_sample() {
("remote-peer-faulty", msg("Remote peer health check failed for node2: marking as faulty")),
(
"peer-disks-offline",
msg("Storage inventory probe failed; current drive health is unknown"),
msg("reporting peer disks offline after consecutive storage_info failures"),
),
("drive-faulty-error", msg("remote drive is faulty")),
(
@@ -318,10 +318,6 @@ fn smoke_samples_hit_exact_rule_sets() {
&["disk-marked-faulty"],
);
exact(&msg("erasure write quorum (required=8, achieved=5)"), &["ec-write-quorum"]);
exact(
&msg("reporting peer disks offline after consecutive storage_info failures"),
&["peer-disks-offline"],
);
exact(
&Sample {
message: "Metacache listing quorum failed",
+7 -7
View File
@@ -15,14 +15,14 @@ Keep changes narrow and source-driven.
## Source of Truth
Before changing behavior, read these files first:
Start with the file that owns the changed behavior; follow callers and shared
types only as needed:
- `src/scan.rs`
- `src/capacity_manager.rs`
- `src/capacity_scope.rs`
- `src/types.rs`
- `../../rustfs/src/capacity/service.rs`
- `../config/src/constants/capacity.rs`
- Scan/sampling: `src/scan.rs`.
- Refresh and per-disk cache state: `src/capacity_manager.rs`.
- Scope propagation: `src/capacity_scope.rs`; shared data shapes: `src/types.rs`.
- Admin integration: `../../rustfs/src/capacity/service.rs`.
- Configuration/defaults: `../config/src/constants/capacity.rs`.
Do not treat shell scripts or old docs as the authoritative behavior definition when the Rust code says otherwise.
@@ -37,6 +37,8 @@ fn segment_proof() -> SegmentInvalidationProof {
key_format: envelope.key_format,
baseline_scan_plan_digest: envelope.baseline_scan_plan_digest,
process_epoch: envelope.process_epoch,
generation_start: envelope.generation_start,
generation_end: envelope.generation_end,
durable_producer_identity: true,
invalidation_domain: SegmentInvalidationDomain::LocalSingleSet,
distributed_ec_invalidation: false,
@@ -126,6 +128,20 @@ fn segment_observation_trusted_proposal_requires_identity_and_complete_producer_
Err(SegmentInvalidationError::InvalidProof)
);
let mut wrong_generation_start = proof.clone();
wrong_generation_start.generation_start = wrong_generation_start.generation_start.saturating_sub(1);
assert_eq!(
admit_segment_invalidation(&envelope, &wrong_generation_start, ["hot/one"]),
Err(SegmentInvalidationError::InvalidProof)
);
let mut wrong_generation_end = proof.clone();
wrong_generation_end.generation_end = wrong_generation_end.generation_end.saturating_add(1);
assert_eq!(
admit_segment_invalidation(&envelope, &wrong_generation_end, ["hot/one"]),
Err(SegmentInvalidationError::InvalidProof)
);
let mut no_durable_identity = proof.clone();
no_durable_identity.durable_producer_identity = false;
assert_eq!(
@@ -166,16 +166,22 @@ async fn round(request: &Request) -> serde_json::Value {
let reloaded = DataUsageCache::unmarshal(&read_bounded(&cache_path).await).expect("reload returned cache codec");
let retained = reloaded.checked_flatten("bucket").expect("reloaded bucket root");
let scanned = returned.checked_flatten("bucket").expect("returned bucket root");
let raw_page_index_committed_entries = reloaded
.validated_raw_enumeration_page_index()
let raw_page_index = reloaded.validated_raw_enumeration_page_index();
let raw_page_index_committed_entries = raw_page_index
.and_then(|index| index.committed_entries().ok())
.map(|entries| entries.len())
.unwrap_or(0);
let raw_page_index_indexed_entries = reloaded
.validated_raw_enumeration_page_index()
let raw_page_index_indexed_entries = raw_page_index
.and_then(|index| index.indexed_entries().ok())
.map(|entries| entries.len())
.unwrap_or(0);
let (raw_page_index_parent, raw_page_index_complete) = raw_page_index
.map(|index| match index.status() {
crate::raw_page_index::RawEnumerationPageOwnerStatus::Building { parent, .. } => (Some(parent), false),
crate::raw_page_index::RawEnumerationPageOwnerStatus::Ready { parent, complete, .. } => (Some(parent), complete),
crate::raw_page_index::RawEnumerationPageOwnerStatus::Unsupported => (None, false),
})
.unwrap_or((None, false));
assert_eq!(
(retained.objects, retained.versions, retained.size),
(scanned.objects, scanned.versions, scanned.size)
@@ -188,6 +194,8 @@ async fn round(request: &Request) -> serde_json::Value {
"objects_expected": request.objects, "raw_entry_budget": request.raw_entry_budget,
"raw_entries": observation.entries, "raw_name_bytes": observation.name_bytes,
"raw_first_entry": observation.first_entry, "raw_last_entry": observation.last_entry,
"raw_page_index_parent": raw_page_index_parent,
"raw_page_index_complete": raw_page_index_complete,
"raw_page_index_committed_entries": raw_page_index_committed_entries,
"raw_page_index_indexed_entries": raw_page_index_indexed_entries,
"objects_processed": budget.progress().0,
@@ -77,6 +77,8 @@ pub struct SegmentInvalidationProof {
pub key_format: u16,
pub baseline_scan_plan_digest: DataUsageScanPlanDigest,
pub process_epoch: String,
pub generation_start: u64,
pub generation_end: u64,
pub durable_producer_identity: bool,
pub invalidation_domain: SegmentInvalidationDomain,
pub distributed_ec_invalidation: bool,
@@ -108,11 +110,15 @@ fn validate_segment_invalidation_proof(
|| envelope.baseline_scan_plan_digest != proof.baseline_scan_plan_digest
|| envelope.process_epoch.is_empty()
|| envelope.process_epoch != proof.process_epoch
|| envelope.generation_start != proof.generation_start
|| envelope.generation_end != proof.generation_end
|| !proof.durable_producer_identity
|| !proof.cold_zero_walk_oracle
|| (proof.invalidation_domain == SegmentInvalidationDomain::DistributedEc && !proof.distributed_ec_invalidation)
|| envelope.generation_start == 0
|| envelope.generation_end < envelope.generation_start
|| proof.generation_start == 0
|| proof.generation_end < proof.generation_start
|| envelope.restart_gap
|| envelope.overflow
|| !SegmentInvalidationProducer::REQUIRED
@@ -190,6 +196,8 @@ mod tests {
key_format: envelope.key_format,
baseline_scan_plan_digest: envelope.baseline_scan_plan_digest,
process_epoch: envelope.process_epoch,
generation_start: envelope.generation_start,
generation_end: envelope.generation_end,
durable_producer_identity: true,
invalidation_domain: SegmentInvalidationDomain::LocalSingleSet,
distributed_ec_invalidation: false,
@@ -241,6 +249,20 @@ mod tests {
Err(SegmentInvalidationError::InvalidProof)
);
let mut wrong_generation_start = proof.clone();
wrong_generation_start.generation_start = wrong_generation_start.generation_start.saturating_sub(1);
assert_eq!(
admit_segment_invalidation(&envelope, &wrong_generation_start, ["hot/one"]),
Err(SegmentInvalidationError::InvalidProof)
);
let mut wrong_generation_end = proof.clone();
wrong_generation_end.generation_end = wrong_generation_end.generation_end.saturating_add(1);
assert_eq!(
admit_segment_invalidation(&envelope, &wrong_generation_end, ["hot/one"]),
Err(SegmentInvalidationError::InvalidProof)
);
let mut no_durable_identity = proof.clone();
no_durable_identity.durable_producer_identity = false;
assert_eq!(
+12
View File
@@ -125,6 +125,7 @@ const EXTERNAL_COMPATIBLE_SUFFIXES: &[&str] = &[
"ACCESS_KEY",
"ACCESS_KEY_FILE",
"ADDRESS",
"API_OBJECT_MAX_VERSIONS",
"API_XFF_HEADER",
"AUDIT_WEBHOOK_AUTH_TOKEN",
"AUDIT_WEBHOOK_CLIENT_CERT",
@@ -900,4 +901,15 @@ mod tests {
});
});
}
#[test]
fn external_env_compat_includes_api_object_max_versions() {
let report =
build_external_env_compat_report_from_entries([("MINIO_API_OBJECT_MAX_VERSIONS".to_string(), "50000".to_string())]);
assert_eq!(
report.mapped_pairs,
vec![("MINIO_API_OBJECT_MAX_VERSIONS".to_string(), "RUSTFS_API_OBJECT_MAX_VERSIONS".to_string())]
);
}
}
+1 -1
View File
@@ -60,6 +60,6 @@ Required headings and strings in these files are asserted by `scripts/check_arch
| [minio-rustfs-router-compatibility.md](minio-rustfs-router-compatibility.md) | a client or `mc` call that works against MinIO fails against RustFS and you need to know whether the endpoint is missing, stubbed, or deliberately different |
| [minio-file-format-compat.md](minio-file-format-compat.md) | deciding whether a MinIO drive set, bucket-metadata blob, or SSE object can be read or imported by a given RustFS build, or before touching a listed version anchor |
Operations runbooks live in [../operations/](../README.md#operations) and testing references in [../testing/README.md](../testing/README.md).
Operations runbooks live in [../operations/](../operations/) and testing references in [../testing/README.md](../testing/README.md).
For per-node HTTP failure ratios and cached storage probe provenance, see [S3 write failure diagnostics](../operations/s3-write-failure-diagnostics.md).
+1 -1
View File
@@ -1,4 +1,3 @@
| Outbound target matrix | Replication of every object shape (empty, plain, retention, legal hold, multipart) against every remote-target failure mode the fake target models; an explicit expectation table pins known-red cells to an open issue | `cargo nextest run -p e2e_test -E 'test(/^replication_target_matrix_test::/)'` (build `target/debug/rustfs` first) | With `e2e-repl-nightly`; required locally for any change to outbound client defaults (SOP: [`docs/postmortems/2026-09-03-replication-checksum-default-regression.md`](../postmortems/2026-09-03-replication-checksum-default-regression.md)) |
# RustFS Testing
**Use this when:** you need to pick a test layer for a change, name a test so a gate keeps selecting it, understand why `#[serial]` does nothing under nextest, or handle a flaky test.
@@ -13,6 +12,7 @@ Pick the lowest layer that can prove the change; add a higher-layer test only wh
| Unit & crate integration | Per-crate logic and in-process integration tests | `cargo nextest run --all --exclude e2e_test` (or `-p <crate>`); `make test` wraps it | Every PR, required (`Test and Lint`, `ci` profile) |
| ecstore black-box | Erasure-coded read/write/recovery validation; profiles `quick` / `full` / `destructive` / `fuzz` | `scripts/run_ecstore_validation_suite.sh --profile quick` | Local and release validation only; not wired into any workflow. Contract: [ecstore-validation-suite-design.md](ecstore-validation-suite-design.md) |
| e2e (`e2e_test` crate) | A real `rustfs` binary per test, driven over the S3, admin, and protocol APIs | `cargo nextest run --profile e2e-smoke -p e2e_test` | PR: `e2e-smoke` (report-only); merge queue / main push: `e2e-full`; nightly: `e2e-repl-nightly`, `e2e-nightly`, `e2e-protocols`, `e2e-distributed`. Guide: [`crates/e2e_test/README.md`](../../crates/e2e_test/README.md); 4-node 4-disk map: [distributed-e2e.md](distributed-e2e.md) |
| Outbound target matrix | Replication of every object shape (empty, plain, retention, legal hold, multipart) against every remote-target failure mode the fake target models; an explicit expectation table pins known-red cells to an open issue | `cargo nextest run -p e2e_test -E 'test(/^replication_target_matrix_test::/)'` (build `target/debug/rustfs` first) | With `e2e-repl-nightly`; required locally for any change to outbound client defaults (SOP: [`docs/postmortems/2026-09-03-replication-checksum-default-regression.md`](../postmortems/2026-09-03-replication-checksum-default-regression.md)) |
| s3s-e2e conformance | External S3 conformance tool against a live server | `./scripts/e2e-run.sh ./target/debug/rustfs <data-dir>` | PR, report-only (second half of the `End-to-End Tests` job) |
| S3 compatibility | `ceph/s3-tests` (boto3; allow-list `scripts/s3-tests/implemented_tests.txt`) and MinIO `mint` | `scripts/s3-tests/run.sh`; mint via `.github/workflows/mint.yml` | s3-tests: PR report-only plus a weekly full sweep; mint: weekly, report-only |
| Chaos / fault-injection | Single-node disk fault injection (`crates/e2e_test/src/chaos.rs`, `crates/e2e_test/src/fault_proxy.rs`) plus the 4-node kill/fresh-drive/blackhole cases in `crates/e2e_test/src/distributed/chaos_test.rs` | Part of the e2e crate (`e2e-reliability` and `e2e-distributed`) | Reliability cases with `e2e-full`; 4-node chaos on storage-sensitive PRs and nightly via `e2e-distributed` |
+13
View File
@@ -228,6 +228,19 @@ these. The external `rustfs/auto-testing` functional workflows propagate suite
failures. Their workflow status does not establish this registry's required
case coverage, build provenance, or object-level oracles.
For automation, `--check-scanner-heal-release "$RUN_DIR"` emits one compact
JSON decision and exits nonzero while blocked. `verified_cases` contains only
cases that pass the complete receipt, build provenance, nextest/JUnit and real
oracle checks; `rejected_cases` names registered cases that do not, and
`pending_gates` names the unimplemented release requirements. Approval requires
every registered case to verify, `pending_gates` to be empty, and a future
registry schema capable of representing the complete release matrix. Schema 1
is deliberately marked `release_schema_capable: false`: it models only the
single-version, unversioned-object restart/crash cases and cannot represent
mixed-version, rollback, EC8+4 or performance evidence. A focused run,
synthetic harness, compile-only result, skipped/retried test, ordinary CI
success, or removal of pending text therefore cannot become a release approval.
Run parser/receipt regressions with
`scripts/python_bin.sh scripts/check_test_wiring.py --self-test`. Those fixtures
validate the checker only and produce no runtime or performance evidence.
+3 -3
View File
@@ -19,11 +19,11 @@ python3 scripts/diagnose_scanner_enumeration_restart.py \
--objects 128 --raw-entry-budget 8 --rounds 8
```
The output directory must not exist. Each round starts a new OS test-worker process, opens the same synthetic disk, decodes the preceding cache, invokes the real scanner, encodes the returned cache, and decodes it again. When cancellation returns no useful partial cache, it preserves the previous cache. Reports identify the actual child PID, round, raw entries and name bytes observed, processed objects, retained object/version/byte counts, and completeness. No observed-name set, `readdir` offset, or assumed stable ordering is used as durable progress. Namespace creation happens only during fixture setup, before scan accounting.
The output directory must not exist. Each round starts a new OS test-worker process, opens the same synthetic disk, decodes the preceding cache, invokes the real scanner, encodes the returned cache, and decodes it again. When cancellation returns no useful partial cache, it preserves the previous cache. Reports identify the actual child PID, round, raw entries and name bytes observed, raw page-index parent/entries, processed/classified objects, retained object/version/byte counts, and completeness. The driver rejects retained coverage that advances beyond classified object work, root raw page indexes that outrun the fixture namespace, committed page coverage that exceeds indexed coverage, same-parent committed coverage regressions before completion, and any process-restart regression in retained coverage. No observed-name set, `readdir` offset, or assumed stable ordering is used as durable progress. Namespace creation happens only during fixture setup, before scan accounting.
The `cfg(test)` hook observes actual entries delivered by `read_dir` and cancels the existing cycle token at the fixed entry limit. This is a deterministic injected **raw-entry work budget**, not a wall-clock performance measurement or a claim that kernel prefetch, probes, allocations, name bytes, or cache I/O are independently budgeted. The watchdog timeout only bounds worker lifetime. The hook does not replace enumeration, classification, or recursion, and does not exist in production builds. In particular, `xl.meta` object-boundary classification is unchanged.
Exit 0 requires exact complete object/version/byte coverage within the same fixed budget on every executed round. Exit 1 means the strict convergence oracle remains unmet, including the current flat-directory enumeration starvation case. Exit 2 means invalid input, worker failure, or invalid evidence; it is not a successful reproduction. There is no final unbudgeted sweep. Small fixtures can pass; that does not establish the general R-E gate from [the scanner review comment](https://github.com/rustfs/backlog/issues/2240#issuecomment-5549222480). Raw entries observed are not a retained enumeration watermark. This is scanner-worker process restart plus codec evidence, **not** whole-daemon restart, EC quorum persistence, crash/fsync durability, remote RPC, or a throughput benchmark. The caller owns the bounded evidence directory and may remove it after inspection.
Exit 0 requires exact complete object/version/byte coverage within the same fixed budget on every executed round and positive evidence for all three stages: raw enumeration/indexing, object classification/processing, and durable cache retention after a fresh worker process reloads the previous report. Exit 1 means the strict convergence oracle remains unmet, including the current flat-directory enumeration starvation case. Exit 2 means invalid input, worker failure, or invalid evidence; it is not a successful reproduction. There is no final unbudgeted sweep. Small fixtures can pass; that does not establish the general R-E gate from [the scanner review comment](https://github.com/rustfs/backlog/issues/2240#issuecomment-5549222480). Raw entries observed are not a retained enumeration watermark. This is scanner-worker process restart plus codec evidence, **not** whole-daemon restart, EC quorum persistence, crash/fsync durability, remote RPC, or a throughput benchmark. The caller owns the bounded evidence directory and may remove it after inspection.
### Missing Storage Capability
@@ -64,7 +64,7 @@ The nested `segment_observation` fixture compares diagnostic on/off runs of the
Entry/byte overflow and malformed keys reject the fixture proposal. Missing producers, process restarts, event gaps, and compacted child coverage remain **unverified production capabilities**, not simulated success cases in this fixture. Mainline bucket dirty generations and hashed metadata-cache invalidation stripes are not an exact, replayable object-key stream. The open [prefix reuse proposal #7208](https://github.com/rustfs/rustfs/pull/7208) is a separate candidate implementation; these tests neither import its hint map nor activate its skip path.
The ECStore `segment_observation_equal_size_mutations_retire_metadata_generation` test uses the existing exact-key, test-only invalidation probe and actual owner operations. A same-length PUT must change the returned body and ETag while retiring the old generation; metadata-only PUT must change returned metadata and retire the old generation while size and ETag remain equal. Setup uses the existing full-fanout cache-priming helper; the observed mutations use normal owner locking. This is focused producer evidence, not an end-to-end connection between the owner probe and scanner range selection. The existing semantic mutation matrix covers additional owner entry points separately.
The ECStore `segment_observation_equal_size_mutations_retire_metadata_generation` test uses the existing exact-key, test-only invalidation probe and actual owner operations. A same-length PUT must change the returned body and ETag while retiring the old generation; metadata-only PUT must change returned metadata and retire the old generation while size and ETag remain equal. Setup uses the existing full-fanout cache-priming helper; the observed mutations use normal owner locking. This is focused producer evidence, not an end-to-end connection between the owner probe and scanner range selection. The segment invalidation proof is bound to the same generation window as the observed envelope, so an old distributed or cold-walk proof cannot authorize a later mutation range. The existing semantic mutation matrix covers additional owner entry points separately.
```sh
cargo test -p rustfs-scanner --lib segment_observation -- --list
+1 -1
View File
@@ -68,7 +68,7 @@ license = []
io-scheduler-debug = [] # Enable debug information in I/O scheduler
tracing-chunk-debug = [] # Enable per-chunk tracing in data plane (high noise, for debugging only)
full = ["metrics-gpu", "ftps", "swift", "webdav", "sftp", "pyroscope", "gcs"]
e2e-test-hooks = ["rustfs-ecstore/e2e-test-hooks"]
e2e-test-hooks = []
# Shortens Connect credentials only in debug E2E builds.
connect-e2e-short-credentials = []
# Builds the dedicated rustfs-cli-e2e target with a build-time public enrollment root.
+2 -35
View File
@@ -26,14 +26,13 @@
//! server is not ready rather than that another server's global context applies.
use super::global::{AppContext, get_global_app_context};
use crate::app::storage_api::context::{BootstrapLocalTarget, ECStore, InstanceContext};
use crate::app::storage_api::context::ECStore;
use std::sync::{Arc, OnceLock};
/// Late-bound, per-server handle to the application context.
#[derive(Default)]
pub struct ServerContextSlot {
app_context: OnceLock<Arc<AppContext>>,
bootstrap_target: Option<BootstrapLocalTarget>,
heal_topology_fingerprint: Arc<tokio::sync::OnceCell<String>>,
}
@@ -51,47 +50,15 @@ impl ServerContextSlot {
pub fn new() -> Arc<Self> {
Arc::new(Self {
app_context: OnceLock::new(),
bootstrap_target: None,
heal_topology_fingerprint: Arc::new(tokio::sync::OnceCell::new()),
})
}
/// Bind the listener to its foundation before it can accept requests.
pub fn with_instance_context(ctx: Arc<InstanceContext>) -> Arc<Self> {
Arc::new(Self {
bootstrap_target: Some(BootstrapLocalTarget::new(ctx)),
..Self::default()
})
}
/// Install this server's application context (once). Returns `false` if
/// the slot was already installed; the first installation wins, matching
/// the process-global singleton's `get_or_init` semantics.
pub fn install(&self, context: Arc<AppContext>) -> bool {
self.try_install(context).is_ok()
}
/// Claim the slot before any process-global application publication.
/// Repeated installation, even of the same Arc, is an explicit conflict.
pub fn try_install(&self, context: Arc<AppContext>) -> std::io::Result<()> {
if self
.bootstrap_target
.as_ref()
.is_some_and(|target| !target.is_for_store(&context.object_store()))
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"application context does not belong to this server foundation",
));
}
self.app_context.set(context).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::AlreadyExists, "server application context is already installed")
})
}
/// Immutable, restricted startup capability; never resolves an ambient store.
pub fn bootstrap_target(&self) -> Option<BootstrapLocalTarget> {
self.bootstrap_target.clone()
self.app_context.set(context).is_ok()
}
/// This server's installed application context, if startup has completed.
+2 -2
View File
@@ -37,8 +37,8 @@ impl AppContext {
// also publishes to the process default (first server wins) so legacy
// free-function readers keep resolving the first server's context.
let context = Arc::new(AppContext::with_default_interfaces(store, iam, kms_interface));
server_ctx.try_install(context.clone())?;
publish_global_app_context(context);
publish_global_app_context(context.clone());
let _ = server_ctx.install(context);
Ok(())
}
}
+1 -1
View File
@@ -1261,7 +1261,7 @@ pub(crate) mod context {
pub(crate) use super::EndpointServerPools;
pub(crate) use super::bucket;
pub(crate) use super::runtime;
pub(crate) use crate::storage::storage_api::{BootstrapLocalTarget, ECStore, EndpointServerPools, InstanceContext};
pub(crate) use crate::storage::storage_api::{ECStore, EndpointServerPools};
#[cfg(test)]
pub(crate) use crate::storage::storage_api::{Endpoint, Endpoints, PoolEndpoints};
}
+27 -1
View File
@@ -14,9 +14,13 @@
use crate::storage_api::error::contract::{StorageErrorCode, range::HTTPRangeError};
use crate::storage_api::error::{QuotaError, StorageError};
use http::StatusCode;
use rustfs_kms::KmsUnavailableError;
use s3s::{S3Error, S3ErrorCode};
const MAX_VERSIONS_EXCEEDED_CODE: &str = "MaxVersionsExceeded";
const MAX_VERSIONS_EXCEEDED_MESSAGE: &str = "You've exceeded the limit on the number of versions you can create on this object";
/// Marks a request body that exceeded a presigned upload size capability.
///
/// This marker must survive the body-reader and storage layers so the client
@@ -284,6 +288,9 @@ impl ApiError {
S3ErrorCode::EvaluatorBindingDoesNotExist => "A column name or a path provided does not exist in the SQL expression".to_string(),
S3ErrorCode::InvalidColumnIndex => "The column index is invalid. Please check the service documentation and try again.".to_string(),
S3ErrorCode::UnsupportedFunction => "Encountered an unsupported SQL function.".to_string(),
S3ErrorCode::Custom(code) if &**code == MAX_VERSIONS_EXCEEDED_CODE => {
MAX_VERSIONS_EXCEEDED_MESSAGE.to_string()
}
_ => code.as_str().to_string(),
}
}
@@ -362,6 +369,9 @@ fn error_chain_s3s_body_stream_error(err: &(dyn std::error::Error + 'static)) ->
impl From<ApiError> for S3Error {
fn from(err: ApiError) -> Self {
let mut s3e = S3Error::with_message(err.code, err.message);
if matches!(s3e.code(), S3ErrorCode::Custom(code) if &**code == MAX_VERSIONS_EXCEEDED_CODE) {
s3e.set_status_code(StatusCode::BAD_REQUEST);
}
if let Some(source) = err.source {
s3e.set_source(source);
}
@@ -455,6 +465,7 @@ impl From<StorageError> for ApiError {
| StorageError::InsufficientWriteQuorum(_, _) => S3ErrorCode::ServiceUnavailable,
StorageError::NamespaceLockQuorumUnavailable { .. } => S3ErrorCode::ServiceUnavailable,
StorageError::QuotaExceeded { .. } => S3ErrorCode::InvalidRequest,
StorageError::MaxVersionsExceeded => S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()),
StorageError::Lock(_) => S3ErrorCode::ServiceUnavailable,
StorageError::DecommissionNotStarted => S3ErrorCode::InvalidRequest,
StorageError::DecommissionAlreadyRunning => S3ErrorCode::InvalidRequest,
@@ -485,7 +496,9 @@ impl From<StorageError> for ApiError {
let message = if matches!(&err, StorageError::QuotaExceeded { .. }) {
err.to_string()
} else if code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)) {
} else if matches!(&err, StorageError::MaxVersionsExceeded)
|| (code == S3ErrorCode::InternalError && matches!(&err, StorageError::Io(_)))
{
ApiError::error_code_to_message(&code)
} else if code == S3ErrorCode::InternalError {
err.to_string()
@@ -1189,6 +1202,19 @@ mod tests {
assert_eq!(api_error.message, "Bucket quota exceeded. Current usage: 5 bytes, limit: 10 bytes");
}
#[test]
fn max_versions_exceeded_maps_to_minio_compatible_s3_error() {
let api_error: ApiError = StorageError::MaxVersionsExceeded.into();
assert_eq!(api_error.code, S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()));
assert_eq!(api_error.message, MAX_VERSIONS_EXCEEDED_MESSAGE);
let s3_error: S3Error = api_error.into();
assert_eq!(s3_error.code(), &S3ErrorCode::Custom(MAX_VERSIONS_EXCEEDED_CODE.into()));
assert_eq!(s3_error.message(), Some(MAX_VERSIONS_EXCEEDED_MESSAGE));
assert_eq!(s3_error.status_code(), Some(StatusCode::BAD_REQUEST));
}
#[test]
fn test_api_error_to_s3_error_without_source() {
let api_error = ApiError {
+1 -3
View File
@@ -36,9 +36,7 @@ use crate::server::{
};
use crate::storage_api::server::http as storage;
use crate::storage_api::server::http::rpc::InternodeRpcService;
#[cfg(test)]
use crate::storage_api::server::http::tonic_service::make_server;
use crate::storage_api::server::http::tonic_service::make_server_for_slot;
use crate::storage_api::server::http::{
ServerContextSlot, TONIC_RPC_PREFIX, normalize_tonic_rpc_audience, tonic_boot_epoch_challenge,
tonic_boot_epoch_response_headers, verify_tonic_rpc_signature_with_bootstrap,
@@ -1857,7 +1855,7 @@ fn process_connection(
// each service in the auth interceptor.
let rpc_max_message_size = rustfs_protos::internode_rpc_max_message_size();
let node_service = InterceptedService::new(
NodeServiceServer::new(make_server_for_slot(Arc::clone(&server_ctx)))
NodeServiceServer::new(make_server())
.max_decoding_message_size(rpc_max_message_size)
.max_encoding_message_size(rpc_max_message_size),
check_auth,
+3 -1
View File
@@ -124,6 +124,9 @@ pub(crate) async fn run_embedded_startup(args: EmbeddedStartupArgs) -> Result<Em
} else {
bootstrap_instance_ctx()
};
// This server's request-path context slot (backlog#1052 S2).
let server_ctx = ServerContextSlot::new();
let EmbeddedStartupConfig {
config,
identity,
@@ -148,7 +151,6 @@ pub(crate) async fn run_embedded_startup(args: EmbeddedStartupArgs) -> Result<Em
.await
.map_err(init_error)?;
let server_ctx = ServerContextSlot::with_instance_context(instance_ctx.clone());
let http_server = start_embedded_http_server(&config, listen_context.readiness.clone(), server_ctx.clone()).await?;
let shutdown_handle = http_server.shutdown_handle;
let bound_addr = http_server.bound_addr;
+4 -51
View File
@@ -62,29 +62,6 @@ fn emit_fatal_stderr(context: &str, error: impl std::fmt::Display) {
}
async fn async_main() -> Result<()> {
#[cfg(feature = "e2e-test-hooks")]
if let Ok(nonce) = std::env::var("RUSTFS_E2E_STARTUP_CAS_PROBE") {
let nonce = uuid::Uuid::parse_str(&nonce).map_err(Error::other)?;
// This precedes CLI parsing and observability, including `--help`.
println!(
"RUSTFS_E2E_STARTUP_CAS {}",
serde_json::json!({
"kind": "capability", "schema": "fresh-startup-cas/v1", "nonce": nonce,
})
);
return Ok(());
}
#[cfg(feature = "e2e-test-hooks")]
if let Ok(nonce) = std::env::var("RUSTFS_E2E_STARTUP_CAS_NONCE") {
let nonce = uuid::Uuid::parse_str(&nonce).map_err(Error::other)?;
let line = format!(
"RUSTFS_E2E_STARTUP_CAS {}\n",
serde_json::json!({
"kind": "observer-ready", "nonce": nonce, "pid": std::process::id(),
})
);
let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), line.as_bytes());
}
hotpath::tokio_runtime!();
// Log container resource detection early in startup
@@ -164,6 +141,10 @@ async fn run(config: Config) -> Result<()> {
// the storage path explicitly (Phase 5 follow-up, backlog#1052); a future
// multi-instance server constructs its own context here instead.
let instance_ctx = bootstrap_instance_ctx();
// This server's request-path context slot (backlog#1052 S2): handed to the
// HTTP service now, installed once IAM bootstrap completes.
let server_ctx = ServerContextSlot::new();
let StartupListenContext {
readiness,
server_addr,
@@ -171,7 +152,6 @@ async fn run(config: Config) -> Result<()> {
} = init_startup_listen_context(&config, &instance_ctx).await?;
let endpoint_pools = init_startup_storage_foundation(&server_address, &config.volumes, &instance_ctx).await?;
let server_ctx = ServerContextSlot::with_instance_context(instance_ctx.clone());
let StartupHttpServers {
state_manager,
s3_shutdown_tx,
@@ -183,33 +163,6 @@ async fn run(config: Config) -> Result<()> {
shutdown_token: ctx,
} = init_startup_storage_runtime(server_addr, &endpoint_pools, readiness.clone(), instance_ctx).await?;
#[cfg(feature = "e2e-test-hooks")]
if let Ok(nonce) = std::env::var("RUSTFS_E2E_STARTUP_CAS_NONCE") {
let nonce = uuid::Uuid::parse_str(&nonce).map_err(Error::other)?;
let release = std::path::PathBuf::from(
std::env::var_os("RUSTFS_E2E_STARTUP_CAS_RELEASE")
.ok_or_else(|| Error::other("startup CAS fixture requires a release path"))?,
);
if server_ctx.installed_object_store().is_some() {
return Err(Error::other("startup CAS gate reached an installed slot"));
}
let line = format!(
"RUSTFS_E2E_STARTUP_CAS {}\n",
serde_json::json!({
"kind": "gate", "nonce": nonce, "pid": std::process::id(), "slot_installed": false,
})
);
let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), line.as_bytes());
tokio::time::timeout(std::time::Duration::from_secs(180), async {
while !tokio::fs::try_exists(&release).await? {
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
Ok::<_, Error>(())
})
.await
.map_err(|_| Error::other("startup CAS gate release timed out"))??;
}
let capacity_tasks = crate::capacity::capacity_integration::init_capacity_management_managed().await;
let service_runtime = init_startup_runtime_services(
+97 -1
View File
@@ -17,12 +17,108 @@ use crate::{
startup_runtime_hooks::{init_profiling_runtime, install_default_crypto_provider, log_startup_runtime_diagnostics},
startup_tls_material::init_outbound_tls_material,
};
use std::io::Result;
use rustfs_config::ENV_API_OBJECT_MAX_VERSIONS;
use rustfs_utils::EnvParseOutcome;
use std::io::{Error, Result};
pub(crate) async fn init_startup_runtime_foundation(config: &Config) -> Result<()> {
log_startup_runtime_diagnostics();
init_profiling_runtime().await;
rustfs_trusted_proxies::init();
install_default_crypto_provider();
init_object_max_versions_config()?;
init_outbound_tls_material(config).await
}
fn init_object_max_versions_config() -> Result<()> {
let limit = match rustfs_utils::get_env_parse_outcome::<u64>(ENV_API_OBJECT_MAX_VERSIONS) {
EnvParseOutcome::Absent => rustfs_filemeta::DEFAULT_OBJECT_MAX_VERSIONS,
EnvParseOutcome::Invalid => {
return Err(Error::other(format!(
"{ENV_API_OBJECT_MAX_VERSIONS} must be a positive integer no greater than {}",
usize::MAX
)));
}
EnvParseOutcome::Parsed(value) => object_max_versions_limit_from_u64(value)?,
};
rustfs_filemeta::set_object_max_versions(limit).map_err(Error::other)
}
fn object_max_versions_limit_from_u64(value: u64) -> Result<usize> {
if value == 0 {
return Err(Error::other(format!(
"{ENV_API_OBJECT_MAX_VERSIONS} must be a positive integer no greater than {}",
usize::MAX
)));
}
usize::try_from(value).map_err(|_| {
Error::other(format!(
"{ENV_API_OBJECT_MAX_VERSIONS} must be a positive integer no greater than {}",
usize::MAX
))
})
}
#[cfg(test)]
mod tests {
use super::*;
struct ObjectMaxVersionsRestore {
previous: usize,
}
impl Drop for ObjectMaxVersionsRestore {
fn drop(&mut self) {
rustfs_filemeta::set_object_max_versions(self.previous).expect("restore object max versions limit after test");
}
}
fn with_object_max_versions_env<R>(rustfs_value: Option<&str>, minio_value: Option<&str>, test: impl FnOnce() -> R) -> R {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _serial = LOCK.lock().expect("serialize object max versions env tests");
let previous = rustfs_filemeta::object_max_versions();
let _restore = ObjectMaxVersionsRestore { previous };
temp_env::with_vars(
[
(rustfs_config::ENV_API_OBJECT_MAX_VERSIONS, rustfs_value),
("MINIO_API_OBJECT_MAX_VERSIONS", minio_value),
],
test,
)
}
#[test]
fn object_max_versions_env_sets_filemeta_limit() {
with_object_max_versions_env(Some("3"), None, || {
init_object_max_versions_config().expect("valid object max versions env must initialize");
assert_eq!(rustfs_filemeta::object_max_versions(), 3);
});
}
#[test]
fn minio_object_max_versions_env_alias_sets_filemeta_limit() {
with_object_max_versions_env(None, Some("4"), || {
init_object_max_versions_config().expect("valid MinIO alias must initialize");
assert_eq!(rustfs_filemeta::object_max_versions(), 4);
});
}
#[test]
fn object_max_versions_env_rejects_zero() {
with_object_max_versions_env(Some("0"), None, || {
let err = init_object_max_versions_config().expect_err("zero object max versions must fail startup config");
assert!(err.to_string().contains(rustfs_config::ENV_API_OBJECT_MAX_VERSIONS));
});
}
#[test]
fn object_max_versions_env_rejects_malformed_value() {
with_object_max_versions_env(Some("not-a-number"), None, || {
let err = init_object_max_versions_config().expect_err("malformed object max versions must fail startup config");
assert!(err.to_string().contains(rustfs_config::ENV_API_OBJECT_MAX_VERSIONS));
});
}
}
+3 -806
View File
@@ -28,9 +28,9 @@ use crate::storage::storage_api::rpc_consumer::node_service::{
SCANNER_PUBLICATION_LEASE_TTL_MS, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, StorageDiskRpcExt as _,
StorageResult, all_local_disk_path, find_local_disk_by_ref, reload_transition_tier_config,
};
use crate::storage::storage_api::runtime_sources_consumer::{EndpointServerPools, ServerContextSlot, runtime_sources};
use crate::storage::storage_api::runtime_sources_consumer::{EndpointServerPools, runtime_sources};
use crate::storage::storage_api::{
BootstrapLocalTarget, sign_tonic_rpc_response_proof, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest,
sign_tonic_rpc_response_proof, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest,
verify_tonic_mutation_body_digest_reject_unsigned,
};
use bytes::Bytes;
@@ -520,97 +520,6 @@ mod metrics;
pub struct NodeService {
local_peer: LocalPeerS3Client,
context: Option<Arc<runtime_sources::AppContext>>,
server_ctx: Option<Arc<ServerContextSlot>>,
}
enum LocalMutationTarget {
Ready(Arc<ECStore>),
Bootstrap(BootstrapLocalTarget),
Unbound,
}
#[cfg(feature = "e2e-test-hooks")]
pub(crate) mod rename_target_capture_test_hook {
use super::LocalMutationTarget;
use rustfs_protos::proto_gen::node_service::RenameDataRequest;
use std::sync::{LazyLock, Mutex};
use tokio::sync::oneshot;
use uuid::Uuid;
struct Hook {
id: Uuid,
disk: String,
volume: String,
path: String,
captured: oneshot::Sender<bool>,
release: oneshot::Receiver<()>,
}
static HOOK: LazyLock<Mutex<Option<Hook>>> = LazyLock::new(|| Mutex::new(None));
/// One exact signed rename paused after its listener target was captured.
/// Dropping the handle removes an unused hook and releases an entered one.
pub struct RenameTargetCapturePause {
id: Uuid,
captured: oneshot::Receiver<bool>,
release: Option<oneshot::Sender<()>>,
}
impl RenameTargetCapturePause {
pub async fn wait_until_captured(&mut self) -> bool {
(&mut self.captured)
.await
.expect("matching rename must report its actual captured target")
}
}
impl Drop for RenameTargetCapturePause {
fn drop(&mut self) {
let unused = HOOK
.lock()
.expect("rename capture hook lock")
.take_if(|hook| hook.id == self.id);
drop(unused);
if let Some(release) = self.release.take() {
let _ = release.send(());
}
}
}
pub fn pause_rename_after_target_capture(disk: &str, volume: &str, path: &str) -> RenameTargetCapturePause {
let id = Uuid::new_v4();
let (captured_tx, captured) = oneshot::channel();
let (release, release_rx) = oneshot::channel();
let mut active = HOOK.lock().expect("rename capture hook lock");
if active.is_some() {
drop(active);
panic!("only one rename capture hook may be active");
}
*active = Some(Hook {
id,
disk: disk.to_owned(),
volume: volume.to_owned(),
path: path.to_owned(),
captured: captured_tx,
release: release_rx,
});
RenameTargetCapturePause {
id,
captured,
release: Some(release),
}
}
pub(super) async fn wait(target: &LocalMutationTarget, request: &RenameDataRequest) {
let hook = {
let mut active = HOOK.lock().expect("rename capture hook lock");
active.take_if(|hook| hook.disk == request.disk && hook.volume == request.dst_volume && hook.path == request.dst_path)
};
if let Some(hook) = hook {
let _ = hook.captured.send(matches!(target, LocalMutationTarget::Bootstrap(_)));
let _ = hook.release.await;
}
}
}
impl std::fmt::Debug for NodeService {
@@ -636,19 +545,7 @@ pub fn make_server() -> NodeService {
pub fn make_server_for_context(context: Option<Arc<runtime_sources::AppContext>>) -> NodeService {
let local_peer = LocalPeerS3Client::new(None, None);
NodeService {
local_peer,
context,
server_ctx: None,
}
}
pub(crate) fn make_server_for_slot(server_ctx: Arc<ServerContextSlot>) -> NodeService {
// Unrelated RPCs retain their existing context policy. Target mutations
// resolve exclusively through this listener slot on each request.
let mut service = make_server();
service.server_ctx = Some(server_ctx);
service
NodeService { local_peer, context }
}
#[derive(Clone, Debug, Default)]
@@ -1217,24 +1114,6 @@ impl heal_control_service_server::HealControlService for HealControlRpcService {
}
impl NodeService {
fn local_mutation_target(&self) -> LocalMutationTarget {
if let Some(slot) = &self.server_ctx {
// Capture exactly once per request, not at connection acceptance.
// A captured Bootstrap request cannot upgrade across a later await.
if let Some(store) = slot.installed_object_store() {
LocalMutationTarget::Ready(store)
} else if let Some(target) = slot.bootstrap_target() {
LocalMutationTarget::Bootstrap(target)
} else {
LocalMutationTarget::Unbound
}
} else if let Some(context) = &self.context {
LocalMutationTarget::Ready(context.object_store())
} else {
LocalMutationTarget::Unbound
}
}
fn resolve_object_store(&self) -> Option<Arc<ECStore>> {
let context = self.context.clone().or_else(runtime_sources::current_app_context);
runtime_sources::current_object_store_handle_for_context(context.as_deref())
@@ -2844,7 +2723,6 @@ mod tests {
validate_admin_heal_control_start,
};
use crate::storage::rpc::node_service::heal::heal_topology_fingerprint;
use crate::storage::storage_api::ecstore_disk::DiskAPI as _;
use crate::storage::storage_api::rpc_consumer::node_service::{DiskError, HealBucketInfo};
use crate::storage::storage_api::set_tonic_canonical_body_digest;
use crate::storage::storage_api::{
@@ -5325,687 +5203,6 @@ mod tests {
assert!(rename_response.error.is_some());
}
struct TargetRpcFixture {
_root: tempfile::TempDir,
env: rustfs_test_utils::TestECStoreEnv,
instance: Arc<crate::storage::storage_api::InstanceContext>,
context: Arc<crate::runtime_sources::AppContext>,
iam: Arc<rustfs_iam::sys::IamSys<ObjectStore>>,
}
async fn target_rpc_fixture() -> TargetRpcFixture {
super::timeout(Duration::from_secs(90), async {
let root = tempfile::tempdir().expect("target RPC root");
let env = rustfs_test_utils::TestECStoreEnv::builder()
.base_dir(root.path())
.init_bucket_metadata(false)
.build()
.await;
ObjectStore::new(env.ecstore.clone())
.save_iam_config(serde_json::json!({"version": 1}), format!("{}/format.json", *IAM_CONFIG_PREFIX))
.await
.expect("seed real IAM format");
let iam = rustfs_iam::build_iam_sys(env.ecstore.clone())
.await
.expect("build fixture IAM");
let context = Arc::new(crate::runtime_sources::AppContext::with_default_interfaces(
env.ecstore.clone(),
iam.clone(),
Arc::new(KmsServiceManager::new()),
));
let instance = crate::storage::storage_api::bootstrap_instance_ctx();
assert!(
super::BootstrapLocalTarget::new(instance.clone()).is_for_store(&env.ecstore),
"the standard builder must use this exact instance context"
);
super::timeout(Duration::from_secs(10), async {
while env.ecstore.scanner_data_usage_publication_blocked().await {
tokio::task::yield_now().await;
}
})
.await
.expect("startup namespace commits drain before test");
TargetRpcFixture {
_root: root,
env,
instance,
context,
iam,
}
})
.await
.expect("bounded real fixture initialization")
}
async fn stage_target_rpc(fixture: &TargetRpcFixture) -> (super::DiskStore, rustfs_filemeta::FileInfo, Vec<u8>) {
use crate::storage::storage_api::ecstore_disk::{DiskAPI, ReadOptions};
let set = fixture
.env
.ecstore
.all_set_disks()
.into_iter()
.next()
.expect("target erasure set");
let disk = set.disks.read().await.iter().find_map(Clone::clone).expect("local target");
let mut fi = rustfs_filemeta::FileInfo::new("destination", 1, 0);
fi.erasure.index = 1;
fi.version_id = Some(Uuid::new_v4());
fi.mod_time = Some(OffsetDateTime::now_utc());
fi.size = 17;
fi.parts = vec![rustfs_filemeta::ObjectPartInfo {
number: 1,
size: 17,
actual_size: 17,
..Default::default()
}];
fi.data = Some(Bytes::from_static(b"target-rpc-inline"));
fi.set_inline_data();
disk.make_volume("target-rpc").await.expect("target volume");
disk.write_metadata("target-rpc", "target-rpc", "staged", fi.clone())
.await
.expect("stage real inline body");
let read = disk
.read_version(
"target-rpc",
"target-rpc",
"staged",
&fi.version_id.expect("version").to_string(),
&ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("read staged body before mutation");
assert_eq!(read.data, fi.data);
let before = tokio::fs::read(disk.path().join("target-rpc/staged/xl.meta"))
.await
.expect("staged bytes");
(disk, fi, before)
}
fn target_rename_request(disk: &super::DiskStore, fi: &rustfs_filemeta::FileInfo) -> Request<RenameDataRequest> {
let mut request = Request::new(RenameDataRequest {
disk: disk.endpoint().to_string(),
src_volume: "target-rpc".to_string(),
src_path: "staged".to_string(),
dst_volume: "target-rpc".to_string(),
dst_path: "destination".to_string(),
file_info: serde_json::to_string(fi).expect("real FileInfo JSON"),
..Default::default()
});
let body = rustfs_protos::canonical_rename_data_request_body(request.get_ref()).expect("canonical target body");
set_tonic_canonical_body_digest(&mut request, &body).expect("body digest");
// Direct-handler precondition only; this does not stand in for wire authentication.
mark_v2_authenticated(&mut request);
request
}
#[tokio::test]
async fn target_slot_rejects_mismatched_and_repeated_install_before_global_publication() {
let fixture = target_rpc_fixture().await;
assert!(
crate::runtime_sources::current_app_context().is_none(),
"requires a separate nextest process"
);
let wrong = super::ServerContextSlot::with_instance_context(crate::storage::storage_api::new_instance_ctx());
let error = crate::runtime_sources::AppContext::ensure_startup_after_iam(
fixture.env.ecstore.clone(),
Arc::new(KmsServiceManager::new()),
&wrong,
fixture.iam.clone(),
)
.expect_err("mismatched startup must fail");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert!(wrong.installed_app_context().is_none());
assert!(
crate::runtime_sources::current_app_context().is_none(),
"failed install must not publish globally"
);
assert!(!wrong.install(fixture.context.clone()), "bool adapter cannot bypass identity checks");
let slot = super::ServerContextSlot::with_instance_context(fixture.instance.clone());
crate::runtime_sources::AppContext::ensure_startup_after_iam(
fixture.env.ecstore.clone(),
Arc::new(KmsServiceManager::new()),
&slot,
fixture.iam,
)
.expect("matching startup installation");
let installed = slot.installed_app_context().expect("installed A");
assert!(Arc::ptr_eq(
&crate::runtime_sources::current_app_context().expect("published A"),
&installed
));
assert_eq!(
slot.try_install(installed.clone())
.expect_err("same Arc is still a duplicate")
.kind(),
std::io::ErrorKind::AlreadyExists
);
assert!(!slot.install(installed.clone()));
assert!(Arc::ptr_eq(&slot.installed_app_context().expect("first winner retained"), &installed));
}
#[tokio::test]
async fn target_slot_captures_bootstrap_once_and_next_request_observes_ready() {
let fixture = target_rpc_fixture().await;
let (disk, fi, before) = stage_target_rpc(&fixture).await;
let slot = super::ServerContextSlot::with_instance_context(fixture.instance.clone());
let service = super::make_server_for_slot(slot.clone());
let captured = service.local_mutation_target();
slot.try_install(fixture.context.clone())
.expect("install after the request captures bootstrap");
let super::LocalMutationTarget::Bootstrap(target) = captured else {
panic!("pre-install request must capture bootstrap");
};
assert!(
target
.rename_local_data(
&disk.endpoint().to_string(),
("target-rpc", "staged"),
&fi,
("target-rpc", "destination"),
None
)
.await
.is_err(),
"captured request cannot acquire Ready privileges"
);
assert_eq!(
tokio::fs::read(disk.path().join("target-rpc/staged/xl.meta"))
.await
.expect("original source"),
before
);
assert!(!disk.path().join("target-rpc/destination").exists());
assert!(
matches!(service.local_mutation_target(), super::LocalMutationTarget::Ready(_)),
"the same service must read the installed slot for its next request"
);
let result = service
.rename_data(target_rename_request(&disk, &fi))
.await
.expect("ready handler")
.into_inner();
assert!(result.success, "{:?}", result.error);
}
#[tokio::test]
async fn target_unbound_slot_never_mutates_a_published_global_store() {
let fixture = target_rpc_fixture().await;
let (disk, fi, before) = stage_target_rpc(&fixture).await;
let published = crate::runtime_sources::publish_test_app_context(fixture.context.clone());
assert!(Arc::ptr_eq(&published, &fixture.context));
let service = super::make_server_for_slot(super::ServerContextSlot::new());
let result = service
.rename_data(target_rename_request(&disk, &fi))
.await
.expect("handler reply")
.into_inner();
assert!(!result.success);
assert!(result.error.is_some());
assert_eq!(
tokio::fs::read(disk.path().join("target-rpc/staged/xl.meta"))
.await
.expect("source remains"),
before
);
assert!(!disk.path().join("target-rpc/destination").exists());
assert!(!fixture.env.ecstore.scanner_data_usage_publication_blocked().await);
}
#[tokio::test]
async fn target_undo_rejects_force_delete_marker_before_mutation() {
let fixture = target_rpc_fixture().await;
let (disk, fi, before) = stage_target_rpc(&fixture).await;
let service = make_server_for_context(Some(fixture.context.clone()));
let opts = crate::storage::storage_api::ecstore_disk::DeleteOptions {
undo_write: true,
..Default::default()
};
let mut request = Request::new(DeleteVersionRequest {
disk: disk.endpoint().to_string(),
volume: "target-rpc".to_string(),
path: "staged".to_string(),
file_info: serde_json::to_string(&fi).expect("FileInfo"),
opts: serde_json::to_string(&opts).expect("opts"),
force_del_marker: true,
..Default::default()
});
let body = rustfs_protos::canonical_delete_version_request_body(request.get_ref()).expect("canonical undo body");
set_tonic_canonical_body_digest(&mut request, &body).expect("body digest");
mark_v2_authenticated(&mut request);
let result = service.delete_version(request).await.expect("handler reply").into_inner();
assert!(!result.success);
assert!(result.error.is_some());
assert_eq!(
tokio::fs::read(disk.path().join("target-rpc/staged/xl.meta"))
.await
.expect("source remains"),
before
);
assert!(!fixture.env.ecstore.scanner_data_usage_publication_blocked().await);
}
#[cfg(not(windows))]
#[tokio::test]
async fn target_handler_cancellation_retains_namespace_through_physical_rename() {
use crate::storage::storage_api::{
LocalPublicationPause, LocalPublicationStage,
ecstore_disk::{DiskAPI, ReadOptions},
};
let fixture = target_rpc_fixture().await;
let (disk, fi, _) = stage_target_rpc(&fixture).await;
let slot = super::ServerContextSlot::with_instance_context(fixture.instance.clone());
slot.try_install(fixture.context.clone()).expect("ready target");
let service = super::make_server_for_slot(slot);
let mut pause =
LocalPublicationPause::install(&disk, "target-rpc", "destination/xl.meta", LocalPublicationStage::PreparedRename)
.expect("install scoped physical pause");
let mut handler = Box::pin(service.rename_data(target_rename_request(&disk, &fi)));
super::timeout(Duration::from_secs(10), async {
tokio::select! {
result = &mut handler => panic!("handler completed before physical entry: {result:?}"),
entered = pause.entered() => entered.expect("physical executor entered"),
}
})
.await
.expect("bounded physical entry");
drop(handler);
assert!(
fixture.env.ecstore.scanner_data_usage_publication_blocked().await,
"dropping the actual target handler must not release its physical owner"
);
drop(pause);
super::timeout(Duration::from_secs(10), async {
while fixture.env.ecstore.scanner_data_usage_publication_blocked().await {
tokio::task::yield_now().await;
}
})
.await
.expect("physical owner must drain");
let read = disk
.read_version(
"target-rpc",
"target-rpc",
"destination",
&fi.version_id.expect("version").to_string(),
&ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("read real late commit");
assert_eq!(read.data, fi.data);
}
#[cfg(not(windows))]
#[tokio::test]
async fn target_undo_handler_cancellation_retains_owner_until_backup_restoration() {
use crate::storage::storage_api::{
LocalPublicationPause, LocalPublicationStage,
ecstore_disk::{DeleteOptions, DiskAPI, ReadOptions},
};
let fixture = target_rpc_fixture().await;
let (disk, fi, _) = stage_target_rpc(&fixture).await;
let mut old = fi.clone();
old.data = Some(Bytes::from_static(b"previous-rpc-body"));
assert_eq!(old.data.as_ref().expect("old body").len(), 17);
disk.write_metadata("target-rpc", "target-rpc", "destination", old.clone())
.await
.expect("old actual version");
let old_bytes = tokio::fs::read(disk.path().join("target-rpc/destination/xl.meta"))
.await
.expect("old metadata bytes");
let committed = fixture
.env
.ecstore
.rename_local_data(
&disk.endpoint().to_string(),
("target-rpc", "staged"),
&fi,
("target-rpc", "destination"),
None,
)
.await
.expect("real overwrite creates rollback backup");
let opts = DeleteOptions {
undo_write: true,
old_data_dir: Some(committed.rollback_data_dir.expect("real rollback backup")),
..Default::default()
};
let service = make_server_for_context(Some(fixture.context.clone()));
let mut request = Request::new(DeleteVersionRequest {
disk: disk.endpoint().to_string(),
volume: "target-rpc".to_string(),
path: "destination".to_string(),
file_info: serde_json::to_string(&fi).expect("FileInfo"),
opts: serde_json::to_string(&opts).expect("undo options"),
..Default::default()
});
let body = rustfs_protos::canonical_delete_version_request_body(request.get_ref()).expect("canonical undo body");
set_tonic_canonical_body_digest(&mut request, &body).expect("body digest");
mark_v2_authenticated(&mut request);
let mut pause = LocalPublicationPause::install(&disk, "target-rpc", "destination/xl.meta", LocalPublicationStage::Rename)
.expect("pause actual backup restoration");
let mut handler = Box::pin(service.delete_version(request));
super::timeout(Duration::from_secs(10), async {
tokio::select! {
result = &mut handler => panic!("undo completed before physical entry: {result:?}"),
entered = pause.entered() => entered.expect("physical restore entered"),
}
})
.await
.expect("bounded physical restore entry");
drop(handler);
assert!(fixture.env.ecstore.scanner_data_usage_publication_blocked().await);
drop(pause);
super::timeout(Duration::from_secs(10), async {
while fixture.env.ecstore.scanner_data_usage_publication_blocked().await {
tokio::task::yield_now().await;
}
})
.await
.expect("restore owner drains");
assert_eq!(
tokio::fs::read(disk.path().join("target-rpc/destination/xl.meta"))
.await
.expect("restored bytes"),
old_bytes
);
let read = disk
.read_version(
"target-rpc",
"target-rpc",
"destination",
&fi.version_id.expect("version").to_string(),
&ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("restored readable version");
assert_eq!(read.data, old.data);
}
#[tokio::test]
async fn rename_data_same_uuid_uses_captured_instance_instead_of_global_disk() {
use crate::storage::storage_api::{
ECStore,
ecstore_disk::{DiskAPI, RUSTFS_META_BUCKET, ReadOptions},
init_local_disks_with_instance_ctx, new_instance_ctx, read_config_no_lock,
};
use rustfs_filemeta::{FileInfo, ObjectPartInfo};
use tokio_util::sync::CancellationToken;
async fn build_store(root: &std::path::Path) -> Arc<ECStore> {
let mut endpoints = Vec::new();
for index in 0..4 {
let path = root.join(format!("disk{index}"));
tokio::fs::create_dir_all(&path).await.expect("create instance disk");
let mut endpoint = Endpoint::try_from(path.to_str().expect("UTF-8 disk path")).expect("local endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(index);
endpoints.push(endpoint);
}
let pools = EndpointServerPools(vec![PoolEndpoints {
legacy: false,
set_count: 1,
drives_per_set: 4,
endpoints: Endpoints::from(endpoints),
cmd_line: "namespace-target-context".to_string(),
platform: "test".to_string(),
}]);
let instance = new_instance_ctx();
init_local_disks_with_instance_ctx(&instance, pools.clone())
.await
.expect("register this instance's real disks");
// Match the isolated ECStore fixtures: startup still runs, while
// unrelated background recovery is cancelled for this process.
let shutdown = CancellationToken::new();
shutdown.cancel();
ECStore::new_with_instance_ctx("127.0.0.1:0".parse().expect("local address"), pools, shutdown, instance)
.await
.expect("initialize isolated ECStore")
}
async fn context(store: &Arc<ECStore>) -> Arc<crate::runtime_sources::AppContext> {
ObjectStore::new(store.clone())
.save_iam_config(serde_json::json!({"version": 1}), format!("{}/format.json", *IAM_CONFIG_PREFIX))
.await
.expect("seed isolated IAM format");
let iam = rustfs_iam::build_iam_sys(store.clone()).await.expect("build isolated IAM");
Arc::new(crate::runtime_sources::AppContext::with_default_interfaces(
store.clone(),
iam,
Arc::new(KmsServiceManager::new()),
))
}
async fn internal_snapshot(root: &std::path::Path) -> std::collections::BTreeMap<std::path::PathBuf, Option<Vec<u8>>> {
let mut snapshot = std::collections::BTreeMap::new();
let mut directories = (0..4)
.map(|index| std::path::PathBuf::from(format!("disk{index}/{RUSTFS_META_BUCKET}")))
.collect::<Vec<_>>();
while let Some(relative) = directories.pop() {
let mut entries = tokio::fs::read_dir(root.join(&relative))
.await
.expect("read internal snapshot directory");
snapshot.insert(relative.clone(), None);
while let Some(entry) = entries.next_entry().await.expect("read internal snapshot entry") {
let path = relative.join(entry.file_name());
let file_type = entry.file_type().await.expect("read internal snapshot entry type");
if file_type.is_dir() {
directories.push(path);
} else {
assert!(file_type.is_file(), "fixture snapshot must contain only directories and regular files");
snapshot.insert(path, Some(tokio::fs::read(entry.path()).await.expect("read snapshot file bytes")));
}
}
}
snapshot
}
fn file_info(object: &str, version: Uuid, body: Bytes) -> FileInfo {
let mut fi = FileInfo::new(object, 1, 0);
fi.erasure.index = 1;
fi.name = object.to_string();
fi.version_id = Some(version);
fi.size = i64::try_from(body.len()).expect("small fixture body");
fi.parts = vec![ObjectPartInfo {
number: 1,
size: body.len(),
actual_size: fi.size,
..Default::default()
}];
fi.data = Some(body);
fi.set_inline_data();
fi.mod_time = Some(OffsetDateTime::now_utc());
fi
}
// Both global publications are first-writer-wins. Run this fixture in
// its own nextest process; do not reset or replace another test's state.
assert!(
crate::runtime_sources::current_app_context().is_none(),
"requires an unpublished AppContext"
);
super::timeout(Duration::from_secs(90), async {
let root_b = tempfile::tempdir().expect("instance B directory");
let root_a = tempfile::tempdir().expect("instance A directory");
let store_b = build_store(root_b.path()).await;
let context_b = context(&store_b).await;
let published = crate::runtime_sources::publish_test_app_context(context_b.clone());
assert!(Arc::ptr_eq(&published, &context_b), "B must win the process AppContext publication");
// Existing formats require their committed pool metadata on restart.
// Copy the complete internal trees, including erasure part data,
// without editing disk IDs, cluster identity, epochs or pool topology.
super::timeout(Duration::from_secs(10), async {
while store_b.scanner_data_usage_publication_blocked().await {
tokio::task::yield_now().await;
}
})
.await
.expect("B startup namespace commits must drain before its snapshot");
let snapshot_generation = store_b.scanner_namespace_mutation_generation();
let pool_config = read_config_no_lock(store_b.clone(), "pool.bin")
.await
.expect("read B's actually committed pool metadata");
let pool_identity = read_config_no_lock(store_b.clone(), "pool.bin.identity")
.await
.expect("read B's actually committed pool identity");
let snapshot = internal_snapshot(root_b.path()).await;
for (relative, contents) in &snapshot {
let target = root_a.path().join(relative);
match contents {
None => tokio::fs::create_dir_all(target).await.expect("copy internal directory"),
Some(bytes) => tokio::fs::write(target, bytes).await.expect("copy complete internal file"),
}
}
assert_eq!(internal_snapshot(root_a.path()).await, snapshot, "A must receive the complete physical snapshot");
assert_eq!(internal_snapshot(root_b.path()).await, snapshot, "B's source snapshot must remain unchanged");
assert!(!store_b.scanner_data_usage_publication_blocked().await);
assert_eq!(store_b.scanner_namespace_mutation_generation(), snapshot_generation);
let store_a = build_store(root_a.path()).await;
assert_eq!(
read_config_no_lock(store_a.clone(), "pool.bin").await.expect("read A's restarted pool metadata"),
pool_config,
"A must load the same committed topology without a bootstrap rewrite"
);
assert_eq!(
read_config_no_lock(store_a.clone(), "pool.bin.identity")
.await
.expect("read A's restarted pool identity"),
pool_identity,
"A must preserve the initialized cluster identity and epoch"
);
let service = make_server_for_context(Some(context(&store_a).await));
assert!(Arc::ptr_eq(&service.resolve_object_store().expect("captured store"), &store_a));
assert!(Arc::ptr_eq(
&crate::runtime_sources::current_object_store_handle().expect("global store"),
&store_b
));
let disk_a = store_a.disk_map[&0][0].as_ref().expect("A disk zero").clone();
let disk_b = store_b.disk_map[&0][0].as_ref().expect("B disk zero").clone();
assert!(disk_a.is_local() && disk_b.is_local());
assert!(!Arc::ptr_eq(&disk_a, &disk_b));
let disk_id = disk_a.get_disk_id().await.expect("A disk ID").expect("formatted A disk");
assert!(!disk_id.is_nil());
assert_eq!(disk_b.get_disk_id().await.expect("B disk ID"), Some(disk_id));
let global_disk = super::find_local_disk_by_ref(&disk_id.to_string())
.await
.expect("global UUID lookup must resolve B before the request");
assert!(Arc::ptr_eq(&global_disk, &disk_b));
let volume = "namespace-target-context";
let object = "destination";
let staging = "staged";
let version = Uuid::new_v4();
let new_body = Bytes::from_static(b"committed-through-captured-A");
let new_fi = file_info(object, version, new_body.clone());
let opts = ReadOptions { read_data: true, ..Default::default() };
for (disk, old_body) in [
(&disk_a, Bytes::from_static(b"old-body-A")),
(&disk_b, Bytes::from_static(b"old-body-B")),
] {
disk.make_volume(volume).await.expect("create destination volume");
disk.write_metadata(volume, volume, object, file_info(object, version, old_body.clone()))
.await
.expect("write real old object metadata and inline body");
disk.write_metadata(volume, volume, staging, new_fi.clone())
.await
.expect("stage identical valid metadata on both physical disks");
let seeded = disk
.read_version(volume, volume, object, &version.to_string(), &opts)
.await
.expect("decode seeded inline object before invoking the handler");
assert_eq!(seeded.data, Some(old_body), "the real reader must return the seeded body");
}
let a_meta = disk_a.path().join(volume).join(object).join("xl.meta");
let b_meta = disk_b.path().join(volume).join(object).join("xl.meta");
let a_staging = disk_a.path().join(volume).join(staging).join("xl.meta");
let b_staging = disk_b.path().join(volume).join(staging).join("xl.meta");
let a_before = tokio::fs::read(&a_meta).await.expect("A old metadata bytes");
let b_before = tokio::fs::read(&b_meta).await.expect("B old metadata bytes");
let b_staging_before = tokio::fs::read(&b_staging).await.expect("B staged metadata bytes");
assert!(tokio::fs::try_exists(&a_staging).await.expect("A staging exists"));
assert_ne!(a_before, b_before, "the old on-disk bodies must distinguish A from B");
super::timeout(Duration::from_secs(10), async {
while store_a.scanner_data_usage_publication_blocked().await
|| store_b.scanner_data_usage_publication_blocked().await
{
tokio::task::yield_now().await;
}
})
.await
.expect("startup namespace commits must drain before measuring the handler");
let generation_before = (
store_a.scanner_namespace_mutation_generation(),
store_b.scanner_namespace_mutation_generation(),
);
let mut request = Request::new(RenameDataRequest {
disk: disk_id.to_string(),
src_volume: volume.to_string(),
src_path: staging.to_string(),
dst_volume: volume.to_string(),
dst_path: object.to_string(),
file_info: serde_json::to_string(&new_fi).expect("encode real FileInfo"),
file_info_bin: Vec::new().into(),
scanner_publication_lease_token: Vec::new().into(),
});
let body = rustfs_protos::canonical_rename_data_request_body(request.get_ref()).expect("canonical rename body");
set_tonic_canonical_body_digest(&mut request, &body).expect("body-bound handler request");
mark_v2_authenticated(&mut request);
let response = super::timeout(Duration::from_secs(10), service.rename_data(request))
.await
.expect("real rename handler must finish within ten seconds")
.expect("rename handler response")
.into_inner();
assert!(response.success, "the valid staged rename must execute: {:?}", response.error);
let a_after = disk_a
.read_version(volume, volume, object, &version.to_string(), &opts)
.await
.expect("read A's physical object after rename");
let b_after = disk_b
.read_version(volume, volume, object, &version.to_string(), &opts)
.await
.expect("read B's physical object after rename");
let a_bytes_after = tokio::fs::read(&a_meta).await.expect("A metadata after rename");
let b_bytes_after = tokio::fs::read(&b_meta).await.expect("B metadata after rename");
let b_staging_after = tokio::fs::read(&b_staging).await.ok();
let generation_after = (
store_a.scanner_namespace_mutation_generation(),
store_b.scanner_namespace_mutation_generation(),
);
let pending_after = (
store_a.scanner_data_usage_publication_blocked().await,
store_b.scanner_data_usage_publication_blocked().await,
);
for disk in store_a.disk_map.values().chain(store_b.disk_map.values()).flatten().flatten() {
disk.close().await.expect("close real fixture disk before assertions and directory cleanup");
}
assert_eq!(
a_after.data,
Some(new_body),
"RenameData must commit to captured A, even when global B owns the same UUID; B body={:?}, generations={generation_before:?}->{generation_after:?}, pending={pending_after:?}",
b_after.data
);
assert_ne!(a_bytes_after, a_before, "A metadata must actually be replaced");
assert_eq!(b_after.data, Some(Bytes::from_static(b"old-body-B")), "B body must remain unchanged");
assert_eq!(b_bytes_after, b_before, "B metadata must remain byte-for-byte unchanged");
assert_eq!(b_staging_after, Some(b_staging_before), "B staging must not be consumed");
assert_eq!(pending_after, (false, false), "both stores must reach a stable terminal state");
})
.await
.expect("two-instance handler fixture must finish within ninety seconds");
}
#[tokio::test]
async fn test_make_volumes_invalid_disk() {
let service = create_test_node_service();
+126 -165
View File
@@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::{LocalMutationTarget, NodeService};
use super::NodeService;
use crate::storage::storage_api::rpc_consumer::node_service::{
BatchReadVersionReq, BatchReadVersionResp, DeleteOptions, DiskError, DiskInfoOptions, FileInfoVersions, ReadMultipleReq,
ReadMultipleResp, ReadOptions, StorageDiskRpcExt as _, UpdateMetadataOpts, validate_batch_read_version_item_count,
@@ -39,69 +39,6 @@ use tonic::{Request, Response, Status};
use tracing::debug;
use uuid::Uuid;
#[cfg(feature = "e2e-test-hooks")]
fn startup_cas_rename_observation(
target: &LocalMutationTarget,
request: &RenameDataRequest,
file_info: &FileInfo,
) -> Option<serde_json::Value> {
use sha2::{Digest, Sha256};
if request.dst_volume != ".rustfs.sys" || !matches!(request.dst_path.as_str(), "pool.bin" | "pool.bin.identity") {
return None;
}
let nonce = uuid::Uuid::parse_str(&std::env::var("RUSTFS_E2E_STARTUP_CAS_NONCE").ok()?).ok()?;
let body = rustfs_protos::canonical_rename_data_request_body(request).ok()?;
Some(serde_json::json!({
"kind": "receiver", "nonce": nonce, "pid": std::process::id(),
"target": match target { LocalMutationTarget::Ready(_) => "ready", LocalMutationTarget::Bootstrap(_) => "bootstrap", LocalMutationTarget::Unbound => "unbound" },
"disk": request.disk, "src_volume": request.src_volume, "src_path": request.src_path,
"dst_volume": request.dst_volume, "dst_path": request.dst_path,
"body_sha256": rustfs_utils::crypto::hex(Sha256::digest(body)),
"etag": file_info.metadata.get("etag"),
"mod_time": file_info.mod_time.map(|time| time.unix_timestamp_nanos().to_string()),
}))
}
impl LocalMutationTarget {
async fn rename_local_data(
&self,
disk_ref: &str,
source: (&str, &str),
fi: &FileInfo,
destination: (&str, &str),
scanner_token: Option<Uuid>,
) -> Result<RenameDataResp, DiskError> {
match self {
Self::Ready(store) => {
store
.rename_local_data(disk_ref, source, fi, destination, scanner_token)
.await
}
Self::Bootstrap(target) => {
target
.rename_local_data(disk_ref, source, fi, destination, scanner_token)
.await
}
Self::Unbound => Err(DiskError::other("target disk instance is unavailable")),
}
}
async fn undo_local_write(
&self,
disk_ref: &str,
volume: &str,
path: &str,
fi: FileInfo,
opts: DeleteOptions,
) -> Result<(), DiskError> {
match self {
Self::Ready(store) => store.undo_local_write(disk_ref, volume, path, fi, opts).await,
Self::Bootstrap(target) => target.undo_local_write(disk_ref, volume, path, fi, opts).await,
Self::Unbound => Err(DiskError::other("target disk instance is unavailable")),
}
}
}
/// Initial capacity hint (bytes) for typical small msgpack requests and responses.
const MSGPACK_ENCODE_CAPACITY_HINT: usize = 512;
const FILE_INFO_MSGPACK_ENCODE_CAPACITY_HINT: usize = 1024;
@@ -733,59 +670,55 @@ impl NodeService {
"delete_version",
)?;
let request = request.into_inner();
let file_info = match decode_msgpack_or_json::<FileInfo>(&request.file_info_bin, &request.file_info, "FileInfo") {
Ok(file_info) => file_info,
Err(err) => {
return Ok(Response::new(DeleteVersionResponse {
success: false,
raw_file_info: "".to_string(),
error: Some(DiskError::other(format!("decode FileInfo failed: {err}")).into()),
}));
}
};
let opts = match decode_msgpack_or_json::<DeleteOptions>(&request.opts_bin, &request.opts, "DeleteOptions") {
Ok(opts) => opts,
Err(err) => {
return Ok(Response::new(DeleteVersionResponse {
success: false,
raw_file_info: "".to_string(),
error: Some(DiskError::other(format!("decode DeleteOptions failed: {err}")).into()),
}));
}
};
let result = if opts.undo_write {
if request.force_del_marker {
Err(DiskError::other("undo_write cannot force a delete marker"))
} else {
let target = self.local_mutation_target();
target
.undo_local_write(&request.disk, &request.volume, &request.path, file_info, opts)
.await
}
} else if let Some(disk) = self.find_disk(&request.disk).await {
disk.delete_version(&request.volume, &request.path, file_info, request.force_del_marker, opts)
if let Some(disk) = self.find_disk(&request.disk).await {
let file_info = match decode_msgpack_or_json::<FileInfo>(&request.file_info_bin, &request.file_info, "FileInfo") {
Ok(file_info) => file_info,
Err(err) => {
return Ok(Response::new(DeleteVersionResponse {
success: false,
raw_file_info: "".to_string(),
error: Some(DiskError::other(format!("decode FileInfo failed: {err}")).into()),
}));
}
};
let opts = match decode_msgpack_or_json::<DeleteOptions>(&request.opts_bin, &request.opts, "DeleteOptions") {
Ok(opts) => opts,
Err(err) => {
return Ok(Response::new(DeleteVersionResponse {
success: false,
raw_file_info: "".to_string(),
error: Some(DiskError::other(format!("decode DeleteOptions failed: {err}")).into()),
}));
}
};
match disk
.delete_version(&request.volume, &request.path, file_info, request.force_del_marker, opts)
.await
} else {
Err(DiskError::other("cannot find disk"))
};
match result {
Ok(raw_file_info) => match serde_json::to_string(&raw_file_info) {
Ok(raw_file_info) => Ok(Response::new(DeleteVersionResponse {
success: true,
raw_file_info,
error: None,
})),
{
Ok(raw_file_info) => match serde_json::to_string(&raw_file_info) {
Ok(raw_file_info) => Ok(Response::new(DeleteVersionResponse {
success: true,
raw_file_info,
error: None,
})),
Err(err) => Ok(Response::new(DeleteVersionResponse {
success: false,
raw_file_info: "".to_string(),
error: Some(DiskError::other(format!("encode data failed: {err}")).into()),
})),
},
Err(err) => Ok(Response::new(DeleteVersionResponse {
success: false,
raw_file_info: "".to_string(),
error: Some(DiskError::other(format!("encode data failed: {err}")).into()),
error: Some(err.into()),
})),
},
Err(err) => Ok(Response::new(DeleteVersionResponse {
}
} else {
Ok(Response::new(DeleteVersionResponse {
success: false,
raw_file_info: "".to_string(),
error: Some(err.into()),
})),
error: Some(DiskError::other("cannot find disk".to_string()).into()),
}))
}
}
@@ -1273,70 +1206,98 @@ impl NodeService {
"rename_data",
)?;
let request = request.into_inner();
let target = self.local_mutation_target();
#[cfg(feature = "e2e-test-hooks")]
super::rename_target_capture_test_hook::wait(&target, &request).await;
let decoded_file_info = match decode_rename_data_request_file_info(&request.file_info_bin, &request.file_info) {
Ok(file_info) => file_info,
Err(err) => {
return Ok(Response::new(RenameDataResponse {
success: false,
rename_data_resp: String::new(),
rename_data_resp_bin: Vec::new().into(),
error: Some(DiskError::other(format!("decode FileInfo failed: {err}")).into()),
}));
}
};
let scanner_publication_lease_token = if request.scanner_publication_lease_token.is_empty() {
None
} else {
let token = Uuid::from_slice(&request.scanner_publication_lease_token)
.map_err(|_| Status::invalid_argument("scanner publication lease token must be a UUID"))?;
if token.is_nil() {
return Err(Status::invalid_argument("scanner publication lease token must not be nil"));
}
Some(token)
};
let request_decoded_from_msgpack = decoded_file_info.from_msgpack;
#[cfg(feature = "e2e-test-hooks")]
let observation = startup_cas_rename_observation(&target, &request, &decoded_file_info.value);
let result = target
.rename_local_data(
&request.disk,
(&request.src_volume, &request.src_path),
&decoded_file_info.value,
(&request.dst_volume, &request.dst_path),
scanner_publication_lease_token,
)
.await;
#[cfg(feature = "e2e-test-hooks")]
if let Some(mut observation) = observation {
observation["ok"] = serde_json::json!(result.is_ok());
observation["error"] = serde_json::json!(result.as_ref().err().map(ToString::to_string));
let line = format!("RUSTFS_E2E_STARTUP_CAS {observation}\n");
let _ = std::io::Write::write_all(&mut std::io::stderr().lock(), line.as_bytes());
}
match result {
Ok(rename_data_resp) => match encode_rename_data_response_payloads(&rename_data_resp, request_decoded_from_msgpack) {
Ok((rename_data_resp, rename_data_resp_bin)) => Ok(Response::new(RenameDataResponse {
success: true,
rename_data_resp,
rename_data_resp_bin: rename_data_resp_bin.into(),
error: None,
})),
if let Some(disk) = self.find_disk(&request.disk).await {
let decoded_file_info = match decode_rename_data_request_file_info(&request.file_info_bin, &request.file_info) {
Ok(file_info) => file_info,
Err(err) => {
return Ok(Response::new(RenameDataResponse {
success: false,
rename_data_resp: String::new(),
rename_data_resp_bin: Vec::new().into(),
error: Some(DiskError::other(format!("decode FileInfo failed: {err}")).into()),
}));
}
};
let scanner_publication_lease_token = if request.scanner_publication_lease_token.is_empty() {
None
} else {
let token = Uuid::from_slice(&request.scanner_publication_lease_token)
.map_err(|_| Status::invalid_argument("scanner publication lease token must be a UUID"))?;
if token.is_nil() {
return Err(Status::invalid_argument("scanner publication lease token must not be nil"));
}
Some(token)
};
// The target owns this read guard. It must span the complete
// disk rename, not merely the preflight, so a movement transition
// cannot restart after validation and before rename linearization.
let scanner_publication_lease_guard: Option<Arc<dyn Send + Sync>> =
if let Some(token) = scanner_publication_lease_token {
let Some(store) = self.resolve_object_store() else {
return Ok(Response::new(RenameDataResponse {
success: false,
rename_data_resp: String::new(),
rename_data_resp_bin: Vec::new().into(),
error: Some(DiskError::other("scanner publication lease owner is unavailable").into()),
}));
};
match store.acquire_scanner_publication_lease_guard(token).await {
Ok(guard) => Some(Arc::new(guard)),
Err(err) => {
return Ok(Response::new(RenameDataResponse {
success: false,
rename_data_resp: String::new(),
rename_data_resp_bin: Vec::new().into(),
error: Some(DiskError::other(err.to_string()).into()),
}));
}
}
} else {
None
};
let request_decoded_from_msgpack = decoded_file_info.from_msgpack;
match disk
.rename_data_borrowed_with_fence_and_guard(
&request.src_volume,
&request.src_path,
&decoded_file_info.value,
&request.dst_volume,
&request.dst_path,
scanner_publication_lease_token,
scanner_publication_lease_guard,
)
.await
{
Ok(rename_data_resp) => {
match encode_rename_data_response_payloads(&rename_data_resp, request_decoded_from_msgpack) {
Ok((rename_data_resp, rename_data_resp_bin)) => Ok(Response::new(RenameDataResponse {
success: true,
rename_data_resp,
rename_data_resp_bin: rename_data_resp_bin.into(),
error: None,
})),
Err(err) => Ok(Response::new(RenameDataResponse {
success: false,
rename_data_resp: String::new(),
rename_data_resp_bin: Vec::new().into(),
error: Some(err.into()),
})),
}
}
Err(err) => Ok(Response::new(RenameDataResponse {
success: false,
rename_data_resp: String::new(),
rename_data_resp_bin: Vec::new().into(),
error: Some(err.into()),
})),
},
Err(err) => Ok(Response::new(RenameDataResponse {
}
} else {
Ok(Response::new(RenameDataResponse {
success: false,
rename_data_resp: String::new(),
rename_data_resp_bin: Vec::new().into(),
error: Some(err.into()),
})),
error: Some(DiskError::other("cannot find disk".to_string()).into()),
}))
}
}
+3 -8
View File
@@ -377,12 +377,10 @@ pub(crate) mod timeout_wrapper_consumer {
}
pub(crate) mod tonic_service_consumer {
#[cfg(test)]
pub(crate) use super::super::tonic_service::make_server;
#[cfg(test)]
pub(crate) use super::super::tonic_service::{heal_topology_fingerprint, make_heal_control_server_for_source};
pub(crate) use super::super::tonic_service::{
make_heal_control_server_with_cache, make_scanner_control_server, make_server_for_slot, make_tier_mutation_control_server,
make_heal_control_server_with_cache, make_scanner_control_server, make_server, make_tier_mutation_control_server,
};
}
@@ -604,8 +602,8 @@ pub(crate) mod ecstore_storage {
#[cfg(test)]
pub(crate) use rustfs_ecstore::api::storage::init_local_disks;
pub(crate) use rustfs_ecstore::api::storage::{
BootstrapLocalTarget, ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk,
all_local_disk_path, find_local_disk_by_ref, init_local_disks_with_instance_ctx, init_lock_clients,
ECStore, SCANNER_PUBLICATION_LEASE_TTL_MS, ScannerDataMovementPauseStatus, all_local_disk, all_local_disk_path,
find_local_disk_by_ref, init_local_disks_with_instance_ctx, init_lock_clients,
prewarm_local_disk_id_map_with_instance_ctx,
};
}
@@ -681,9 +679,6 @@ type EcstoreReplicationStats = ecstore_bucket::replication::ReplicationStats;
pub(crate) type DynReplicationPool = StorageReplicationPoolHandle;
pub(crate) type DynReader = ecstore_rio::DynReader;
pub(crate) type ECStore = ecstore_storage::ECStore;
pub(crate) type BootstrapLocalTarget = ecstore_storage::BootstrapLocalTarget;
#[cfg(all(test, not(windows)))]
pub(crate) use rustfs_ecstore::api::disk::{LocalPublicationPause, LocalPublicationStage};
pub(crate) type Endpoint = ecstore_disk::endpoint::Endpoint;
#[cfg(test)]
pub(crate) type Endpoints = ecstore_layout::Endpoints;
+1 -7
View File
@@ -13,14 +13,8 @@
// limitations under the License.
pub(crate) use crate::storage::rpc::node_service::make_heal_control_server_with_cache;
pub(crate) use crate::storage::rpc::node_service::make_scanner_control_server;
#[cfg(test)]
pub(crate) use crate::storage::rpc::node_service::{heal::heal_topology_fingerprint, make_heal_control_server_for_source};
pub(crate) use crate::storage::rpc::node_service::{make_scanner_control_server, make_server_for_slot};
pub use crate::storage::rpc::{make_heal_control_server, make_server, make_tier_mutation_control_server};
pub type NodeService = crate::storage::rpc::NodeService;
#[cfg(feature = "e2e-test-hooks")]
#[doc(hidden)]
pub use crate::storage::rpc::node_service::rename_target_capture_test_hook::{
RenameTargetCapturePause, pause_rename_after_target_capture,
};
+1 -4
View File
@@ -177,15 +177,12 @@ pub(crate) mod server {
}
pub(crate) mod tonic_service {
#[cfg(test)]
pub(crate) use crate::storage::storage_api::tonic_service_consumer::make_server;
#[cfg(test)]
pub(crate) use crate::storage::storage_api::tonic_service_consumer::{
heal_topology_fingerprint, make_heal_control_server_for_source,
};
pub(crate) use crate::storage::storage_api::tonic_service_consumer::{
make_heal_control_server_with_cache, make_scanner_control_server, make_server_for_slot,
make_tier_mutation_control_server,
make_heal_control_server_with_cache, make_scanner_control_server, make_server, make_tier_mutation_control_server,
};
}
}
@@ -540,530 +540,3 @@ async fn second_embedded_server_fails_closed_until_its_context_slot_is_installed
server_b.shutdown().await;
server_a.shutdown().await;
}
#[cfg(feature = "e2e-test-hooks")]
mod signed_target_rpc {
use super::{common, find_available_port, pause_embedded_startup_after_http_bind, sha256_hex};
use bytes::Bytes;
use futures::FutureExt;
use hyper_util::rt::TokioIo;
use rustfs::app::context::resolve_object_store_handle;
use rustfs::embedded::RustFSServerBuilder;
use rustfs_ecstore::api::disk::{DiskAPI, DiskError, DiskOption, DiskStore, Endpoint, ReadOptions, new_disk};
use rustfs_ecstore::api::rpc::{gen_tonic_signature_headers, normalize_tonic_rpc_audience};
use rustfs_filemeta::{FileInfo, ObjectPartInfo};
use rustfs_protos::proto_gen::node_service::{RenameDataRequest, RenameDataResponse, node_service_client::NodeServiceClient};
use std::net::SocketAddr;
use std::path::Path;
use std::sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
};
use std::time::Duration;
use time::OffsetDateTime;
use tokio::net::TcpStream;
use tokio::time::timeout;
use tonic::transport::Channel;
use uuid::Uuid;
const WAIT: Duration = Duration::from_secs(30);
const INTERNAL_VOLUME: &str = ".rustfs.sys/tmp";
const USER_VOLUME: &str = "target-transport";
struct SingleConnection {
client: NodeServiceClient<Channel>,
local: SocketAddr,
peer: SocketAddr,
attempts: Arc<AtomicUsize>,
}
impl SingleConnection {
async fn connect(address: SocketAddr) -> Self {
let socket = timeout(WAIT, TcpStream::connect(address))
.await
.expect("bounded real TCP connection")
.expect("connect to the production listener");
let local = socket.local_addr().expect("client socket identity");
let peer = socket.peer_addr().expect("listener socket identity");
let socket = Arc::new(Mutex::new(Some(socket)));
let attempts = Arc::new(AtomicUsize::new(0));
let connector_attempts = attempts.clone();
let channel = timeout(
WAIT,
tonic::transport::Endpoint::from_shared(format!("http://{address}"))
.expect("local endpoint")
.timeout(WAIT)
.connect_with_connector(tower::service_fn(move |_: http::Uri| {
connector_attempts.fetch_add(1, Ordering::SeqCst);
// A channel may reconnect implicitly. This fixture has exactly one
// already-connected socket and fails every subsequent dial attempt.
let socket = socket.lock().expect("single socket lock").take();
async move {
socket.map(TokioIo::new).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "implicit reconnect forbidden")
})
}
})),
)
.await
.expect("bounded HTTP/2 handshake")
.expect("HTTP/2 over the original TCP connection");
Self {
client: NodeServiceClient::new(channel),
local,
peer,
attempts,
}
}
fn assert_original_connection(&self) {
assert_eq!(self.attempts.load(Ordering::SeqCst), 1, "the channel must not redial");
}
async fn rename(&mut self, request: tonic::Request<RenameDataRequest>) -> RenameDataResponse {
let response = timeout(WAIT, self.client.rename_data(request))
.await
.expect("bounded signed RenameData")
.expect("production authentication and RPC routing")
.into_inner();
self.assert_original_connection();
response
}
}
async fn local_fixture_disk(root: &Path) -> DiskStore {
let mut endpoint = Endpoint::try_from(root.to_str().expect("UTF-8 fixture root")).expect("local disk endpoint");
endpoint.set_pool_index(0);
endpoint.set_set_index(0);
endpoint.set_disk_index(0);
new_disk(&endpoint, &DiskOption::default())
.await
.expect("open real fixture disk")
}
async fn stage(disk: &DiskStore, volume: &str, path: &str, body: &'static [u8]) -> FileInfo {
match disk.make_volume(volume).await {
Ok(()) | Err(DiskError::VolumeExists) => {}
Err(err) => panic!("create fixture volume: {err}"),
}
let mut fi = FileInfo::new(path, 1, 0);
fi.erasure.index = 1;
fi.version_id = Some(Uuid::new_v4());
fi.mod_time = Some(OffsetDateTime::now_utc());
fi.size = i64::try_from(body.len()).expect("small fixture");
fi.parts = vec![ObjectPartInfo {
number: 1,
size: body.len(),
actual_size: fi.size,
..Default::default()
}];
fi.data = Some(Bytes::from_static(body));
fi.set_inline_data();
disk.write_metadata(volume, volume, path, fi.clone())
.await
.expect("stage real xl.meta");
assert_body(disk, volume, path, &fi).await;
fi
}
async fn assert_body(disk: &DiskStore, volume: &str, path: &str, fi: &FileInfo) {
let read = disk
.read_version(
volume,
volume,
path,
&fi.version_id.expect("version").to_string(),
&ReadOptions {
read_data: true,
..Default::default()
},
)
.await
.expect("decode actual inline object bytes");
assert_eq!(read.data, fi.data);
}
fn signed_rename(
disk: &DiskStore,
volume: &str,
source: &str,
destination: &str,
fi: &FileInfo,
) -> tonic::Request<RenameDataRequest> {
let payload = RenameDataRequest {
disk: disk.endpoint().to_string(),
src_volume: volume.to_owned(),
src_path: source.to_owned(),
dst_volume: volume.to_owned(),
dst_path: destination.to_owned(),
file_info: serde_json::to_string(fi).expect("real FileInfo JSON"),
..Default::default()
};
let canonical = rustfs_protos::canonical_rename_data_request_body(&payload).expect("canonical mutation body");
// The current production interceptor uses the process RPC identity. Keep
// that authentication contract while testing listener-local disk routing.
let identity = rustfs_common::try_get_global_local_node_name().expect("startup published the RPC identity");
let audience = normalize_tonic_rpc_audience(&identity).expect("RPC audience");
let headers =
gen_tonic_signature_headers(&audience, "node_service.NodeService", "RenameData", Some(&sha256_hex(&canonical)))
.expect("production v2 signing with the configured shared secret");
assert_eq!(headers.get("x-rustfs-rpc-auth-version").expect("v2 metadata"), "2");
let mut request = tonic::Request::new(payload);
*request.metadata_mut() = tonic::metadata::MetadataMap::from_headers(headers);
request
}
#[test]
fn signed_target_rpc_uses_listener_instance_across_install_and_reconnect() {
common::run_embedded_test(|| async {
timeout(WAIT * 6, signed_target_rpc_body())
.await
.expect("bounded listener/startup/transport fixture");
});
}
async fn signed_target_rpc_body() {
// B installs the process default first; A must remain a different target
// both before and after its own application context is installed.
let root_b = tempfile::tempdir().expect("B root");
let server_b = timeout(
WAIT,
RustFSServerBuilder::new()
.address(format!("127.0.0.1:{}", find_available_port().expect("B port")))
.volume(root_b.path().to_str().expect("B path"))
.access_key("target-transport-access")
.secret_key("target-transport-secret")
.build(),
)
.await
.expect("bounded B startup")
.expect("start global B");
let global_b = resolve_object_store_handle().expect("B installed process AppContext");
let disk_b = local_fixture_disk(root_b.path()).await;
let global_endpoints = global_b.instance_endpoints().expect("B instance topology");
let global_paths: Vec<_> = global_endpoints
.0
.iter()
.flat_map(|pool| pool.endpoints.as_ref().iter())
.map(ToString::to_string)
.collect();
assert_eq!(
global_paths,
vec![disk_b.endpoint().to_string()],
"the ambient store must really own B's disk"
);
let sentinel = stage(&disk_b, USER_VOLUME, "sentinel", b"global-B-must-survive").await;
let sentinel_path = root_b.path().join(USER_VOLUME).join("sentinel/xl.meta");
let sentinel_bytes = tokio::fs::read(&sentinel_path).await.expect("B's committed bytes");
let root_a = tempfile::tempdir().expect("A root");
let port_a = find_available_port().expect("A port");
let address_a: SocketAddr = format!("127.0.0.1:{port_a}").parse().expect("A address");
let mut barrier = pause_embedded_startup_after_http_bind(port_a);
let startup_a = RustFSServerBuilder::new()
.address(address_a.to_string())
.volume(root_a.path().to_str().expect("A path"))
.access_key("target-transport-access")
.secret_key("target-transport-secret")
.build();
tokio::pin!(startup_a);
timeout(WAIT, async {
tokio::select! {
() = barrier.wait_until_http_bound() => {}
result = startup_a.as_mut() => {
let _unexpected_server = result.expect("A startup before barrier");
panic!("A must pause after bind and before ECStore/AppContext");
}
}
})
.await
.expect("bounded A HTTP-bind barrier");
// Catch assertion failures only to release the real startup barrier and
// obtain a shutdown-capable server handle before resuming the failure.
let pre_ready = std::panic::AssertUnwindSafe(async {
assert!(Arc::ptr_eq(&global_b, &resolve_object_store_handle().expect("global B remains live")));
let disk_a = local_fixture_disk(root_a.path()).await;
let internal = stage(&disk_a, INTERNAL_VOLUME, "transport-staged", b"pre-ready-internal-body").await;
let user = stage(&disk_a, USER_VOLUME, "staged", b"listener-A-user-body").await;
let user_before = tokio::fs::read(root_a.path().join(USER_VOLUME).join("staged/xl.meta"))
.await
.expect("A staged user bytes");
let mut connection = SingleConnection::connect(address_a).await;
let mut invalid_signature = signed_rename(&disk_a, INTERNAL_VOLUME, "transport-staged", "bad-signature", &internal);
invalid_signature
.metadata_mut()
.insert("x-rustfs-rpc-signature-v2", "00".parse().expect("invalid MAC header"));
let status = timeout(WAIT, connection.client.rename_data(invalid_signature))
.await
.expect("bounded invalid-signature response")
.expect_err("production interceptor must reject a bad signature");
assert_eq!(status.code(), tonic::Code::Unauthenticated);
assert!(!root_a.path().join(INTERNAL_VOLUME).join("bad-signature/xl.meta").exists());
assert_body(&disk_a, INTERNAL_VOLUME, "transport-staged", &internal).await;
let committed = connection
.rename(signed_rename(
&disk_a,
INTERNAL_VOLUME,
"transport-staged",
"transport-published",
&internal,
))
.await;
assert!(
committed.success,
"Bootstrap must commit internal metadata through the bound A registry: {:?}",
committed.error
);
assert_body(&disk_a, INTERNAL_VOLUME, "transport-published", &internal).await;
let denied = connection
.rename(signed_rename(&disk_a, USER_VOLUME, "staged", "destination", &user))
.await;
assert!(!denied.success, "Bootstrap must reject a real user mutation");
let error: DiskError = denied.error.expect("typed bootstrap rejection").into();
assert_eq!(error, DiskError::FileAccessDenied);
assert_eq!(
tokio::fs::read(root_a.path().join(USER_VOLUME).join("staged/xl.meta"))
.await
.expect("unchanged A source"),
user_before
);
assert!(!root_a.path().join(USER_VOLUME).join("destination/xl.meta").exists());
assert_eq!(tokio::fs::read(&sentinel_path).await.expect("unchanged B bytes"), sentinel_bytes);
assert_body(&disk_b, USER_VOLUME, "sentinel", &sentinel).await;
connection.assert_original_connection();
(connection, disk_a, user)
})
.catch_unwind()
.await;
barrier.release();
let server_a = timeout(WAIT, startup_a.as_mut())
.await
.expect("bounded A context installation")
.expect("A startup after real internal metadata commit");
let (mut connection, disk_a, user) = match pre_ready {
Ok(fixture) => fixture,
Err(panic) => {
timeout(WAIT, server_a.shutdown()).await.expect("bounded A failure cleanup");
timeout(WAIT, server_b.shutdown()).await.expect("bounded B failure cleanup");
std::panic::resume_unwind(panic);
}
};
assert!(Arc::ptr_eq(
&global_b,
&resolve_object_store_handle().expect("A install preserves global B")
));
assert_eq!(connection.peer, server_a.address());
assert_body(&disk_a, USER_VOLUME, "staged", &user).await;
let committed = connection
.rename(signed_rename(&disk_a, USER_VOLUME, "staged", "destination", &user))
.await;
assert!(
committed.success,
"the same accepted connection must observe Ready for its next request: {:?}",
committed.error
);
assert_body(&disk_a, USER_VOLUME, "destination", &user).await;
// Keep the first connection open so the OS cannot recycle its 4-tuple.
let mut reconnected = SingleConnection::connect(address_a).await;
assert_ne!(reconnected.local, connection.local);
assert_eq!(reconnected.peer, connection.peer);
let committed = reconnected
.rename(signed_rename(&disk_a, USER_VOLUME, "destination", "reconnected", &user))
.await;
assert!(committed.success, "new connections must retain listener A: {:?}", committed.error);
assert_body(&disk_a, USER_VOLUME, "reconnected", &user).await;
assert_eq!(tokio::fs::read(&sentinel_path).await.expect("B remains unchanged"), sentinel_bytes);
assert_body(&disk_b, USER_VOLUME, "sentinel", &sentinel).await;
assert!(!root_b.path().join(USER_VOLUME).join("reconnected/xl.meta").exists());
connection.assert_original_connection();
reconnected.assert_original_connection();
drop(reconnected);
drop(connection);
drop(disk_a);
drop(disk_b);
timeout(WAIT, server_a.shutdown()).await.expect("bounded A shutdown");
timeout(WAIT, server_b.shutdown()).await.expect("bounded B shutdown");
}
#[test]
fn signed_bootstrap_request_does_not_upgrade_after_context_installation() {
common::run_embedded_test(|| async {
timeout(WAIT * 6, signed_delayed_bootstrap_body())
.await
.expect("bounded delayed Bootstrap fixture");
});
}
async fn signed_delayed_bootstrap_body() {
use rustfs::storage::tonic_service::pause_rename_after_target_capture;
let root_b = tempfile::tempdir().expect("B root");
let server_b = timeout(
WAIT,
RustFSServerBuilder::new()
.address(format!("127.0.0.1:{}", find_available_port().expect("B port")))
.volume(root_b.path().to_str().expect("B path"))
.access_key("delayed-bootstrap-access")
.secret_key("delayed-bootstrap-secret")
.build(),
)
.await
.expect("bounded B startup")
.expect("start global B");
let global_b = resolve_object_store_handle().expect("B's published context");
let disk_b = local_fixture_disk(root_b.path()).await;
let endpoints = global_b.instance_endpoints().expect("B instance topology");
let paths: Vec<_> = endpoints
.0
.iter()
.flat_map(|pool| pool.endpoints.as_ref().iter())
.map(ToString::to_string)
.collect();
assert_eq!(paths, [disk_b.endpoint().to_string()], "the ambient store owns B");
stage(&disk_b, USER_VOLUME, "delayed-sentinel", b"B-is-not-the-listener-target").await;
let sentinel_path = root_b.path().join(USER_VOLUME).join("delayed-sentinel/xl.meta");
let sentinel_before = tokio::fs::read(&sentinel_path).await.expect("B sentinel bytes");
let root_a = tempfile::tempdir().expect("A root");
let port_a = find_available_port().expect("A port");
let address_a = format!("127.0.0.1:{port_a}").parse().expect("A address");
let mut startup_barrier = Some(pause_embedded_startup_after_http_bind(port_a));
let startup_a = RustFSServerBuilder::new()
.address(format!("127.0.0.1:{port_a}"))
.volume(root_a.path().to_str().expect("A path"))
.access_key("delayed-bootstrap-access")
.secret_key("delayed-bootstrap-secret")
.build();
tokio::pin!(startup_a);
timeout(WAIT, async {
tokio::select! {
() = startup_barrier.as_mut().expect("startup barrier").wait_until_http_bound() => {}
startup = startup_a.as_mut() => {
let _unexpected_server = startup.expect("A initial startup");
panic!("A must reach its pre-AppContext barrier");
}
}
})
.await
.expect("bounded A listener startup");
let disk_a = local_fixture_disk(root_a.path()).await;
let delayed_info = stage(&disk_a, USER_VOLUME, "delayed-source", b"captured-Bootstrap-must-not-publish").await;
let control_info = stage(&disk_a, USER_VOLUME, "control-source", b"new-Ready-request-can-publish").await;
let source_path = root_a.path().join(USER_VOLUME).join("delayed-source/xl.meta");
let destination_path = root_a.path().join(USER_VOLUME).join("delayed-destination/xl.meta");
let source_before = tokio::fs::read(&source_path).await.expect("delayed source bytes");
let mut connection = SingleConnection::connect(address_a).await;
let mut server_a = None;
let mut startup_finished = false;
let (observations, delayed_result, source_after, destination_exists, sentinel_after) = {
let mut capture =
pause_rename_after_target_capture(&disk_a.endpoint().to_string(), USER_VOLUME, "delayed-destination");
let mut delayed_client = connection.client.clone();
let delayed = delayed_client.rename_data(signed_rename(
&disk_a,
USER_VOLUME,
"delayed-source",
"delayed-destination",
&delayed_info,
));
tokio::pin!(delayed);
let mut early_response = None;
// Bound all work while the request is parked to less than the
// existing channel's 30-second deadline; no timeout is disabled.
let observations = std::panic::AssertUnwindSafe(timeout(Duration::from_secs(20), async {
let was_bootstrap = tokio::select! {
observed = capture.wait_until_captured() => observed,
response = delayed.as_mut() => {
early_response = Some(response);
panic!("signed request finished before the capture pause: {early_response:?}");
},
};
assert!(was_bootstrap, "the actual authenticated handler captured Bootstrap");
assert!(Arc::ptr_eq(&global_b, &resolve_object_store_handle().expect("global B")));
assert_eq!(tokio::fs::read(&source_path).await.expect("source before install"), source_before);
assert!(!destination_path.exists());
startup_barrier.take().expect("unreleased startup barrier").release();
let started = startup_a.as_mut().await;
startup_finished = true;
server_a = Some(started.expect("normal A context installation"));
assert!(Arc::ptr_eq(&global_b, &resolve_object_store_handle().expect("global remains B")));
assert_eq!(connection.peer, server_a.as_ref().expect("A handle").address());
// A separate source prevents this control from consuming the
// delayed request's data and masking an erroneous second lookup.
let ready = connection
.rename(signed_rename(&disk_a, USER_VOLUME, "control-source", "ready-control", &control_info))
.await;
assert!(ready.success, "a fresh signed user request must actually use Ready: {:?}", ready.error);
assert_body(&disk_a, USER_VOLUME, "ready-control", &control_info).await;
assert_body(&disk_a, USER_VOLUME, "delayed-source", &delayed_info).await;
assert!(!destination_path.exists(), "the original request remains parked");
connection.assert_original_connection();
}))
.catch_unwind()
.await;
// Release on every assertion/timeout path, then drain the original
// RPC before shutting down the server and its connection.
drop(capture);
if let Some(barrier) = startup_barrier.take() {
barrier.release();
}
if !startup_finished {
let started = timeout(WAIT, startup_a.as_mut()).await;
if let Ok(Ok(started)) = started {
server_a = Some(started);
}
}
let delayed_result = match early_response {
Some(response) => Ok(response),
None => timeout(WAIT, delayed.as_mut()).await,
};
let source_after = tokio::fs::read(&source_path).await;
let destination_exists = tokio::fs::try_exists(&destination_path).await;
let sentinel_after = tokio::fs::read(&sentinel_path).await;
(observations, delayed_result, source_after, destination_exists, sentinel_after)
};
let connection_attempts = connection.attempts.load(Ordering::SeqCst);
drop(connection);
let shutdown_a = if let Some(server) = server_a {
Some(timeout(WAIT, server.shutdown()).await)
} else {
None
};
let shutdown_b = timeout(WAIT, server_b.shutdown()).await;
if let Some(result) = shutdown_a {
result.expect("bounded A shutdown");
}
shutdown_b.expect("bounded B shutdown");
assert_eq!(connection_attempts, 1, "the original channel must not redial");
match observations {
Err(panic) => std::panic::resume_unwind(panic),
Ok(result) => result.expect("complete capture/install/Ready-control within the parked request deadline"),
}
let response = delayed_result
.expect("bounded original request drain")
.expect("the original signed request must return an application result")
.into_inner();
assert!(
!response.success,
"a captured Bootstrap request must not upgrade to Ready after its await"
);
let error: DiskError = response.error.expect("typed Bootstrap rejection").into();
assert_eq!(error, DiskError::FileAccessDenied);
assert_eq!(source_after.expect("original source remains readable"), source_before);
assert!(!destination_exists.expect("read original destination state"));
assert_eq!(sentinel_after.expect("global B sentinel survives"), sentinel_before);
}
}
+99 -2
View File
@@ -1094,6 +1094,38 @@ def check_scanner_heal_evidence(root: Path, directory: Path, case_id: str) -> li
return [f"scanner/heal evidence rejected: {error}"]
def scanner_heal_release_status(root: Path, directory: Path) -> dict[str, object]:
"""Return a compact release decision without weakening case validation."""
registry = read_json(root / ".config/scanner-heal-required-tests.json")
evidence_integer(registry.get("schema"), "registry schema", 1, 1)
cases = registry.get("cases")
require(isinstance(cases, dict) and cases, "invalid scanner/heal registry")
pending = registry.get("release_pending")
require(isinstance(pending, dict), "invalid scanner/heal release requirements")
for gate, reason in pending.items():
require(isinstance(gate, str) and re.fullmatch(r"[A-Z][A-Z0-9-]*", gate) is not None,
"invalid scanner/heal release gate")
require(isinstance(reason, str) and reason.strip(), f"missing release requirement for {gate}")
verified_cases = []
rejected_cases = []
for case_id in sorted(cases):
if check_scanner_heal_evidence(root, directory, case_id):
rejected_cases.append(case_id)
else:
verified_cases.append(case_id)
return {
"schema": 1,
"decision": "blocked",
"release_approved": False,
"release_schema_capable": False,
"verified_cases": verified_cases,
"rejected_cases": rejected_cases,
"pending_gates": sorted(pending),
}
def validate(root: Path) -> list[str]:
errors: list[str] = []
errors.extend(check_core_fixtures(root))
@@ -1311,6 +1343,61 @@ class SelfTests(unittest.TestCase):
self.assertTrue(any(error.startswith("pending R-D:") for error in errors))
self.assertTrue(any(error.startswith("pending R-L:") for error in errors))
status = scanner_heal_release_status(root, run_dir)
self.assertEqual(status["decision"], "blocked")
self.assertFalse(status["release_approved"])
self.assertEqual(status["rejected_cases"], [])
self.assertEqual(len(status["pending_gates"]), 21)
def test_scanner_heal_case_only_schema_cannot_approve_release(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root, run_dir = self.scanner_heal_fixture(Path(tmp))
registry = read_json(root / ".config/scanner-heal-required-tests.json")
registry["release_pending"] = {}
write_json(root / ".config/scanner-heal-required-tests.json", registry)
status = scanner_heal_release_status(root, run_dir)
self.assertEqual(status["decision"], "blocked")
self.assertFalse(status["release_approved"])
self.assertFalse(status["release_schema_capable"])
self.assertEqual(status["rejected_cases"], [])
self.assertEqual(status["pending_gates"], [])
def test_scanner_heal_release_status_rejects_synthetic_case(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root, run_dir = self.scanner_heal_fixture(Path(tmp))
registry = read_json(root / ".config/scanner-heal-required-tests.json")
registry["release_pending"] = {}
write_json(root / ".config/scanner-heal-required-tests.json", registry)
path = run_dir / "background-target-crash.json"
oracle = read_json(path)
oracle["evidence"] = "synthetic"
write_json(path, oracle)
(run_dir / "execution.json").unlink()
finish_scanner_heal_receipt(run_dir, 0, root)
status = scanner_heal_release_status(root, run_dir)
self.assertEqual(status["decision"], "blocked")
self.assertFalse(status["release_approved"])
self.assertEqual(status["rejected_cases"], ["background-target-crash"])
self.assertEqual(status["pending_gates"], [])
def test_scanner_heal_release_status_rejects_focused_case_run(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root, run_dir = self.scanner_heal_fixture(Path(tmp))
registry = read_json(root / ".config/scanner-heal-required-tests.json")
registry["release_pending"] = {}
write_json(root / ".config/scanner-heal-required-tests.json", registry)
(run_dir / "background-target-crash.json").unlink()
(run_dir / "execution.json").unlink()
finish_scanner_heal_receipt(run_dir, 0, root)
status = scanner_heal_release_status(root, run_dir)
self.assertEqual(status["decision"], "blocked")
self.assertFalse(status["release_approved"])
self.assertEqual(status["verified_cases"], ["background-target-restart"])
self.assertEqual(status["rejected_cases"], ["background-target-crash"])
def test_scanner_heal_finish_collects_oracles_from_registry(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
root, run_dir = self.scanner_heal_fixture(Path(tmp))
@@ -2158,7 +2245,8 @@ def main() -> int:
if sys.argv[1:] == ["--self-test"]:
suite = unittest.defaultTestLoader.loadTestsFromTestCase(SelfTests)
return 0 if unittest.TextTestRunner(verbosity=2).run(suite).wasSuccessful() else 1
if sys.argv[1:2] in (["--begin-scanner-heal"], ["--finish-scanner-heal"], ["--check-scanner-heal"]):
if sys.argv[1:2] in (["--begin-scanner-heal"], ["--finish-scanner-heal"], ["--check-scanner-heal"],
["--check-scanner-heal-release"]):
try:
if len(sys.argv) == 5 and sys.argv[1] == "--begin-scanner-heal":
begin_scanner_heal_receipt(ROOT, Path(sys.argv[2]), Path(sys.argv[3]), Path(sys.argv[4]))
@@ -2173,7 +2261,16 @@ def main() -> int:
if not errors:
print(f"Case evidence verified: {sys.argv[3]}; this does not approve release")
return 1 if errors else 0
raise ValueError("expected --begin-scanner-heal DIR BINARY TEST_BINARY, --finish-scanner-heal DIR EXIT, or --check-scanner-heal DIR CASE|release")
if len(sys.argv) == 3 and sys.argv[1] == "--check-scanner-heal-release":
try:
status = scanner_heal_release_status(ROOT, Path(sys.argv[2]))
except (OSError, KeyError, TypeError, ValueError, ET.ParseError) as error:
print(json.dumps({"schema": 1, "decision": "invalid", "release_approved": False,
"error": str(error)}, sort_keys=True, separators=(",", ":")))
return 2
print(json.dumps(status, sort_keys=True, separators=(",", ":")))
return 0 if status["release_approved"] else 1
raise ValueError("expected --begin-scanner-heal DIR BINARY TEST_BINARY, --finish-scanner-heal DIR EXIT, --check-scanner-heal DIR CASE|release, or --check-scanner-heal-release DIR")
except (OSError, KeyError, TypeError, ValueError, subprocess.SubprocessError) as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
@@ -47,10 +47,24 @@ def validate_report(report, *, round_number, pid, objects, budget):
continue
if type(value) is not str or not 0 < len(value.encode("utf-8")) <= 512:
raise ValueError(f"invalid raw entry marker: {key}")
if "raw_page_index_parent" not in report:
raise ValueError("missing raw page index parent")
raw_page_index_parent = report.get("raw_page_index_parent")
if raw_page_index_parent is not None and (type(raw_page_index_parent) is not str
or not 0 < len(raw_page_index_parent.encode("utf-8")) <= 512):
raise ValueError("invalid raw page index parent")
if type(report.get("raw_page_index_complete")) is not bool:
raise ValueError("missing raw page index completeness")
if type(report.get("snapshot_complete")) is not bool:
raise ValueError("missing explicit completeness")
if report.get("outcome") not in ("complete", "partial", "cancelled_without_cache"):
raise ValueError("unexpected scanner outcome")
if report["raw_page_index_committed_entries"] > report["raw_page_index_indexed_entries"]:
raise ValueError("raw page index committed entries exceed indexed entries")
if report["raw_page_index_parent"] == "bucket" and report["raw_page_index_indexed_entries"] > objects:
raise ValueError("raw page index exceeds fixture object count")
if report["objects_retained"] > report["objects_before"] + report["objects_processed"]:
raise ValueError("retained coverage advanced beyond classified object work")
def converged(report, objects):
@@ -66,6 +80,38 @@ def replays_raw_window(previous, current):
and current["objects_retained"] == previous["objects_retained"])
def validate_recoverable_quantum(reports, *, objects, budget, require_converged):
if not reports:
raise ValueError("no scanner restart reports were produced")
previous = None
made_enumeration_progress = False
made_classification_progress = False
made_durable_progress = False
for index, report in enumerate(reports):
validate_report(report, round_number=index, pid=report["pid"], objects=objects, budget=budget)
if previous is not None:
if report["objects_before"] != previous["objects_retained"]:
raise ValueError("durable retained coverage did not survive process restart")
if report["objects_retained"] < previous["objects_retained"]:
raise ValueError("durable retained coverage regressed across restart")
if (report["raw_page_index_parent"] == previous["raw_page_index_parent"]
and report["raw_page_index_committed_entries"] < previous["raw_page_index_committed_entries"]
and not previous["raw_page_index_complete"]):
raise ValueError("committed raw enumeration page coverage regressed before completion")
made_enumeration_progress |= report["raw_entries"] > 0 or report["raw_page_index_indexed_entries"] > 0
made_classification_progress |= report["objects_processed"] > 0
made_durable_progress |= report["objects_retained"] > report["objects_before"]
previous = report
if not made_enumeration_progress:
raise ValueError("restart proof did not exercise raw enumeration")
if not made_classification_progress:
raise ValueError("restart proof did not exercise object classification")
if not made_durable_progress:
raise ValueError("restart proof did not persist processed object coverage")
if require_converged and not converged(reports[-1], objects):
raise ValueError("fixed-budget restart convergence was not established")
def run(args):
binary = args.test_binary.resolve(strict=True)
listed = subprocess.run([str(binary), WORKER, "--exact", "--list"],
@@ -103,15 +149,15 @@ def run(args):
report = json.loads(raw)
validate_report(report, round_number=round_number, pid=worker.pid,
objects=args.objects, budget=args.raw_entry_budget)
if reports and report["objects_before"] != reports[-1]["objects_retained"]:
raise ValueError("cache coverage did not survive the process boundary")
if reports and replays_raw_window(reports[-1], report):
replayed_raw_window = True
reports.append(report)
print(json.dumps(report, sort_keys=True), flush=True)
if converged(report, args.objects):
print("PASS: bounded scanner-worker restart convergence for this fixture only")
validate_recoverable_quantum(reports, objects=args.objects, budget=args.raw_entry_budget, require_converged=True)
print("PASS: bounded scanner-worker restart convergence with enumeration/classification/processing evidence")
return 0
validate_recoverable_quantum(reports, objects=args.objects, budget=args.raw_entry_budget, require_converged=False)
reason = "replayed raw enumeration window" if replayed_raw_window else "no bounded restart convergence"
print(f"FAIL: fixed-budget restart convergence not established ({reason}); R-E gate remains unmet",
file=sys.stderr)
+14 -7
View File
@@ -90,15 +90,22 @@ PY
release_gate_must_remain_blocked() {
local run_dir="$1"
local output="$run_dir/release-check.txt"
if "$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal "$run_dir" release >"$output" 2>&1; then
local output="$run_dir/release-status.json"
if "$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal-release "$run_dir" >"$output"; then
echo "release gate unexpectedly approved a single Scanner/Heal evidence run" >&2
return 1
fi
if ! grep -Eq 'required test not selected:|pending [A-Z0-9-]+:' "$output"; then
echo "release gate did not explain why the Scanner/Heal release remains blocked" >&2
return 1
fi
"$PYTHON_BIN" - "$output" <<'PY'
import json
import pathlib
import sys
status = json.loads(pathlib.Path(sys.argv[1]).read_text())
if status.get("decision") != "blocked" or status.get("release_approved") is not False:
raise SystemExit("release status did not record a blocked decision")
if status.get("release_schema_capable") is not False:
raise SystemExit("case-only evidence schema unexpectedly became release-capable")
PY
}
run_self_test() {
@@ -242,5 +249,5 @@ fi
"$PYTHON_BIN" "$ROOT/scripts/check_test_wiring.py" --check-scanner-heal "$RUN_DIR" "$CASE_ID"
release_gate_must_remain_blocked "$RUN_DIR"
echo "Scanner/Heal evidence case verified: $CASE_ID"
echo "Release gate remains blocked; details: $RUN_DIR/release-check.txt"
echo "Release gate remains blocked; status: $RUN_DIR/release-status.json"
echo "Evidence directory: $RUN_DIR"
@@ -2,7 +2,12 @@
import unittest
from diagnose_scanner_enumeration_restart import converged, replays_raw_window, validate_report
from diagnose_scanner_enumeration_restart import (
converged,
replays_raw_window,
validate_recoverable_quantum,
validate_report,
)
class ReportTests(unittest.TestCase):
@@ -10,6 +15,7 @@ class ReportTests(unittest.TestCase):
return dict(schema=1, round=0, pid=123, objects_expected=4, raw_entry_budget=16,
raw_entries=8, raw_name_bytes=64, objects_before=0, objects_retained=4,
versions_retained=4, bytes_retained=4, objects_processed=4,
raw_page_index_parent="bucket", raw_page_index_complete=True,
raw_page_index_committed_entries=4,
raw_page_index_indexed_entries=4,
raw_first_entry="bucket/object-0000",
@@ -46,6 +52,26 @@ class ReportTests(unittest.TestCase):
with self.assertRaises(ValueError):
self.validate(report)
def test_raw_page_index_ordering_and_bounds_are_checked(self):
report = self.report()
report["raw_page_index_committed_entries"] = 5
report["raw_page_index_indexed_entries"] = 4
with self.assertRaisesRegex(ValueError, "committed entries exceed indexed entries"):
self.validate(report)
report = self.report()
report["raw_page_index_indexed_entries"] = 5
with self.assertRaisesRegex(ValueError, "exceeds fixture object count"):
self.validate(report)
def test_retained_coverage_cannot_advance_without_classified_work(self):
report = self.report()
report["objects_before"] = 1
report["objects_processed"] = 1
report["objects_retained"] = 3
with self.assertRaisesRegex(ValueError, "advanced beyond classified object work"):
self.validate(report)
report = self.report()
report["objects_processed"] = 17
with self.assertRaises(ValueError):
@@ -73,6 +99,11 @@ class ReportTests(unittest.TestCase):
report["raw_first_entry"] = None
report["raw_last_entry"] = None
report["objects_processed"] = 1
report["objects_retained"] = 1
report["versions_retained"] = 1
report["bytes_retained"] = 1
report["snapshot_complete"] = False
report["outcome"] = "partial"
self.validate(report)
def test_missing_wrong_type_and_negative_counter_rejected(self):
@@ -84,7 +115,7 @@ class ReportTests(unittest.TestCase):
self.validate(report)
def test_missing_completeness_or_unknown_outcome_rejected(self):
for key in ("snapshot_complete", "outcome"):
for key in ("raw_page_index_parent", "raw_page_index_complete", "snapshot_complete", "outcome"):
report = self.report()
del report[key]
with self.assertRaises(ValueError):
@@ -109,6 +140,59 @@ class ReportTests(unittest.TestCase):
advanced = dict(current, objects_retained=1)
self.assertFalse(replays_raw_window(previous, advanced))
def test_recoverable_quantum_requires_three_stage_progress_and_convergence(self):
first = self.report()
first.update(round=0, pid=123, raw_entries=2, raw_page_index_committed_entries=2,
raw_page_index_indexed_entries=2, objects_processed=2, objects_before=0,
objects_retained=2, versions_retained=2, bytes_retained=2,
snapshot_complete=False, outcome="partial")
second = self.report()
second.update(round=1, pid=124, raw_entries=2, raw_page_index_committed_entries=4,
raw_page_index_indexed_entries=4, objects_processed=2, objects_before=2,
objects_retained=4, snapshot_complete=True, outcome="complete")
validate_recoverable_quantum([first, second], objects=4, budget=16, require_converged=True)
def test_recoverable_quantum_rejects_restart_regression(self):
first = self.report()
first.update(snapshot_complete=False, outcome="partial", objects_retained=2,
versions_retained=2, bytes_retained=2)
second = self.report()
second.update(round=1, pid=124, objects_before=1, objects_retained=1,
versions_retained=1, bytes_retained=1, snapshot_complete=False,
outcome="partial")
with self.assertRaisesRegex(ValueError, "did not survive process restart"):
validate_recoverable_quantum([first, second], objects=4, budget=16, require_converged=False)
def test_recoverable_quantum_allows_new_raw_page_parent_after_processing(self):
first = self.report()
first.update(snapshot_complete=False, outcome="partial", objects_before=0,
objects_processed=0, objects_retained=0, versions_retained=0,
bytes_retained=0, raw_page_index_parent="bucket",
raw_page_index_complete=True, raw_page_index_committed_entries=4,
raw_page_index_indexed_entries=4)
second = self.report()
second.update(round=1, pid=124, raw_entries=0, raw_first_entry=None,
raw_last_entry=None, raw_name_bytes=0, objects_before=0,
objects_processed=2, objects_retained=2,
versions_retained=2, bytes_retained=2,
snapshot_complete=False, outcome="partial",
raw_page_index_parent="bucket/object-0000",
raw_page_index_complete=False,
raw_page_index_committed_entries=1,
raw_page_index_indexed_entries=1)
validate_recoverable_quantum([first, second], objects=4, budget=16, require_converged=False)
def test_recoverable_quantum_rejects_missing_processing_stage(self):
report = self.report()
report["objects_processed"] = 0
report["objects_retained"] = 0
report["versions_retained"] = 0
report["bytes_retained"] = 0
report["snapshot_complete"] = False
report["outcome"] = "partial"
with self.assertRaisesRegex(ValueError, "object classification"):
validate_recoverable_quantum([report], objects=4, budget=16, require_converged=False)
if __name__ == "__main__":
unittest.main()