mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-31 10:32:24 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 830055ba9c | |||
| a8c2e3e253 |
@@ -10,16 +10,10 @@ never weaken a check to get green.
|
|||||||
|
|
||||||
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
|
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
|
||||||
|
|
||||||
Enforces `composition (server, startup/init) → interface (admin,
|
Enforces `interface (admin, storage/ecfs, storage/s3_api) → app → infra`; no
|
||||||
storage/ecfs, storage/s3_api) → app → infra`; no upward imports. Server source
|
upward imports. Known legacy violations live in
|
||||||
files are composition roots, while imports of their exported HTTP contracts
|
|
||||||
are classified as interface dependencies. Known legacy violations live in
|
|
||||||
`scripts/layer-dependency-baseline.txt`.
|
`scripts/layer-dependency-baseline.txt`.
|
||||||
|
|
||||||
Dedicated `*_test.rs` and `tests/` modules are outside this production guard.
|
|
||||||
Inline `#[cfg(test)]` imports remain checked under their source file's layer;
|
|
||||||
move architecture-crossing test scaffolding into a dedicated test module.
|
|
||||||
|
|
||||||
- **New violation**: restructure your change so the dependency points
|
- **New violation**: restructure your change so the dependency points
|
||||||
downward (move the shared type/function to the lower layer).
|
downward (move the shared type/function to the lower layer).
|
||||||
- **You legitimately removed a baseline entry**: run
|
- **You legitimately removed a baseline entry**: run
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
---
|
---
|
||||||
name: rustfs-release-publish
|
name: rustfs-release-publish
|
||||||
description: "End-to-end RustFS release pipeline: first publish any merged-but-unreleased rustfs/console changes and wait for its latest Release asset, then bump RustFS version files on main directly to the final target, publish a visible GitHub prerelease from a preview tag without updating latest channels, validate it, and publish the final tag on the SAME commit. Use whenever the user wants to release/publish a RustFS version (发版/发布)."
|
description: "End-to-end RustFS release pipeline: bump version files on main directly to the final target version, cut a preview tag on that commit, verify the CI build and release artifacts, run the downloaded binary locally and exercise the console, validate the server with the latest rc client, then publish the final tag on the SAME validated commit — never a new bump commit, never latest main. Use whenever the user wants to release/publish a RustFS version (发版/发布)."
|
||||||
---
|
---
|
||||||
# RustFS Release Publish (preview-validated pipeline)
|
# RustFS Release Publish (preview-validated pipeline)
|
||||||
|
|
||||||
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
|
This skill orchestrates a full release. It wraps `rustfs-release-version-bump` (which only edits version files and opens the PR) with a mandatory preview-tag validation loop before the final tag is published.
|
||||||
|
|
||||||
Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. A preview tag creates a visible GitHub Release marked Prerelease and uploads versioned assets, but it never becomes GitHub Latest and never updates `*-latest`, `latest.json`, R2, Docker, or Helm channels. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and preview classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships.
|
Core design: **version files never carry a `-preview.N` suffix**. The preview suffix exists only in tag names. This works because the binary self-reports the git tag it was built from (`build::TAG` via shadow_rs, see `rustfs/src/config/cli.rs` `SHORT_VERSION`), and `build.yml` derives artifact names and prerelease classification from the tag name — Cargo.toml's version is only a no-tag fallback. Therefore the preview tag and the final tag can (and MUST) point at the exact same commit: what you validated is byte-for-byte the source that ships.
|
||||||
|
|
||||||
Pipeline shape:
|
Pipeline shape:
|
||||||
|
|
||||||
```
|
```
|
||||||
check console main against its latest Release
|
bump version files to <target> (final version, ONE commit) -> merge
|
||||||
-> if ahead: publish console -> wait for Release asset + latest API
|
|
||||||
-> bump RustFS version files to <target> (final version, ONE commit) -> merge
|
|
||||||
-> tag <preview-tag> at that commit -> CI green
|
-> tag <preview-tag> at that commit -> CI green
|
||||||
-> verify preview Release assets -> run binary locally + console checks
|
-> verify release artifacts -> run binary locally + console checks
|
||||||
-> validate with latest rc client
|
-> validate with latest rc client
|
||||||
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
|
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
|
||||||
```
|
```
|
||||||
@@ -25,7 +23,7 @@ On validation failure: fix lands on main via normal PR (version files are alread
|
|||||||
## Required inputs
|
## Required inputs
|
||||||
|
|
||||||
- Final target version, for example `1.0.0-beta.10`.
|
- Final target version, for example `1.0.0-beta.10`.
|
||||||
- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '<target>-preview.*'` after `git fetch --tags`).
|
- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '<target>-preview.*'` — and for stable targets `git tag -l '<target>-rc.*'` — after `git fetch --tags`).
|
||||||
|
|
||||||
If the target version is missing or ambiguous, stop and ask before doing anything (see the semver gate below).
|
If the target version is missing or ambiguous, stop and ask before doing anything (see the semver gate below).
|
||||||
|
|
||||||
@@ -47,18 +45,14 @@ Rules:
|
|||||||
|
|
||||||
## Preview tag naming
|
## Preview tag naming
|
||||||
|
|
||||||
- Use `<target>-preview.N` for every target, e.g. `1.0.0-beta.10-preview.3` or `1.1.0-preview.1`.
|
- Prerelease target (contains `alpha`/`beta`/`rc`): preview tag is `<target>-preview.N`, e.g. `1.0.0-beta.10-preview.3`. It contains `beta`, so `build.yml`'s substring-based classification marks it prerelease — safe.
|
||||||
- The canonical suffix is exactly `-preview.<digits>`. `build.yml` recognizes it before alpha/beta/rc classification and routes it to the preview-only path; any other tag containing `-preview` fails closed instead of being treated as a release.
|
- **Stable** target (e.g. `1.1.0`): NEVER tag `1.1.0-preview.N` — `build.yml` marks a tag prerelease only if its name contains `alpha`, `beta`, or `rc`, so `1.1.0-preview.N` would be treated as a stable release and overwrite `latest.json` as stable. Use `1.1.0-rc.N` as the preview tag instead.
|
||||||
- A preview Release MUST be published with `isPrerelease=true` and `isLatest=false`. Any `*-latest` preview asset or preview-triggered `latest.json`, R2, Docker, or Helm publication is a pipeline failure.
|
|
||||||
|
|
||||||
## Hard rules
|
## Hard rules
|
||||||
|
|
||||||
- Version files (Cargo.toml, Cargo.lock, README, flake.nix, Chart.yaml, rustfs.spec) are bumped ONCE, directly to `<target>`. Never write a `-preview.N` suffix into any version file. If `rustfs-release-version-bump` is ever asked for a `-preview` version, that is a pipeline bug — stop.
|
- Version files (Cargo.toml, Cargo.lock, README, flake.nix, Chart.yaml, rustfs.spec) are bumped ONCE, directly to `<target>`. Never write a `-preview.N` suffix into any version file. If `rustfs-release-version-bump` is ever asked for a `-preview` version, that is a pipeline bug — stop.
|
||||||
- Preview Release assets are versioned and intentionally visible on the Releases page. Do not label them Latest or use them to update any latest distribution channel.
|
|
||||||
- Tags have no `v` prefix. Always annotated: `git tag -a <tag> -m "Release <tag>"`.
|
- Tags have no `v` prefix. Always annotated: `git tag -a <tag> -m "Release <tag>"`.
|
||||||
- The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final.
|
- The final tag MUST point at exactly `PREVIEW_HASH` — the commit the validated preview tag points at. Never tag current `main` HEAD (commits merged after validation are unvalidated), and never create an extra version-bump commit between preview and final.
|
||||||
- When a previous deliverable exists, GitHub Release notes for the preview and final tags MUST use it as their shared comparison baseline: the most recently published non-preview Release before the target. Internal `-preview.N` Releases are explicitly excluded from that selection, even when they point at the same commit as the final tag. If no previous deliverable exists, omit `previous_tag_name` and record that GitHub's default baseline fallback was used.
|
|
||||||
- Generated Release notes carry a workflow-management marker so retries can repair them. Before manually curating a generated body, remove that marker; unmarked non-placeholder notes are preserved by later workflow runs.
|
|
||||||
- Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash.
|
- Phases run in order; a failure in any phase blocks everything after it. After the fix lands on main, restart from Phase 2 with the next preview iteration against the new `origin/main` hash — do not resume mid-pipeline against a stale hash.
|
||||||
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
|
- If the release is abandoned after Phase 1 merged, main's version files claim a version that was never tagged. Either revert the bump PR or leave it to be overwritten by the next release — but tell the user explicitly and record the decision.
|
||||||
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
|
- User-facing status updates in Chinese; commits, PR titles/bodies, and tag messages in English. No hard-wrapping in commit messages, PR bodies, or documentation prose — one logical line per sentence/paragraph, let soft wrap handle display.
|
||||||
@@ -69,61 +63,6 @@ Rules:
|
|||||||
- `gh auth status` works; confirm you can view `gh release list -L 3`.
|
- `gh auth status` works; confirm you can view `gh release list -L 3`.
|
||||||
- Confirm the exact final target version with the user if not explicit.
|
- Confirm the exact final target version with the user if not explicit.
|
||||||
|
|
||||||
### Console release gate
|
|
||||||
|
|
||||||
Complete this gate before changing any RustFS version file or creating any RustFS tag. RustFS `build.yml` downloads the asset returned by `repos/rustfs/console/releases/latest`, so a successful Console build alone is insufficient.
|
|
||||||
|
|
||||||
1. Read the latest published Console tag and compare it with Console `main`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CONSOLE_REPO="rustfs/console"
|
|
||||||
CONSOLE_LATEST=$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)
|
|
||||||
gh api "repos/${CONSOLE_REPO}/compare/${CONSOLE_LATEST}...main" \
|
|
||||||
--jq '{status, ahead_by, behind_by, commits: [.commits[] | {sha, message: .commit.message}]}'
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ahead_by == 0`: no merged Console change is waiting for release. Still verify the current latest asset using step 4, then continue to Phase 1.
|
|
||||||
- `ahead_by > 0` and `behind_by == 0`: publish Console before continuing. Report the merged commits and select the next unused `vX.Y.Z` tag. Default to the next patch version when the changes are fixes or backward-compatible UI work; stop for confirmation if a minor/major bump is plausible.
|
|
||||||
- Any diverged history or `behind_by > 0`: stop and resolve the Console release baseline explicitly. Do not guess a range or publish RustFS.
|
|
||||||
|
|
||||||
2. Clone/fetch `rustfs/console` into a scratch directory and record its exact `main` commit. Before creating a tag, check for a `v*` tag or Release workflow already associated with that hash. If one is in progress, wait for it instead of creating another version:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
CONSOLE_SCRATCH=$(mktemp -d)
|
|
||||||
gh repo clone "$CONSOLE_REPO" "$CONSOLE_SCRATCH/console"
|
|
||||||
git -C "$CONSOLE_SCRATCH/console" fetch origin main --tags
|
|
||||||
CONSOLE_HASH=$(git -C "$CONSOLE_SCRATCH/console" rev-parse origin/main)
|
|
||||||
git -C "$CONSOLE_SCRATCH/console" tag --points-at "$CONSOLE_HASH" 'v*'
|
|
||||||
gh run list -R "$CONSOLE_REPO" --workflow release.yml --commit "$CONSOLE_HASH" --limit 5
|
|
||||||
```
|
|
||||||
|
|
||||||
If no release exists or is running for `CONSOLE_HASH`, create the selected annotated tag at that exact hash and push it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git -C "$CONSOLE_SCRATCH/console" tag -a "<console-tag>" -m "Release <console-tag>" "$CONSOLE_HASH"
|
|
||||||
git -C "$CONSOLE_SCRATCH/console" push origin "<console-tag>"
|
|
||||||
```
|
|
||||||
|
|
||||||
Console tags include the `v` prefix. Pushing the tag triggers `.github/workflows/release.yml` (`🚀 Release`). Remove `CONSOLE_SCRATCH` after the gate completes.
|
|
||||||
|
|
||||||
3. Find the exact tag run and wait for completion:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh run list -R "$CONSOLE_REPO" --workflow release.yml --branch "<console-tag>" --limit 1
|
|
||||||
gh run watch -R "$CONSOLE_REPO" "<console-run-id>" --exit-status
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Block until the published Release is non-draft, the latest endpoint returns the expected tag, and `rustfs-console-<console-tag>.zip` is uploaded, non-empty, and carries a `sha256:` digest:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gh release view -R "$CONSOLE_REPO" "<console-tag>" --json isDraft,isPrerelease,assets,url
|
|
||||||
test "$(gh api "repos/${CONSOLE_REPO}/releases/latest" --jq .tag_name)" = "<console-tag>"
|
|
||||||
test "$(gh api "repos/${CONSOLE_REPO}/releases/tags/<console-tag>" \
|
|
||||||
--jq '[.assets[] | select(.name == "rustfs-console-<console-tag>.zip" and .state == "uploaded" and .size > 0 and (.digest | startswith("sha256:")))] | length')" -eq 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Treat a missing/mismatched asset, digest, latest tag, or failed/cancelled workflow as BLOCKED. Do not start Phase 1 until the Console gate passes. Record `CONSOLE_TAG`, `CONSOLE_HASH`, Console run URL, and Release URL for the final report.
|
|
||||||
|
|
||||||
## Phase 1 — Version bump to the final target (once)
|
## Phase 1 — Version bump to the final target (once)
|
||||||
|
|
||||||
- If main's version files already read `<target>` (e.g. this is a restart after a failed preview), verify with `rg -n "<target>" Cargo.toml rustfs.spec helm/rustfs/Chart.yaml` and skip to Phase 2.
|
- If main's version files already read `<target>` (e.g. this is a restart after a failed preview), verify with `rg -n "<target>" Cargo.toml rustfs.spec helm/rustfs/Chart.yaml` and skip to Phase 2.
|
||||||
@@ -146,17 +85,16 @@ git push origin "<preview-tag>"
|
|||||||
|
|
||||||
Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`.
|
Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`.
|
||||||
|
|
||||||
The preview run builds versioned artifacts and publishes them in a GitHub prerelease. Its latest-channel, R2, Docker, and Helm jobs must be skipped. Those publication paths run only after the final tag is pushed.
|
|
||||||
|
|
||||||
On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first — it must contain the fix — and re-report it.
|
On a restart (N+1), refresh `PREVIEW_HASH=$(git rev-parse origin/main)` first — it must contain the fix — and re-report it.
|
||||||
|
|
||||||
## Phase 3 — CI and preview Release verification
|
## Phase 3 — CI and artifact verification
|
||||||
|
|
||||||
- Find and watch the tag build: `gh run list --workflow build.yml --branch "<preview-tag>" --limit 1` then `gh run watch <run-id>`. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64).
|
- Watch the tag build: `gh run list --workflow build.yml --limit 5` then `gh run watch <run-id>`. Every matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64) plus the release and latest.json jobs.
|
||||||
- Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped.
|
- Verify the GitHub release: `gh release view "<preview-tag>" --json isPrerelease,assets`
|
||||||
- Verify `gh release view "<preview-tag>" --json isPrerelease,assets,url`: `isPrerelease` must be `true`, and the Release must contain all 6 versioned platform zips, checksums, SBOM, and provenance with no `-latest` assets. Confirm `gh api repos/{owner}/{repo}/releases/latest --jq .tag_name` does not return `<preview-tag>`.
|
- `isPrerelease` must be `true`.
|
||||||
- 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.
|
- Assets must include all 6 platform zips in both versioned (`rustfs-<platform>-v<tag>.zip`) and `-latest` forms, plus `SHA256SUMS`, `SHA512SUMS`, `rustfs-<tag>.sbom.cdx.json`, `rustfs-<tag>.provenance.json`.
|
||||||
- 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.
|
- Verify the chained Docker run succeeded: `gh run list --workflow docker.yml --limit 3`.
|
||||||
|
- Checksum spot-check for the platform you will run locally: download the zip and `SHA256SUMS`, verify with `shasum -a 256 -c` (grep to one line).
|
||||||
|
|
||||||
## Phase 4 — Run the artifact locally, verify the console
|
## Phase 4 — Run the artifact locally, verify the console
|
||||||
|
|
||||||
@@ -219,15 +157,13 @@ git push origin "<target>"
|
|||||||
```
|
```
|
||||||
|
|
||||||
- CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports `<target>`.
|
- CI rebuilds from the same source; the only changed input is the tag name, so the binary now self-reports `<target>`.
|
||||||
- Verify the final tag's complete publication path: all matrix and release jobs green; `gh release view "<target>"` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to `<target>`. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target.
|
- Re-run the Phase 3 verification against the final tag: all matrix jobs green; `gh release view "<target>"` shows the full asset set; for a prerelease target `isPrerelease` is `true`, for a stable target it must be `false` and `latest.json` must be updated.
|
||||||
- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<target>` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`.
|
|
||||||
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
|
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
|
||||||
|
|
||||||
## Output contract
|
## Output contract
|
||||||
|
|
||||||
Always report:
|
Always report:
|
||||||
|
|
||||||
- Console gate result: previous/latest Console tags, whether merged changes required a release, `CONSOLE_HASH`, and Console run/Release URLs when a release was published.
|
|
||||||
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
|
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
|
||||||
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: preview and final Release URLs, preview `isPrerelease`/`isLatest` state, final latest-channel state, console check results, and the rc command matrix.
|
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: CI run URLs, release URLs, console check results, the rc command matrix.
|
||||||
- Any deviation from this pipeline and why the user approved it.
|
- Any deviation from this pipeline and why the user approved it.
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Validated baseline: release pattern used in PR `#2957`.
|
|||||||
|
|
||||||
If target version is missing or ambiguous, stop and ask before editing.
|
If target version is missing or ambiguous, stop and ask before editing.
|
||||||
|
|
||||||
Reject any target version containing `-preview`: preview identifiers are tag-only (see `rustfs-release-publish`) and must never be written into version files. If asked for one, stop and point to the release pipeline instead of editing.
|
Reject any target version containing a `-preview.` suffix: preview identifiers are tag-only (see `rustfs-release-publish`) and must never be written into version files. If asked for one, stop and point to the release pipeline instead of editing.
|
||||||
|
|
||||||
## Read before editing
|
## Read before editing
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ on:
|
|||||||
- 'deny.toml'
|
- 'deny.toml'
|
||||||
- '.github/actions/**'
|
- '.github/actions/**'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
- 'scripts/release/create_or_update_release.sh'
|
|
||||||
- 'scripts/security/check_preview_release_workflow.sh'
|
|
||||||
- 'scripts/security/check_workflow_pins.sh'
|
- 'scripts/security/check_workflow_pins.sh'
|
||||||
pull_request:
|
pull_request:
|
||||||
types: [ opened, synchronize, reopened, closed ]
|
types: [ opened, synchronize, reopened, closed ]
|
||||||
@@ -35,8 +33,6 @@ on:
|
|||||||
- 'deny.toml'
|
- 'deny.toml'
|
||||||
- '.github/actions/**'
|
- '.github/actions/**'
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
- 'scripts/release/create_or_update_release.sh'
|
|
||||||
- 'scripts/security/check_preview_release_workflow.sh'
|
|
||||||
- 'scripts/security/check_workflow_pins.sh'
|
- 'scripts/security/check_workflow_pins.sh'
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 3 * * 0' # Weekly on Sunday 03:00 UTC (staggered after the midnight ci/build crons)
|
- cron: '0 3 * * 0' # Weekly on Sunday 03:00 UTC (staggered after the midnight ci/build crons)
|
||||||
@@ -100,9 +96,6 @@ jobs:
|
|||||||
- name: Report unpinned GitHub Actions
|
- name: Report unpinned GitHub Actions
|
||||||
run: ./scripts/security/check_workflow_pins.sh --enforce
|
run: ./scripts/security/check_workflow_pins.sh --enforce
|
||||||
|
|
||||||
- name: Check preview release workflow policy
|
|
||||||
run: ./scripts/security/check_preview_release_workflow.sh
|
|
||||||
|
|
||||||
dependency-review:
|
dependency-review:
|
||||||
name: Dependency Review
|
name: Dependency Review
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+81
-50
@@ -107,21 +107,13 @@ jobs:
|
|||||||
|
|
||||||
# Determine build type based on trigger
|
# Determine build type based on trigger
|
||||||
if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then
|
if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then
|
||||||
# Tag push - preview, release, or prerelease
|
# Tag push - release or prerelease
|
||||||
should_build=true
|
should_build=true
|
||||||
tag_name="${GITHUB_REF#refs/tags/}"
|
tag_name="${GITHUB_REF#refs/tags/}"
|
||||||
version="${tag_name}"
|
version="${tag_name}"
|
||||||
|
|
||||||
# Preview tags publish a GitHub prerelease for validation, but
|
# Check if this is a prerelease
|
||||||
# must not update any latest channel.
|
if [[ "$tag_name" == *"alpha"* ]] || [[ "$tag_name" == *"beta"* ]] || [[ "$tag_name" == *"rc"* ]]; then
|
||||||
if [[ "$tag_name" =~ -preview\.[0-9]+$ ]]; then
|
|
||||||
build_type="preview"
|
|
||||||
is_prerelease=true
|
|
||||||
echo "🔍 Preview build detected: $tag_name"
|
|
||||||
elif [[ "$tag_name" == *"-preview"* ]]; then
|
|
||||||
echo "❌ Invalid preview tag: $tag_name (expected suffix: -preview.<number>)" >&2
|
|
||||||
exit 1
|
|
||||||
elif [[ "$tag_name" == *"alpha"* ]] || [[ "$tag_name" == *"beta"* ]] || [[ "$tag_name" == *"rc"* ]]; then
|
|
||||||
build_type="prerelease"
|
build_type="prerelease"
|
||||||
is_prerelease=true
|
is_prerelease=true
|
||||||
echo "🚀 Prerelease build detected: $tag_name"
|
echo "🚀 Prerelease build detected: $tag_name"
|
||||||
@@ -722,10 +714,6 @@ jobs:
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
case "$BUILD_TYPE" in
|
case "$BUILD_TYPE" in
|
||||||
"preview")
|
|
||||||
echo "🔍 Preview artifacts are published in a GitHub prerelease"
|
|
||||||
echo "⏭️ Preview releases do not update latest channels"
|
|
||||||
;;
|
|
||||||
"development")
|
"development")
|
||||||
echo "🛠️ Development build artifacts have been uploaded to OSS dev directory"
|
echo "🛠️ Development build artifacts have been uploaded to OSS dev directory"
|
||||||
echo "⚠️ This is a development build - not suitable for production use"
|
echo "⚠️ This is a development build - not suitable for production use"
|
||||||
@@ -744,9 +732,7 @@ jobs:
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🐳 Docker Images:"
|
echo "🐳 Docker Images:"
|
||||||
if [[ "$BUILD_TYPE" == "preview" ]]; then
|
if [[ "${{ github.event.inputs.build_docker }}" == "false" ]]; then
|
||||||
echo "⏭️ Preview tags do not publish Docker images"
|
|
||||||
elif [[ "${{ github.event.inputs.build_docker }}" == "false" ]]; then
|
|
||||||
echo "⏭️ Docker image build was skipped (binary only build)"
|
echo "⏭️ Docker image build was skipped (binary only build)"
|
||||||
elif [[ "$BUILD_STATUS" == "success" ]]; then
|
elif [[ "$BUILD_STATUS" == "success" ]]; then
|
||||||
echo "🔄 Docker images will be built and pushed automatically via workflow_run event"
|
echo "🔄 Docker images will be built and pushed automatically via workflow_run event"
|
||||||
@@ -754,11 +740,11 @@ jobs:
|
|||||||
echo "❌ Docker image build will be skipped due to build failure"
|
echo "❌ Docker image build will be skipped due to build failure"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create GitHub Release for every valid release tag, including previews
|
# Create GitHub Release (only for tag pushes)
|
||||||
create-release:
|
create-release:
|
||||||
name: Create GitHub Release
|
name: Create GitHub Release
|
||||||
needs: [ build-check, build-rustfs ]
|
needs: [ build-check, build-rustfs ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -781,12 +767,9 @@ jobs:
|
|||||||
VERSION="${{ needs.build-check.outputs.version }}"
|
VERSION="${{ needs.build-check.outputs.version }}"
|
||||||
IS_PRERELEASE="${{ needs.build-check.outputs.is_prerelease }}"
|
IS_PRERELEASE="${{ needs.build-check.outputs.is_prerelease }}"
|
||||||
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
||||||
TARGET_COMMITISH=$(git rev-parse --verify "refs/tags/${TAG}^{commit}")
|
|
||||||
|
|
||||||
# Determine release type for title
|
# Determine release type for title
|
||||||
if [[ "$BUILD_TYPE" == "preview" ]]; then
|
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||||
RELEASE_TYPE="preview"
|
|
||||||
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
|
||||||
if [[ "$TAG" == *"alpha"* ]]; then
|
if [[ "$TAG" == *"alpha"* ]]; then
|
||||||
RELEASE_TYPE="alpha"
|
RELEASE_TYPE="alpha"
|
||||||
elif [[ "$TAG" == *"beta"* ]]; then
|
elif [[ "$TAG" == *"beta"* ]]; then
|
||||||
@@ -800,24 +783,54 @@ jobs:
|
|||||||
RELEASE_TYPE="release"
|
RELEASE_TYPE="release"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create release title
|
# Check if release already exists
|
||||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||||
TITLE="RustFS $VERSION (${RELEASE_TYPE})"
|
echo "Release $TAG already exists"
|
||||||
|
RELEASE_ID=$(gh release view "$TAG" --json databaseId --jq '.databaseId')
|
||||||
|
RELEASE_URL=$(gh release view "$TAG" --json url --jq '.url')
|
||||||
else
|
else
|
||||||
TITLE="RustFS $VERSION"
|
# Get release notes from tag message
|
||||||
|
RELEASE_NOTES=$(git tag -l --format='%(contents)' "${TAG}")
|
||||||
|
if [[ -z "$RELEASE_NOTES" || "$RELEASE_NOTES" =~ ^[[:space:]]*$ ]]; then
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
RELEASE_NOTES="Pre-release ${VERSION} (${RELEASE_TYPE})"
|
||||||
|
else
|
||||||
|
RELEASE_NOTES="Release ${VERSION}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create release title
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
TITLE="RustFS $VERSION (${RELEASE_TYPE})"
|
||||||
|
else
|
||||||
|
TITLE="RustFS $VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create the release
|
||||||
|
PRERELEASE_FLAG=""
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
PRERELEASE_FLAG="--prerelease"
|
||||||
|
fi
|
||||||
|
|
||||||
|
gh release create "$TAG" \
|
||||||
|
--title "$TITLE" \
|
||||||
|
--notes "$RELEASE_NOTES" \
|
||||||
|
$PRERELEASE_FLAG \
|
||||||
|
--draft
|
||||||
|
|
||||||
|
RELEASE_ID=$(gh release view "$TAG" --json databaseId --jq '.databaseId')
|
||||||
|
RELEASE_URL=$(gh release view "$TAG" --json url --jq '.url')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
./scripts/release/create_or_update_release.sh \
|
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||||
"$TAG" \
|
echo "release_url=$RELEASE_URL" >> "$GITHUB_OUTPUT"
|
||||||
"$TARGET_COMMITISH" \
|
echo "Created release: $RELEASE_URL"
|
||||||
"$TITLE" \
|
|
||||||
"$IS_PRERELEASE"
|
|
||||||
|
|
||||||
# Prepare and upload release assets
|
# Prepare and upload release assets
|
||||||
upload-release-assets:
|
upload-release-assets:
|
||||||
name: Upload Release Assets
|
name: Upload Release Assets
|
||||||
needs: [ build-check, build-rustfs, create-release ]
|
needs: [ build-check, build-rustfs, create-release ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -907,8 +920,8 @@ jobs:
|
|||||||
# the pointed-to version is a prerelease.
|
# the pointed-to version is a prerelease.
|
||||||
update-latest-version:
|
update-latest-version:
|
||||||
name: Update Latest Version
|
name: Update Latest Version
|
||||||
needs: [ build-check, publish-release ]
|
needs: [ build-check, upload-release-assets ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Update latest.json
|
- name: Update latest.json
|
||||||
@@ -967,33 +980,51 @@ jobs:
|
|||||||
publish-release:
|
publish-release:
|
||||||
name: Publish Release
|
name: Publish Release
|
||||||
needs: [ build-check, create-release, upload-release-assets ]
|
needs: [ build-check, create-release, upload-release-assets ]
|
||||||
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'preview' || needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Publish release
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||||
|
|
||||||
|
- name: Update release notes and publish
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
TAG="${{ needs.build-check.outputs.version }}"
|
TAG="${{ needs.build-check.outputs.version }}"
|
||||||
|
VERSION="${{ needs.build-check.outputs.version }}"
|
||||||
|
IS_PRERELEASE="${{ needs.build-check.outputs.is_prerelease }}"
|
||||||
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
||||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
|
||||||
|
|
||||||
# Publish the release and correct its channel state on retries.
|
# Determine release type
|
||||||
# Only a stable final release may become GitHub Latest.
|
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||||
if [[ "$BUILD_TYPE" == "release" ]]; then
|
if [[ "$TAG" == *"alpha"* ]]; then
|
||||||
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
|
RELEASE_TYPE="alpha"
|
||||||
-F draft=false \
|
elif [[ "$TAG" == *"beta"* ]]; then
|
||||||
-F prerelease=false \
|
RELEASE_TYPE="beta"
|
||||||
-f make_latest=true >/dev/null
|
elif [[ "$TAG" == *"rc"* ]]; then
|
||||||
|
RELEASE_TYPE="rc"
|
||||||
|
else
|
||||||
|
RELEASE_TYPE="prerelease"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
|
RELEASE_TYPE="release"
|
||||||
-F draft=false \
|
|
||||||
-F prerelease=true \
|
|
||||||
-f make_latest=false >/dev/null
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Get original release notes from tag
|
||||||
|
ORIGINAL_NOTES=$(git tag -l --format='%(contents)' "${TAG}")
|
||||||
|
if [[ -z "$ORIGINAL_NOTES" || "$ORIGINAL_NOTES" =~ ^[[:space:]]*$ ]]; then
|
||||||
|
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||||
|
ORIGINAL_NOTES="Pre-release ${VERSION} (${RELEASE_TYPE})"
|
||||||
|
else
|
||||||
|
ORIGINAL_NOTES="Release ${VERSION}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Publish the release (remove draft status)
|
||||||
|
gh release edit "$TAG" --draft=false
|
||||||
|
|
||||||
echo "🎉 Released $TAG successfully!"
|
echo "🎉 Released $TAG successfully!"
|
||||||
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
echo "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
||||||
|
|||||||
@@ -173,10 +173,6 @@ jobs:
|
|||||||
run: cargo clippy --all-targets -- -D warnings
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Run nextest tests
|
- name: Run nextest tests
|
||||||
env:
|
|
||||||
# Three concurrent workspace test links saturate the self-hosted
|
|
||||||
# runner's overlay I/O and can wedge Cargo until the 75m timeout.
|
|
||||||
CARGO_BUILD_JOBS: "2"
|
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts/test-and-lint
|
mkdir -p artifacts/test-and-lint
|
||||||
# Evidence sampler for issue #5394: the post-mortem pgrep below runs
|
# Evidence sampler for issue #5394: the post-mortem pgrep below runs
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ jobs:
|
|||||||
github.event_name == 'workflow_dispatch' ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
(github.event.workflow_run.conclusion == 'success' &&
|
(github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.event == 'push' &&
|
github.event.workflow_run.event == 'push' &&
|
||||||
github.event.workflow_run.head_branch != 'main' &&
|
github.event.workflow_run.head_branch != 'main')
|
||||||
!contains(github.event.workflow_run.head_branch, '-preview'))
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
should_build: ${{ steps.check.outputs.should_build }}
|
should_build: ${{ steps.check.outputs.should_build }}
|
||||||
@@ -221,13 +220,6 @@ jobs:
|
|||||||
create_latest=true
|
create_latest=true
|
||||||
echo "🚀 Building with latest stable release version"
|
echo "🚀 Building with latest stable release version"
|
||||||
;;
|
;;
|
||||||
*-preview*)
|
|
||||||
build_type="preview"
|
|
||||||
is_prerelease=true
|
|
||||||
should_build=false
|
|
||||||
should_push=false
|
|
||||||
echo "⏭️ Preview tags do not publish Docker images"
|
|
||||||
;;
|
|
||||||
# Prerelease versions (must match first, more specific)
|
# Prerelease versions (must match first, more specific)
|
||||||
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
||||||
build_type="prerelease"
|
build_type="prerelease"
|
||||||
|
|||||||
@@ -33,12 +33,11 @@ jobs:
|
|||||||
build-helm-package:
|
build-helm-package:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: |
|
if: |
|
||||||
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
(
|
(
|
||||||
github.event.workflow_run.conclusion == 'success' &&
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
github.event.workflow_run.event == 'push' &&
|
github.event.workflow_run.event == 'push' &&
|
||||||
contains(github.event.workflow_run.head_branch, '.') &&
|
contains(github.event.workflow_run.head_branch, '.')
|
||||||
!contains(github.event.workflow_run.head_branch, '-preview')
|
|
||||||
)
|
)
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
|
|||||||
Generated
+159
-254
File diff suppressed because it is too large
Load Diff
+54
-59
@@ -69,7 +69,7 @@ edition = "2024"
|
|||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
repository = "https://github.com/rustfs/rustfs"
|
repository = "https://github.com/rustfs/rustfs"
|
||||||
rust-version = "1.97.1"
|
rust-version = "1.97.1"
|
||||||
version = "1.0.0-beta.12"
|
version = "1.0.0-beta.11"
|
||||||
homepage = "https://rustfs.com"
|
homepage = "https://rustfs.com"
|
||||||
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
description = "RustFS is a high-performance distributed object storage software built using Rust, one of the most popular languages worldwide. "
|
||||||
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
keywords = ["RustFS", "Minio", "object-storage", "filesystem", "s3"]
|
||||||
@@ -86,58 +86,58 @@ redundant_clone = "warn"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
# RustFS Internal Crates
|
# RustFS Internal Crates
|
||||||
rustfs = { path = "./rustfs", version = "1.0.0-beta.12" }
|
rustfs = { path = "./rustfs", version = "1.0.0-beta.11" }
|
||||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.12" }
|
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.11" }
|
||||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.12" }
|
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.11" }
|
||||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.12" }
|
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.11" }
|
||||||
rustfs-common = { path = "crates/common", version = "1.0.0-beta.12" }
|
rustfs-common = { path = "crates/common", version = "1.0.0-beta.11" }
|
||||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.12" }
|
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.11" }
|
||||||
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.12" }
|
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.11" }
|
||||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.12" }
|
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.11" }
|
||||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.12" }
|
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.11" }
|
||||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.12" }
|
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.11" }
|
||||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.12" }
|
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.11" }
|
||||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.12" }
|
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.11" }
|
||||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.12" }
|
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.11" }
|
||||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.12" }
|
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.11" }
|
||||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.12" }
|
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.11" }
|
||||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.12" }
|
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.11" }
|
||||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.12" }
|
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.11" }
|
||||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.12" }
|
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.11" }
|
||||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.12" }
|
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.11" }
|
||||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.12" }
|
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.11" }
|
||||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.12" }
|
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.11" }
|
||||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.12" }
|
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.11" }
|
||||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.12" }
|
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.11" }
|
||||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.12" }
|
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.11" }
|
||||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.12" }
|
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.11" }
|
||||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.12" }
|
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.11" }
|
||||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.12" }
|
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.11" }
|
||||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.12" }
|
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.11" }
|
||||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.12" }
|
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.11" }
|
||||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.12" }
|
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.11" }
|
||||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.12" }
|
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.11" }
|
||||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.12" }
|
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.11" }
|
||||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.12" }
|
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.11" }
|
||||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.12" }
|
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.11" }
|
||||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.12" }
|
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.11" }
|
||||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.12" }
|
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.11" }
|
||||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.12" }
|
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.11" }
|
||||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.12" }
|
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.11" }
|
||||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.12" }
|
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.11" }
|
||||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.12" }
|
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.11" }
|
||||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.12" }
|
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.11" }
|
||||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.12" }
|
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.11" }
|
||||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.12" }
|
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.11" }
|
||||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.12" }
|
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.11" }
|
||||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.12" }
|
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.11" }
|
||||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.12" }
|
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.11" }
|
||||||
|
|
||||||
# Async Runtime and Networking
|
# Async Runtime and Networking
|
||||||
async-channel = "2.5.0"
|
async-channel = "2.5.0"
|
||||||
async_zip = { default-features = false, version = "0.0.18" }
|
async_zip = { default-features = false, version = "0.0.18" }
|
||||||
mysql_async = { default-features = false, version = "0.37" }
|
mysql_async = { default-features = false, version = "0.37" }
|
||||||
async-compression = { version = "0.4.43" }
|
async-compression = { version = "0.4.42" }
|
||||||
async-recursion = "1.1.1"
|
async-recursion = "1.1.1"
|
||||||
async-trait = "0.1.91"
|
async-trait = "0.1.91"
|
||||||
async-nats = { version = "0.50.0", default-features = false }
|
async-nats = { version = "0.50.0", default-features = false }
|
||||||
@@ -152,7 +152,7 @@ lapin = { default-features = false, version = "4.10.0" }
|
|||||||
hyper = { version = "1.11.0" }
|
hyper = { version = "1.11.0" }
|
||||||
hyper-rustls = { default-features = false, version = "0.27.9" }
|
hyper-rustls = { default-features = false, version = "0.27.9" }
|
||||||
hyper-util = { version = "0.1.20" }
|
hyper-util = { version = "0.1.20" }
|
||||||
http = "1.5.0"
|
http = "1.4.2"
|
||||||
http-body = "1.1.0"
|
http-body = "1.1.0"
|
||||||
http-body-util = "0.1.4"
|
http-body-util = "0.1.4"
|
||||||
minlz = "1.2.3"
|
minlz = "1.2.3"
|
||||||
@@ -200,7 +200,7 @@ jsonwebtoken = { version = "11.0.0" }
|
|||||||
openidconnect = { default-features = false, version = "4.0" }
|
openidconnect = { default-features = false, version = "4.0" }
|
||||||
pbkdf2 = "0.13.0"
|
pbkdf2 = "0.13.0"
|
||||||
rsa = { version = "=0.10.0-rc.18" }
|
rsa = { version = "=0.10.0-rc.18" }
|
||||||
rustls = { default-features = false, version = "0.23.43" }
|
rustls = { default-features = false, version = "0.23.42" }
|
||||||
rustls-native-certs = "0.8"
|
rustls-native-certs = "0.8"
|
||||||
rustls-pki-types = "1.15.1"
|
rustls-pki-types = "1.15.1"
|
||||||
sha1 = "0.11.0"
|
sha1 = "0.11.0"
|
||||||
@@ -250,13 +250,12 @@ enumset = "1.1.14"
|
|||||||
faster-hex = "0.10.0"
|
faster-hex = "0.10.0"
|
||||||
flate2 = "1.1.9"
|
flate2 = "1.1.9"
|
||||||
glob = "0.3.4"
|
glob = "0.3.4"
|
||||||
google-cloud-storage = "1.17.0"
|
google-cloud-storage = "1.16.0"
|
||||||
google-cloud-auth = "1.15.0"
|
google-cloud-auth = "1.14.0"
|
||||||
hashbrown = { version = "0.17.1" }
|
hashbrown = { version = "0.17.1" }
|
||||||
hex = "0.4.3"
|
hex = "0.4.3"
|
||||||
hex-simd = "0.8.0"
|
hex-simd = "0.8.0"
|
||||||
highway = { version = "1.3.0" }
|
highway = { version = "1.3.0" }
|
||||||
hostname = "0.4.2"
|
|
||||||
ipnetwork = { version = "0.21.1" }
|
ipnetwork = { version = "0.21.1" }
|
||||||
lazy_static = "1.5.0"
|
lazy_static = "1.5.0"
|
||||||
libc = "0.2.189"
|
libc = "0.2.189"
|
||||||
@@ -284,8 +283,7 @@ reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
|
|||||||
reed-solomon-simd = "3.1.0"
|
reed-solomon-simd = "3.1.0"
|
||||||
regex = { version = "1.13.1" }
|
regex = { version = "1.13.1" }
|
||||||
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
|
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
|
||||||
redis = { version = "1.5.0" }
|
redis = { version = "1.4.1" }
|
||||||
rustify = { version = "0.7", default-features = false }
|
|
||||||
rustix = { version = "1.1.4" }
|
rustix = { version = "1.1.4" }
|
||||||
rust-embed = { version = "8.12.0" }
|
rust-embed = { version = "8.12.0" }
|
||||||
rustc-hash = { version = "2.1.3" }
|
rustc-hash = { version = "2.1.3" }
|
||||||
@@ -305,7 +303,6 @@ test-case = "3.3.1"
|
|||||||
thiserror = "2.0.19"
|
thiserror = "2.0.19"
|
||||||
tracing = { version = "0.1.44" }
|
tracing = { version = "0.1.44" }
|
||||||
tracing-appender = "0.2.5"
|
tracing-appender = "0.2.5"
|
||||||
tracing-core = "0.1.36"
|
|
||||||
tracing-error = "0.2.1"
|
tracing-error = "0.2.1"
|
||||||
tracing-opentelemetry = { version = "0.33" }
|
tracing-opentelemetry = { version = "0.33" }
|
||||||
tracing-subscriber = { version = "0.3.23" }
|
tracing-subscriber = { version = "0.3.23" }
|
||||||
@@ -316,9 +313,7 @@ uuid = { version = "1.24.0" }
|
|||||||
vaultrs = { version = "0.8.0" }
|
vaultrs = { version = "0.8.0" }
|
||||||
tar = "0.4.46"
|
tar = "0.4.46"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
winapi-util = "0.1.11"
|
|
||||||
windows = { version = "0.62.2" }
|
windows = { version = "0.62.2" }
|
||||||
windows-sys = "0.61.2"
|
|
||||||
xxhash-rust = { version = "0.8.18" }
|
xxhash-rust = { version = "0.8.18" }
|
||||||
zip = "8.6.0"
|
zip = "8.6.0"
|
||||||
zstd = "0.13.3"
|
zstd = "0.13.3"
|
||||||
@@ -348,7 +343,7 @@ dav-server = "0.11.0"
|
|||||||
|
|
||||||
# Performance Analysis and Memory Profiling
|
# Performance Analysis and Memory Profiling
|
||||||
mimalloc = "0.1.52"
|
mimalloc = "0.1.52"
|
||||||
hotpath = { version = "0.22.0", default-features = false }
|
hotpath = "0.22.0"
|
||||||
# Snapshot testing for output format regression detection
|
# Snapshot testing for output format regression detection
|
||||||
insta = { version = "1.48" }
|
insta = { version = "1.48" }
|
||||||
|
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ chown -R 10001:10001 data logs
|
|||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||||
|
|
||||||
# Using specific version
|
# Using specific version
|
||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.12
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.11
|
||||||
```
|
```
|
||||||
|
|
||||||
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
If you use [podman](https://github.com/containers/podman) instead of docker, you can install the RustFS with the below command
|
||||||
|
|||||||
+1
-1
@@ -113,7 +113,7 @@ chown -R 10001:10001 data logs
|
|||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:latest
|
||||||
|
|
||||||
# 使用指定版本运行
|
# 使用指定版本运行
|
||||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.12
|
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.11
|
||||||
```
|
```
|
||||||
|
|
||||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||||
|
|||||||
@@ -25,33 +25,7 @@ documentation = "https://docs.rs/rustfs-audit/latest/rustfs_audit/"
|
|||||||
keywords = ["audit", "target", "management", "fan-out", "RustFS"]
|
keywords = ["audit", "target", "management", "fan-out", "RustFS"]
|
||||||
categories = ["web-programming", "development-tools", "asynchronous", "api-bindings"]
|
categories = ["web-programming", "development-tools", "asynchronous", "api-bindings"]
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
hotpath = [
|
|
||||||
"hotpath/hotpath",
|
|
||||||
"hotpath/tokio",
|
|
||||||
"hotpath/futures",
|
|
||||||
"rustfs-config/hotpath",
|
|
||||||
"rustfs-s3-types/hotpath",
|
|
||||||
"rustfs-targets/hotpath",
|
|
||||||
]
|
|
||||||
hotpath-alloc = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-alloc",
|
|
||||||
"rustfs-config/hotpath-alloc",
|
|
||||||
"rustfs-s3-types/hotpath-alloc",
|
|
||||||
"rustfs-targets/hotpath-alloc",
|
|
||||||
]
|
|
||||||
hotpath-cpu = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-cpu",
|
|
||||||
"rustfs-config/hotpath-cpu",
|
|
||||||
"rustfs-s3-types/hotpath-cpu",
|
|
||||||
"rustfs-targets/hotpath-cpu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
rustfs-targets = { workspace = true }
|
rustfs-targets = { workspace = true }
|
||||||
rustfs-config = { workspace = true, features = ["audit", "server-config-model"] }
|
rustfs-config = { workspace = true, features = ["audit", "server-config-model"] }
|
||||||
rustfs-s3-types = { workspace = true }
|
rustfs-s3-types = { workspace = true }
|
||||||
|
|||||||
@@ -28,14 +28,7 @@ documentation = "https://docs.rs/rustfs-checksums/latest/rustfs_checksum/"
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
hotpath = ["hotpath/hotpath"]
|
|
||||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
|
||||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
bytes = { workspace = true, features = ["serde"] }
|
bytes = { workspace = true, features = ["serde"] }
|
||||||
crc-fast = { workspace = true }
|
crc-fast = { workspace = true }
|
||||||
http = { workspace = true }
|
http = { workspace = true }
|
||||||
|
|||||||
@@ -27,14 +27,7 @@ categories = ["web-programming", "development-tools", "data-structures"]
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
hotpath = ["hotpath/hotpath", "hotpath/tokio"]
|
|
||||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
|
||||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||||
|
|||||||
@@ -13,14 +13,7 @@ categories = ["concurrency", "filesystem"]
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
hotpath = ["hotpath/hotpath", "hotpath/tokio", "rustfs-io-core/hotpath"]
|
|
||||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "rustfs-io-core/hotpath-alloc"]
|
|
||||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-io-core/hotpath-cpu"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
rustfs-io-core = { workspace = true }
|
rustfs-io-core = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ keywords = ["configuration", "settings", "management", "rustfs", "Minio"]
|
|||||||
categories = ["web-programming", "development-tools", "config"]
|
categories = ["web-programming", "development-tools", "config"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
const-str = { workspace = true, optional = true, features = ["std", "proc"] }
|
const-str = { workspace = true, optional = true, features = ["std", "proc"] }
|
||||||
serde = { workspace = true, optional = true, features = ["derive"] }
|
serde = { workspace = true, optional = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true, optional = true, features = ["raw_value"] }
|
serde_json = { workspace = true, optional = true, features = ["raw_value"] }
|
||||||
@@ -35,9 +34,6 @@ workspace = true
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["constants"]
|
default = ["constants"]
|
||||||
hotpath = ["hotpath/hotpath"]
|
|
||||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
|
||||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
|
||||||
audit = ["dep:const-str", "constants"]
|
audit = ["dep:const-str", "constants"]
|
||||||
constants = ["dep:const-str"]
|
constants = ["dep:const-str"]
|
||||||
notify = ["dep:const-str", "constants"]
|
notify = ["dep:const-str", "constants"]
|
||||||
|
|||||||
@@ -66,10 +66,6 @@ Current guidance:
|
|||||||
|
|
||||||
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
|
- `RUSTFS_BROWSER_REDIRECT_URL` sets the externally reachable browser origin used for OIDC callback, console success redirect, and logout fallback URLs. Configure it to the public scheme and authority without a path, for example `https://console.example.com`. In load-balancer deployments, keep OIDC authorize and callback requests on the same backend node because the in-flight OIDC `state` is local to the RustFS node.
|
||||||
|
|
||||||
## Distributed endpoint locality
|
|
||||||
|
|
||||||
- `RUSTFS_LOCAL_ENDPOINT_HOST` identifies this server's host in a distributed `RUSTFS_VOLUMES` topology without resolving every peer during startup. Set it to exactly one host, without a scheme, port, or path. It is accepted only for orchestrated URL topologies and must match at least one endpoint on the RustFS server port; invalid or unmatched values fail startup. Leave it unset to retain DNS-based locality discovery.
|
|
||||||
|
|
||||||
## Scanner environment aliases
|
## Scanner environment aliases
|
||||||
|
|
||||||
- `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`)
|
- `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`)
|
||||||
|
|||||||
@@ -131,10 +131,6 @@ pub const ENV_RUSTFS_ADDRESS: &str = "RUSTFS_ADDRESS";
|
|||||||
/// Environment variable for server volumes.
|
/// Environment variable for server volumes.
|
||||||
pub const ENV_RUSTFS_VOLUMES: &str = "RUSTFS_VOLUMES";
|
pub const ENV_RUSTFS_VOLUMES: &str = "RUSTFS_VOLUMES";
|
||||||
|
|
||||||
/// Environment variable identifying this server's host in distributed endpoint
|
|
||||||
/// lists without relying on DNS locality discovery.
|
|
||||||
pub const ENV_LOCAL_ENDPOINT_HOST: &str = "RUSTFS_LOCAL_ENDPOINT_HOST";
|
|
||||||
|
|
||||||
/// Environment variable to explicitly bypass local physical disk independence checks.
|
/// Environment variable to explicitly bypass local physical disk independence checks.
|
||||||
pub const ENV_UNSAFE_BYPASS_DISK_CHECK: &str = "RUSTFS_UNSAFE_BYPASS_DISK_CHECK";
|
pub const ENV_UNSAFE_BYPASS_DISK_CHECK: &str = "RUSTFS_UNSAFE_BYPASS_DISK_CHECK";
|
||||||
|
|
||||||
|
|||||||
@@ -158,33 +158,17 @@ pub const DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT: bool = false;
|
|||||||
// rolling upgrades until the fleet-wide body-digest fallback counter reads zero.
|
// rolling upgrades until the fleet-wide body-digest fallback counter reads zero.
|
||||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT);
|
const _: () = assert!(!DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT);
|
||||||
|
|
||||||
/// Require the replay-scoped internode RPC signature after the fleet has converged on it.
|
|
||||||
///
|
|
||||||
/// The default keeps v1/v2 peers available during a rolling upgrade. Operators may set this only
|
|
||||||
/// after `rustfs_system_network_internode_replay_scope_fallback_total` remains zero for a full
|
|
||||||
/// release window. The node still accepts a v2-authenticated `Ping` carrying an epoch challenge:
|
|
||||||
/// that narrowly scoped bootstrap lets an upgraded client learn the receiving process epoch and
|
|
||||||
/// immediately retry with the replay-scoped signature after a peer restart.
|
|
||||||
pub const ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT: &str = "RUSTFS_INTERNODE_RPC_REPLAY_SCOPE_STRICT";
|
|
||||||
pub const DEFAULT_INTERNODE_RPC_REPLAY_SCOPE_STRICT: bool = false;
|
|
||||||
|
|
||||||
// Compile-time invariant: mixed-version clusters must remain available until operators make the
|
|
||||||
// observed fallback counter an explicit strictness decision.
|
|
||||||
const _: () = assert!(!DEFAULT_INTERNODE_RPC_REPLAY_SCOPE_STRICT);
|
|
||||||
|
|
||||||
/// Capacity (distinct nonces) of the process-local internode RPC replay cache that enforces
|
/// Capacity (distinct nonces) of the process-local internode RPC replay cache that enforces
|
||||||
/// one-time consumption of authenticated RPC signatures.
|
/// one-time consumption of body-bound v2 signatures.
|
||||||
///
|
///
|
||||||
/// The cache retains each nonce for the ~10-minute signature freshness envelope. Once peers use
|
/// The cache retains each nonce for the ~10-minute signature freshness envelope, so the steady
|
||||||
/// replay-scoped v3 authentication, every authenticated RPC consumes one entry, so the steady
|
/// state holds roughly `mutating RPS x 601s` entries; the default sustains ~1,700 body-bound
|
||||||
/// state holds roughly `authenticated RPC RPS x 601s` entries. The default sustains about 1,700
|
/// mutating RPCs per second (about 120 MiB worst case, allocated only under sustained load).
|
||||||
/// authenticated RPCs per second (about 120 MiB worst case, allocated only under sustained load);
|
/// Overflow fails closed — legitimate signed traffic is the only thing that can fill the cache
|
||||||
/// operators must size it for the node's aggregate peak RPC rate before enabling strict replay
|
/// (replays are rejected before insertion, and an attacker cannot mint valid nonces without the
|
||||||
/// scope. Overflow fails closed — legitimate signed traffic is the only thing that can fill the
|
/// shared secret) — and increments
|
||||||
/// cache (replays are rejected before insertion, and an attacker cannot mint valid nonces without
|
|
||||||
/// the shared secret) — and increments
|
|
||||||
/// `rustfs_system_network_internode_replay_cache_overflow_total`, so a sustained non-zero overflow
|
/// `rustfs_system_network_internode_replay_cache_overflow_total`, so a sustained non-zero overflow
|
||||||
/// counter means this capacity is undersized for the node's peak authenticated RPC rate.
|
/// counter means this capacity is undersized for the node's peak mutation rate.
|
||||||
pub const ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: &str = "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY";
|
pub const ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: &str = "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY";
|
||||||
pub const DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: usize = 1_048_576;
|
pub const DEFAULT_INTERNODE_RPC_REPLAY_CACHE_CAPACITY: usize = 1_048_576;
|
||||||
|
|
||||||
@@ -370,12 +354,6 @@ mod tests {
|
|||||||
assert_eq!(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT");
|
assert_eq!(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "RUSTFS_INTERNODE_RPC_BODY_DIGEST_STRICT");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn internode_replay_scope_strict_env_name_is_stable() {
|
|
||||||
// The fail-open default invariant is asserted at compile time next to the definition.
|
|
||||||
assert_eq!(ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT, "RUSTFS_INTERNODE_RPC_REPLAY_SCOPE_STRICT");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn internode_replay_cache_capacity_defaults_and_env_name() {
|
fn internode_replay_cache_capacity_defaults_and_env_name() {
|
||||||
assert_eq!(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY");
|
assert_eq!(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "RUSTFS_INTERNODE_RPC_REPLAY_CACHE_CAPACITY");
|
||||||
|
|||||||
@@ -24,14 +24,7 @@ description = "Credentials management utilities for RustFS, enabling secure hand
|
|||||||
keywords = ["rustfs", "Minio", "credentials", "authentication", "authorization"]
|
keywords = ["rustfs", "Minio", "credentials", "authentication", "authorization"]
|
||||||
categories = ["web-programming", "development-tools", "data-structures", "security"]
|
categories = ["web-programming", "development-tools", "data-structures", "security"]
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
hotpath = ["hotpath/hotpath"]
|
|
||||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
|
||||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
base64-simd = { workspace = true }
|
base64-simd = { workspace = true }
|
||||||
hmac = { workspace = true }
|
hmac = { workspace = true }
|
||||||
rand = { workspace = true, features = ["serde"] }
|
rand = { workspace = true, features = ["serde"] }
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ documentation = "https://docs.rs/rustfs-crypto/latest/rustfs_crypto/"
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
aes-gcm = { workspace = true, optional = true, features = ["rand_core"] }
|
aes-gcm = { workspace = true, optional = true, features = ["rand_core"] }
|
||||||
argon2 = { workspace = true, optional = true }
|
argon2 = { workspace = true, optional = true }
|
||||||
chacha20poly1305 = { workspace = true, optional = true }
|
chacha20poly1305 = { workspace = true, optional = true }
|
||||||
@@ -50,9 +49,6 @@ time = { workspace = true, features = ["parsing", "formatting", "macros", "serde
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["crypto", "fips"]
|
default = ["crypto", "fips"]
|
||||||
hotpath = ["hotpath/hotpath"]
|
|
||||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
|
||||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
|
||||||
fips = []
|
fips = []
|
||||||
crypto = [
|
crypto = [
|
||||||
"dep:aes-gcm",
|
"dep:aes-gcm",
|
||||||
|
|||||||
@@ -27,15 +27,9 @@ categories = ["data-structures", "filesystem"]
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
hotpath = ["hotpath/hotpath", "rustfs-filemeta/hotpath"]
|
|
||||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "rustfs-filemeta/hotpath-alloc"]
|
|
||||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-filemeta/hotpath-cpu"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
path-clean = { workspace = true }
|
||||||
rmp-serde = { workspace = true }
|
rmp-serde = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
rustfs-filemeta = { workspace = true }
|
rustfs-filemeta = { workspace = true }
|
||||||
|
|||||||
@@ -12,10 +12,12 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
use path_clean::PathClean;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, HashSet},
|
collections::{HashMap, HashSet},
|
||||||
hash::{DefaultHasher, Hash, Hasher},
|
hash::{DefaultHasher, Hash, Hasher},
|
||||||
|
path::Path,
|
||||||
time::{Duration, SystemTime},
|
time::{Duration, SystemTime},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -332,7 +334,7 @@ impl<'de> Deserialize<'de> for SizeHistogram {
|
|||||||
impl SizeHistogram {
|
impl SizeHistogram {
|
||||||
pub fn add(&mut self, size: u64) {
|
pub fn add(&mut self, size: u64) {
|
||||||
let intervals = [
|
let intervals = [
|
||||||
(0, 1024 - 1), // LESS_THAN_1024_B
|
(0, 1024), // LESS_THAN_1024_B
|
||||||
(1024, 64 * 1024 - 1), // BETWEEN_1024_B_AND_64_KB
|
(1024, 64 * 1024 - 1), // BETWEEN_1024_B_AND_64_KB
|
||||||
(64 * 1024, 256 * 1024 - 1), // BETWEEN_64_KB_AND_256_KB
|
(64 * 1024, 256 * 1024 - 1), // BETWEEN_64_KB_AND_256_KB
|
||||||
(256 * 1024, 512 * 1024 - 1), // BETWEEN_256_KB_AND_512_KB
|
(256 * 1024, 512 * 1024 - 1), // BETWEEN_256_KB_AND_512_KB
|
||||||
@@ -360,7 +362,7 @@ impl SizeHistogram {
|
|||||||
// the sub-ranges in [1 KiB, 512 KiB).
|
// the sub-ranges in [1 KiB, 512 KiB).
|
||||||
const ONE_MIB: u64 = 1024 * 1024;
|
const ONE_MIB: u64 = 1024 * 1024;
|
||||||
let intervals = [
|
let intervals = [
|
||||||
(0, 1024 - 1), // LESS_THAN_1024_B
|
(0, 1024), // LESS_THAN_1024_B
|
||||||
(1024, 64 * 1024 - 1), // BETWEEN_1024_B_AND_64_KB
|
(1024, 64 * 1024 - 1), // BETWEEN_1024_B_AND_64_KB
|
||||||
(64 * 1024, 256 * 1024 - 1), // BETWEEN_64_KB_AND_256_KB
|
(64 * 1024, 256 * 1024 - 1), // BETWEEN_64_KB_AND_256_KB
|
||||||
(256 * 1024, 512 * 1024 - 1), // BETWEEN_256_KB_AND_512_KB
|
(256 * 1024, 512 * 1024 - 1), // BETWEEN_256_KB_AND_512_KB
|
||||||
@@ -1108,39 +1110,9 @@ fn mark(duc: &DataUsageCache, entry: &DataUsageEntry, found: &mut HashSet<String
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn clean_data_usage_path(data: &str) -> String {
|
/// Hash a path for data usage caching
|
||||||
let rooted = data.starts_with('/');
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
|
|
||||||
for part in data.split('/') {
|
|
||||||
match part {
|
|
||||||
"" | "." => {}
|
|
||||||
".." => {
|
|
||||||
if parts.last().is_some_and(|last| *last != "..") {
|
|
||||||
parts.pop();
|
|
||||||
} else if !rooted {
|
|
||||||
parts.push(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => parts.push(part),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let clean = parts.join("/");
|
|
||||||
match (rooted, clean.is_empty()) {
|
|
||||||
(true, true) => "/".to_string(),
|
|
||||||
(true, false) => format!("/{clean}"),
|
|
||||||
(false, true) => ".".to_string(),
|
|
||||||
(false, false) => clean,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hash a slash-separated path for data usage caching.
|
|
||||||
///
|
|
||||||
/// Cache identifiers are persisted and exchanged across nodes, so their
|
|
||||||
/// normalization must not depend on the host operating system.
|
|
||||||
pub fn hash_path(data: &str) -> DataUsageHash {
|
pub fn hash_path(data: &str) -> DataUsageHash {
|
||||||
DataUsageHash(clean_data_usage_path(data))
|
DataUsageHash(Path::new(&data).clean().to_string_lossy().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DataUsageInfo {
|
impl DataUsageInfo {
|
||||||
@@ -1525,23 +1497,6 @@ mod tests {
|
|||||||
buckets_count: u64,
|
buckets_count: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hash_path_uses_portable_slash_semantics() {
|
|
||||||
for (input, expected) in [
|
|
||||||
("", "."),
|
|
||||||
(".", "."),
|
|
||||||
("/", "/"),
|
|
||||||
("//bucket///prefix/", "/bucket/prefix"),
|
|
||||||
("bucket/./prefix//object", "bucket/prefix/object"),
|
|
||||||
("bucket/a/../b", "bucket/b"),
|
|
||||||
("../bucket/..", ".."),
|
|
||||||
("/../../bucket", "/bucket"),
|
|
||||||
("bucket\\prefix/object", "bucket\\prefix/object"),
|
|
||||||
] {
|
|
||||||
assert_eq!(hash_path(input).key(), expected, "unexpected portable cache key for {input:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn completeness_marker_is_additive_for_legacy_named_readers() {
|
fn completeness_marker_is_additive_for_legacy_named_readers() {
|
||||||
let current = DataUsageInfo {
|
let current = DataUsageInfo {
|
||||||
@@ -1646,49 +1601,6 @@ mod tests {
|
|||||||
assert_eq!(map["BETWEEN_512_KB_AND_1_MB"], 1);
|
assert_eq!(map["BETWEEN_512_KB_AND_1_MB"], 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_size_histogram_classifies_adjacent_boundaries_once() {
|
|
||||||
let cases = [
|
|
||||||
(1023, 0),
|
|
||||||
(1024, 1),
|
|
||||||
(64 * 1024 - 1, 1),
|
|
||||||
(64 * 1024, 2),
|
|
||||||
(256 * 1024 - 1, 2),
|
|
||||||
(256 * 1024, 3),
|
|
||||||
(512 * 1024 - 1, 3),
|
|
||||||
(512 * 1024, 4),
|
|
||||||
(1024 * 1024 - 1, 4),
|
|
||||||
(1024 * 1024, 6),
|
|
||||||
(10 * 1024 * 1024 - 1, 6),
|
|
||||||
(10 * 1024 * 1024, 7),
|
|
||||||
(64 * 1024 * 1024 - 1, 7),
|
|
||||||
(64 * 1024 * 1024, 8),
|
|
||||||
(128 * 1024 * 1024 - 1, 8),
|
|
||||||
(128 * 1024 * 1024, 9),
|
|
||||||
(512 * 1024 * 1024 - 1, 9),
|
|
||||||
(512 * 1024 * 1024, 10),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (size, expected_bucket) in cases {
|
|
||||||
let mut hist = SizeHistogram::default();
|
|
||||||
hist.add(size);
|
|
||||||
|
|
||||||
assert_eq!(hist.0.iter().sum::<u64>(), 1, "size {size} must have exactly one physical bucket");
|
|
||||||
assert_eq!(hist.0[expected_bucket], 1, "size {size} must select the expected bucket");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_size_histogram_1024_bytes_contributes_to_compat_rollup() {
|
|
||||||
let mut hist = SizeHistogram::default();
|
|
||||||
hist.add(1024);
|
|
||||||
|
|
||||||
let map = hist.to_map();
|
|
||||||
assert_eq!(map["LESS_THAN_1024_B"], 0);
|
|
||||||
assert_eq!(map["BETWEEN_1024_B_AND_64_KB"], 1);
|
|
||||||
assert_eq!(map["BETWEEN_1024B_AND_1_MB"], 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_size_histogram_compat_rollup_saturates_on_corrupt_counts() {
|
fn test_size_histogram_compat_rollup_saturates_on_corrupt_counts() {
|
||||||
let mut hist = SizeHistogram::default();
|
let mut hist = SizeHistogram::default();
|
||||||
|
|||||||
@@ -25,58 +25,10 @@ workspace = true
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
hotpath = [
|
|
||||||
"hotpath/hotpath",
|
|
||||||
"hotpath/tokio",
|
|
||||||
"hotpath/futures",
|
|
||||||
"hotpath/reqwest-0-13",
|
|
||||||
"rustfs-config/hotpath",
|
|
||||||
"rustfs-credentials/hotpath",
|
|
||||||
"rustfs-data-usage/hotpath",
|
|
||||||
"rustfs-ecstore/hotpath",
|
|
||||||
"rustfs-filemeta/hotpath",
|
|
||||||
"rustfs-lock/hotpath",
|
|
||||||
"rustfs-madmin/hotpath",
|
|
||||||
"rustfs-protos/hotpath",
|
|
||||||
"rustfs-rio/hotpath",
|
|
||||||
"rustfs-signer/hotpath",
|
|
||||||
"rustfs-utils/hotpath",
|
|
||||||
]
|
|
||||||
hotpath-alloc = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-alloc",
|
|
||||||
"rustfs-config/hotpath-alloc",
|
|
||||||
"rustfs-credentials/hotpath-alloc",
|
|
||||||
"rustfs-data-usage/hotpath-alloc",
|
|
||||||
"rustfs-ecstore/hotpath-alloc",
|
|
||||||
"rustfs-filemeta/hotpath-alloc",
|
|
||||||
"rustfs-lock/hotpath-alloc",
|
|
||||||
"rustfs-madmin/hotpath-alloc",
|
|
||||||
"rustfs-protos/hotpath-alloc",
|
|
||||||
"rustfs-rio/hotpath-alloc",
|
|
||||||
"rustfs-signer/hotpath-alloc",
|
|
||||||
"rustfs-utils/hotpath-alloc",
|
|
||||||
]
|
|
||||||
hotpath-cpu = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-cpu",
|
|
||||||
"rustfs-config/hotpath-cpu",
|
|
||||||
"rustfs-credentials/hotpath-cpu",
|
|
||||||
"rustfs-data-usage/hotpath-cpu",
|
|
||||||
"rustfs-ecstore/hotpath-cpu",
|
|
||||||
"rustfs-filemeta/hotpath-cpu",
|
|
||||||
"rustfs-lock/hotpath-cpu",
|
|
||||||
"rustfs-madmin/hotpath-cpu",
|
|
||||||
"rustfs-protos/hotpath-cpu",
|
|
||||||
"rustfs-rio/hotpath-cpu",
|
|
||||||
"rustfs-signer/hotpath-cpu",
|
|
||||||
"rustfs-utils/hotpath-cpu",
|
|
||||||
]
|
|
||||||
ftps = []
|
ftps = []
|
||||||
sftp = []
|
sftp = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
rustfs-config = { workspace = true, features = ["constants"] }
|
rustfs-config = { workspace = true, features = ["constants"] }
|
||||||
rustfs-credentials.workspace = true
|
rustfs-credentials.workspace = true
|
||||||
rustfs-ecstore.workspace = true
|
rustfs-ecstore.workspace = true
|
||||||
|
|||||||
@@ -2136,20 +2136,11 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
|||||||
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
|
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
|
||||||
assert_eq!(terminal["prefix"].as_str(), Some(prefix));
|
assert_eq!(terminal["prefix"].as_str(), Some(prefix));
|
||||||
assert_eq!(terminal["dry_run"].as_bool(), Some(false));
|
assert_eq!(terminal["dry_run"].as_bool(), Some(false));
|
||||||
let terminal_status = terminal["status"].as_str();
|
assert_eq!(
|
||||||
assert!(
|
terminal["status"].as_str(),
|
||||||
matches!(terminal_status, Some("partial" | "unknown")),
|
Some("partial"),
|
||||||
"small transition queue should surface terminal backpressure: {terminal}"
|
"small transition queue should surface terminal backpressure: {terminal}"
|
||||||
);
|
);
|
||||||
if terminal_status == Some("unknown") {
|
|
||||||
let failure_reason = terminal["failure_reason"]
|
|
||||||
.as_str()
|
|
||||||
.ok_or_else(|| format!("unknown terminal status omitted failure_reason: {terminal}"))?;
|
|
||||||
assert!(
|
|
||||||
failure_reason.contains("worker result was not persisted before the transition queue drained"),
|
|
||||||
"unknown terminal status should identify lost worker-result persistence: {terminal}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let skipped_queue_full = terminal["report"]["skipped_queue_full"]
|
let skipped_queue_full = terminal["report"]["skipped_queue_full"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.ok_or_else(|| format!("terminal status omitted report.skipped_queue_full: {terminal}"))?;
|
.ok_or_else(|| format!("terminal status omitted report.skipped_queue_full: {terminal}"))?;
|
||||||
|
|||||||
@@ -13,9 +13,8 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
//! Cross-process replay / tamper acceptance for internode NodeService RPC
|
//! Cross-process replay / tamper acceptance for the internode NodeService v2 RPC
|
||||||
//! signatures (<https://github.com/rustfs/backlog/issues/1327>,
|
//! signature (<https://github.com/rustfs/backlog/issues/1327>).
|
||||||
//! <https://github.com/rustfs/backlog/issues/1542>).
|
|
||||||
//!
|
//!
|
||||||
//! # Why this exists on top of the in-process tests
|
//! # Why this exists on top of the in-process tests
|
||||||
//!
|
//!
|
||||||
@@ -79,8 +78,6 @@
|
|||||||
//! | mixed version: legacy-only still served, not blocked | [`legacy_only_signature_is_accepted_in_default_posture`] |
|
//! | mixed version: legacy-only still served, not blocked | [`legacy_only_signature_is_accepted_in_default_posture`] |
|
||||||
//! | strict flip closes the signature downgrade | [`signature_strict_rejects_legacy_only_downgrade`] |
|
//! | strict flip closes the signature downgrade | [`signature_strict_rejects_legacy_only_downgrade`] |
|
||||||
//! | strict flip closes the body-digest downgrade, incl. v1 | [`body_digest_strict_rejects_digestless_mutation`] |
|
//! | strict flip closes the body-digest downgrade, incl. v1 | [`body_digest_strict_rejects_digestless_mutation`] |
|
||||||
//! | replay scope binds every RPC and rejects restart replay | [`replay_scope_rejects_replay_path_transplant_and_stale_epoch_e2e`] |
|
|
||||||
//! | strict replay scope allows only Ping bootstrap before v3 | [`replay_scope_strict_requires_v3_after_ping_bootstrap_e2e`] |
|
|
||||||
//!
|
//!
|
||||||
//! Two acceptance items are deliberately left to the in-process tests. A stale
|
//! Two acceptance items are deliberately left to the in-process tests. A stale
|
||||||
//! timestamp cannot be forged from outside — it is inside the HMAC — so
|
//! timestamp cannot be forged from outside — it is inside the HMAC — so
|
||||||
@@ -91,20 +88,18 @@
|
|||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use crate::storage_api::internode_rpc_signature::{
|
use crate::storage_api::internode_rpc_signature::{
|
||||||
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers, node_service_time_out_client_no_auth,
|
||||||
node_service_time_out_client_no_auth, verify_tonic_boot_epoch_response,
|
|
||||||
};
|
};
|
||||||
use http::{HeaderMap, Method};
|
use http::{HeaderMap, Method};
|
||||||
use rustfs_config::{
|
use rustfs_config::{
|
||||||
ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT,
|
ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, ENV_INTERNODE_RPC_SIGNATURE_STRICT,
|
||||||
ENV_INTERNODE_RPC_SIGNATURE_STRICT,
|
|
||||||
};
|
};
|
||||||
use rustfs_protos::canonical_make_volume_request_body;
|
use rustfs_protos::canonical_make_volume_request_body;
|
||||||
use rustfs_protos::proto_gen::node_service::{MakeVolumeRequest, MakeVolumeResponse, PingRequest, PingResponse};
|
use rustfs_protos::proto_gen::node_service::{MakeVolumeRequest, MakeVolumeResponse};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use tonic::{Code, Request, Response, Status};
|
use tonic::{Code, Request, Status};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
type TestResult = Result<(), Box<dyn Error + Send + Sync>>;
|
||||||
@@ -120,14 +115,12 @@ const TEST_RPC_SECRET: &str = "rustfs-internode-signature-e2e-secret";
|
|||||||
/// clears authentication stops harmlessly at `find_disk`.
|
/// clears authentication stops harmlessly at `find_disk`.
|
||||||
const ABSENT_DISK: &str = "/nonexistent/rustfs-signature-e2e-disk";
|
const ABSENT_DISK: &str = "/nonexistent/rustfs-signature-e2e-disk";
|
||||||
|
|
||||||
/// Wire names of the v2 and replay-scope headers these black-box tests edit. They are
|
/// Wire names of the two v2 headers these tests edit. They are `pub(crate)` in
|
||||||
/// `pub(crate)` in ecstore, so they are repeated here rather than imported.
|
/// ecstore, so they are repeated here rather than imported — [`overwrite_header`]
|
||||||
/// [`overwrite_header`] asserts the header it replaces was actually present, which turns a
|
/// asserts the header it replaces was actually present, which turns a rename
|
||||||
/// rename into a loud failure instead of silently reducing an attack to a no-op.
|
/// into a loud failure instead of silently reducing an attack to a no-op.
|
||||||
const CONTENT_SHA256_HEADER: &str = "x-rustfs-content-sha256";
|
const CONTENT_SHA256_HEADER: &str = "x-rustfs-content-sha256";
|
||||||
const NONCE_HEADER: &str = "x-rustfs-rpc-nonce";
|
const NONCE_HEADER: &str = "x-rustfs-rpc-nonce";
|
||||||
const TIMESTAMP_HEADER: &str = "x-rustfs-timestamp";
|
|
||||||
const BOOT_EPOCH_CHALLENGE_HEADER: &str = "x-rustfs-rpc-boot-epoch-challenge";
|
|
||||||
|
|
||||||
/// gRPC service name carried in the signed scope, i.e. `TONIC_RPC_PREFIX`
|
/// gRPC service name carried in the signed scope, i.e. `TONIC_RPC_PREFIX`
|
||||||
/// without its leading `/`.
|
/// without its leading `/`.
|
||||||
@@ -162,28 +155,19 @@ fn align_rpc_secret_with_server() {
|
|||||||
///
|
///
|
||||||
/// Uses the no-cleanup spawn so a `pkill` pattern cannot reap servers belonging
|
/// Uses the no-cleanup spawn so a `pkill` pattern cannot reap servers belonging
|
||||||
/// to other tests running in the same binary.
|
/// to other tests running in the same binary.
|
||||||
fn server_env(extra_env: &[(&'static str, &'static str)]) -> Vec<(&'static str, &'static str)> {
|
async fn start_server(extra_env: &[(&str, &str)]) -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
|
||||||
|
let mut env = RustFSTestEnvironment::new().await?;
|
||||||
let mut child_env = vec![
|
let mut child_env = vec![
|
||||||
("RUSTFS_RPC_SECRET", TEST_RPC_SECRET),
|
("RUSTFS_RPC_SECRET", TEST_RPC_SECRET),
|
||||||
(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "false"),
|
(ENV_INTERNODE_RPC_SIGNATURE_STRICT, "false"),
|
||||||
(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "false"),
|
(ENV_INTERNODE_RPC_BODY_DIGEST_STRICT, "false"),
|
||||||
(ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT, "false"),
|
|
||||||
(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "1048576"),
|
(ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY, "1048576"),
|
||||||
];
|
];
|
||||||
child_env.extend_from_slice(extra_env);
|
child_env.extend_from_slice(extra_env);
|
||||||
child_env
|
env.start_rustfs_server_without_cleanup_with_env(&child_env).await?;
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_server_with_env(child_env: &[(&str, &str)]) -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
|
|
||||||
let mut env = RustFSTestEnvironment::new().await?;
|
|
||||||
env.start_rustfs_server_without_cleanup_with_env(child_env).await?;
|
|
||||||
Ok(env)
|
Ok(env)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn start_server(extra_env: &[(&'static str, &'static str)]) -> Result<RustFSTestEnvironment, Box<dyn Error + Send + Sync>> {
|
|
||||||
start_server_with_env(&server_env(extra_env)).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stop the child and drop the cached gRPC channel for its address.
|
/// Stop the child and drop the cached gRPC channel for its address.
|
||||||
///
|
///
|
||||||
/// `node_service_time_out_client_no_auth` memoises channels in a process-global
|
/// `node_service_time_out_client_no_auth` memoises channels in a process-global
|
||||||
@@ -254,83 +238,12 @@ fn overwrite_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
|
|||||||
/// and nothing else — no interceptor adds or rewrites auth metadata, so the
|
/// and nothing else — no interceptor adds or rewrites auth metadata, so the
|
||||||
/// bytes on the wire are the ones the test chose.
|
/// bytes on the wire are the ones the test chose.
|
||||||
async fn call_make_volume(url: &str, request: MakeVolumeRequest, headers: HeaderMap) -> Result<MakeVolumeResponse, Status> {
|
async fn call_make_volume(url: &str, request: MakeVolumeRequest, headers: HeaderMap) -> Result<MakeVolumeResponse, Status> {
|
||||||
call_make_volume_response(url, request, headers)
|
|
||||||
.await
|
|
||||||
.map(Response::into_inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn call_make_volume_response(
|
|
||||||
url: &str,
|
|
||||||
request: MakeVolumeRequest,
|
|
||||||
headers: HeaderMap,
|
|
||||||
) -> Result<Response<MakeVolumeResponse>, Status> {
|
|
||||||
let mut client = node_service_time_out_client_no_auth(&url.to_string())
|
let mut client = node_service_time_out_client_no_auth(&url.to_string())
|
||||||
.await
|
.await
|
||||||
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
||||||
let mut rpc_request = Request::new(request);
|
let mut rpc_request = Request::new(request);
|
||||||
rpc_request.metadata_mut().as_mut().extend(headers);
|
rpc_request.metadata_mut().as_mut().extend(headers);
|
||||||
client.make_volume(rpc_request).await
|
client.make_volume(rpc_request).await.map(|response| response.into_inner())
|
||||||
}
|
|
||||||
|
|
||||||
async fn call_ping_response(url: &str, headers: HeaderMap) -> Result<Response<PingResponse>, Status> {
|
|
||||||
let mut client = node_service_time_out_client_no_auth(&url.to_string())
|
|
||||||
.await
|
|
||||||
.map_err(|err| Status::unavailable(format!("cannot reach the node service: {err}")))?;
|
|
||||||
let mut rpc_request = Request::new(PingRequest {
|
|
||||||
version: 1,
|
|
||||||
body: bytes::Bytes::new(),
|
|
||||||
});
|
|
||||||
rpc_request.metadata_mut().as_mut().extend(headers);
|
|
||||||
client.ping(rpc_request).await
|
|
||||||
}
|
|
||||||
|
|
||||||
fn attach_boot_epoch_challenge(headers: &mut HeaderMap) -> Uuid {
|
|
||||||
let challenge = Uuid::new_v4();
|
|
||||||
headers.insert(
|
|
||||||
BOOT_EPOCH_CHALLENGE_HEADER,
|
|
||||||
challenge.to_string().parse().expect("UUID must be a valid header value"),
|
|
||||||
);
|
|
||||||
challenge
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mint_replay_scope_headers(audience: &str, path: &str, content_sha256: &str, boot_epoch: Uuid) -> HeaderMap {
|
|
||||||
let mut headers = mint_v2_headers(audience, "MakeVolume", Some(content_sha256));
|
|
||||||
let timestamp = headers
|
|
||||||
.get(TIMESTAMP_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.expect("v2 headers must carry a timestamp")
|
|
||||||
.to_string();
|
|
||||||
headers.extend(
|
|
||||||
gen_tonic_replay_scope_headers(audience, path, ×tamp, content_sha256, boot_epoch)
|
|
||||||
.expect("replay-scope headers must mint with the aligned RPC secret"),
|
|
||||||
);
|
|
||||||
headers
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn learn_boot_epoch_from_make_volume(url: &str, audience: &str) -> Uuid {
|
|
||||||
let request = make_volume_request("signature-e2e-epoch-bootstrap");
|
|
||||||
let mut headers = mint_v2_headers(audience, "MakeVolume", Some(&canonical_digest(&request)));
|
|
||||||
let challenge = attach_boot_epoch_challenge(&mut headers);
|
|
||||||
let response = call_make_volume_response(url, request, headers)
|
|
||||||
.await
|
|
||||||
.expect("v2 request with epoch challenge must clear default authentication");
|
|
||||||
let boot_epoch = verify_tonic_boot_epoch_response(audience, challenge, response.metadata().as_ref())
|
|
||||||
.expect("server must HMAC-authenticate the advertised boot epoch");
|
|
||||||
assert_authenticated(
|
|
||||||
Ok(response.into_inner()),
|
|
||||||
"a v2 epoch-challenge request in the default replay-scope posture",
|
|
||||||
);
|
|
||||||
boot_epoch
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn learn_boot_epoch_from_ping(url: &str, audience: &str) -> Uuid {
|
|
||||||
let mut headers = mint_v2_headers(audience, "Ping", None);
|
|
||||||
let challenge = attach_boot_epoch_challenge(&mut headers);
|
|
||||||
let response = call_ping_response(url, headers)
|
|
||||||
.await
|
|
||||||
.expect("v2 Ping with an epoch challenge must bootstrap strict replay scope");
|
|
||||||
verify_tonic_boot_epoch_response(audience, challenge, response.metadata().as_ref())
|
|
||||||
.expect("strict replay-scope Ping must return a valid boot epoch proof")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Assert a call cleared authentication.
|
/// Assert a call cleared authentication.
|
||||||
@@ -419,122 +332,6 @@ async fn internode_rpc_signature_default_posture_e2e() -> TestResult {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A replay-scoped signature is usable exactly once against the exact gRPC path and the server
|
|
||||||
/// process epoch that minted it. This crosses the child-process boundary twice: the HMAC-protected
|
|
||||||
/// epoch is learned from a real response, then the same server is restarted in place to prove its
|
|
||||||
/// replacement epoch rejects the captured request even though the nonce cache is necessarily new.
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn replay_scope_rejects_replay_path_transplant_and_stale_epoch_e2e() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
align_rpc_secret_with_server();
|
|
||||||
let child_env = server_env(&[]);
|
|
||||||
let mut env = start_server_with_env(&child_env).await?;
|
|
||||||
let url = env.url.clone();
|
|
||||||
let audience = audience_of(&env);
|
|
||||||
let boot_epoch = learn_boot_epoch_from_make_volume(&url, &audience).await;
|
|
||||||
|
|
||||||
let request = make_volume_request("replay-scope-e2e-once");
|
|
||||||
let captured = mint_replay_scope_headers(
|
|
||||||
&audience,
|
|
||||||
&format!("{TONIC_RPC_PREFIX}/MakeVolume"),
|
|
||||||
&canonical_digest(&request),
|
|
||||||
boot_epoch,
|
|
||||||
);
|
|
||||||
assert_authenticated(
|
|
||||||
call_make_volume(&url, request.clone(), captured.clone()).await,
|
|
||||||
"the first replay-scoped mutation delivery",
|
|
||||||
);
|
|
||||||
assert_rejected(
|
|
||||||
call_make_volume(&url, request.clone(), captured).await,
|
|
||||||
Code::Unauthenticated,
|
|
||||||
None,
|
|
||||||
"the same replay-scoped mutation delivered twice",
|
|
||||||
);
|
|
||||||
|
|
||||||
let transplanted =
|
|
||||||
mint_replay_scope_headers(&audience, &format!("{TONIC_RPC_PREFIX}/Ping"), &canonical_digest(&request), boot_epoch);
|
|
||||||
assert_rejected(
|
|
||||||
call_make_volume(&url, request.clone(), transplanted).await,
|
|
||||||
Code::Unauthenticated,
|
|
||||||
None,
|
|
||||||
"a replay-scoped Ping signature transplanted onto MakeVolume",
|
|
||||||
);
|
|
||||||
|
|
||||||
let stale_epoch = mint_replay_scope_headers(
|
|
||||||
&audience,
|
|
||||||
&format!("{TONIC_RPC_PREFIX}/MakeVolume"),
|
|
||||||
&canonical_digest(&request),
|
|
||||||
boot_epoch,
|
|
||||||
);
|
|
||||||
env.restart_server_preserving_data(Vec::new(), &child_env).await?;
|
|
||||||
rustfs_protos::evict_failed_connection(&url).await;
|
|
||||||
assert_rejected(
|
|
||||||
call_make_volume(&url, request.clone(), stale_epoch).await,
|
|
||||||
Code::Unauthenticated,
|
|
||||||
None,
|
|
||||||
"a replay-scoped signature captured before the receiving process restart",
|
|
||||||
);
|
|
||||||
|
|
||||||
let restarted_epoch = learn_boot_epoch_from_make_volume(&url, &audience).await;
|
|
||||||
assert_ne!(boot_epoch, restarted_epoch, "a restarted child process must advertise a new boot epoch");
|
|
||||||
let fresh_epoch = mint_replay_scope_headers(
|
|
||||||
&audience,
|
|
||||||
&format!("{TONIC_RPC_PREFIX}/MakeVolume"),
|
|
||||||
&canonical_digest(&request),
|
|
||||||
restarted_epoch,
|
|
||||||
);
|
|
||||||
assert_authenticated(
|
|
||||||
call_make_volume(&url, request, fresh_epoch).await,
|
|
||||||
"a replay-scoped mutation signed with the replacement process epoch",
|
|
||||||
);
|
|
||||||
|
|
||||||
stop_server(env, &url).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strict replay scope leaves one authenticated v2 bootstrap: `Ping` carrying a fresh challenge.
|
|
||||||
/// A mutating v2 request cannot use that lane; once the epoch proof is returned, the first v3
|
|
||||||
/// mutation succeeds. This protects a server restart without reopening a general downgrade path.
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn replay_scope_strict_requires_v3_after_ping_bootstrap_e2e() -> TestResult {
|
|
||||||
init_logging();
|
|
||||||
align_rpc_secret_with_server();
|
|
||||||
let env = start_server(&[(ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT, "true")]).await?;
|
|
||||||
let url = env.url.clone();
|
|
||||||
let audience = audience_of(&env);
|
|
||||||
|
|
||||||
let v2_request = make_volume_request("replay-scope-e2e-strict-v2");
|
|
||||||
assert_rejected(
|
|
||||||
call_make_volume(
|
|
||||||
&url,
|
|
||||||
v2_request.clone(),
|
|
||||||
mint_v2_headers(&audience, "MakeVolume", Some(&canonical_digest(&v2_request))),
|
|
||||||
)
|
|
||||||
.await,
|
|
||||||
Code::Unauthenticated,
|
|
||||||
None,
|
|
||||||
"a v2 mutation after replay-scope strictness is enabled",
|
|
||||||
);
|
|
||||||
|
|
||||||
let boot_epoch = learn_boot_epoch_from_ping(&url, &audience).await;
|
|
||||||
let request = make_volume_request("replay-scope-e2e-strict-v3");
|
|
||||||
let replay_scoped = mint_replay_scope_headers(
|
|
||||||
&audience,
|
|
||||||
&format!("{TONIC_RPC_PREFIX}/MakeVolume"),
|
|
||||||
&canonical_digest(&request),
|
|
||||||
boot_epoch,
|
|
||||||
);
|
|
||||||
assert_authenticated(
|
|
||||||
call_make_volume(&url, request, replay_scoped).await,
|
|
||||||
"a replay-scoped mutation after Ping bootstrap under strict replay scope",
|
|
||||||
);
|
|
||||||
|
|
||||||
stop_server(env, &url).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Baseline: correctly signed mutations are accepted, both with and without a
|
/// Baseline: correctly signed mutations are accepted, both with and without a
|
||||||
/// body digest.
|
/// body digest.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -21,22 +21,18 @@
|
|||||||
//! function, never as an S3 event sink.
|
//! function, never as an S3 event sink.
|
||||||
//!
|
//!
|
||||||
//! Coverage:
|
//! Coverage:
|
||||||
//! * PUT / multipart-complete / DeleteObject / DeleteObjects each deliver one event with the correct
|
//! * PUT / multipart-complete / DELETE each deliver one event with the correct
|
||||||
//! eventName, bucket, key, versionId and eTag.
|
//! eventName, bucket, key, versionId and eTag.
|
||||||
//! * prefix/suffix filters drop non-matching keys (rule-engine gate).
|
//! * prefix/suffix filters drop non-matching keys (rule-engine gate).
|
||||||
//! * an event queued while the target endpoint is unreachable is redelivered
|
//! * an event queued while the target endpoint is unreachable is redelivered
|
||||||
//! from the on-disk store once the endpoint recovers (store-and-forward).
|
//! from the on-disk store once the endpoint recovers (store-and-forward).
|
||||||
//! * responseElements and the S3 response use the canonical request ID while
|
|
||||||
//! requestParameters preserve a conflicting client-supplied value.
|
|
||||||
|
|
||||||
use crate::common::{RustFSTestEnvironment, init_logging};
|
use crate::common::{RustFSTestEnvironment, init_logging};
|
||||||
use aws_sdk_s3::Client;
|
use aws_sdk_s3::Client;
|
||||||
use aws_sdk_s3::operation::RequestId;
|
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
use aws_sdk_s3::types::{
|
use aws_sdk_s3::types::{
|
||||||
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Delete, Event, FilterRule, FilterRuleName,
|
BucketVersioningStatus, CompletedMultipartUpload, CompletedPart, Event, FilterRule, FilterRuleName,
|
||||||
NotificationConfiguration, NotificationConfigurationFilter, ObjectIdentifier, QueueConfiguration, S3KeyFilter,
|
NotificationConfiguration, NotificationConfigurationFilter, QueueConfiguration, S3KeyFilter, VersioningConfiguration,
|
||||||
VersioningConfiguration,
|
|
||||||
};
|
};
|
||||||
use http::header::{CONTENT_TYPE, HOST};
|
use http::header::{CONTENT_TYPE, HOST};
|
||||||
use local_ip_address::local_ip;
|
use local_ip_address::local_ip;
|
||||||
@@ -44,12 +40,10 @@ use reqwest::StatusCode;
|
|||||||
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
use rustfs_signer::constants::UNSIGNED_PAYLOAD;
|
||||||
use rustfs_signer::sign_v4;
|
use rustfs_signer::sign_v4;
|
||||||
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
use rustfs_utils::egress::ENV_OUTBOUND_ALLOW_ORIGINS;
|
||||||
use rustfs_utils::http::headers::{AMZ_REQUEST_ID, REQUEST_ID_HEADER};
|
|
||||||
use s3s::Body;
|
use s3s::Body;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
use std::io::Cursor;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc, Once,
|
Arc, Once,
|
||||||
@@ -69,8 +63,6 @@ type BoxError = Box<dyn Error + Send + Sync>;
|
|||||||
/// for target lookup (see `process_queue_configurations`), so the region is
|
/// for target lookup (see `process_queue_configurations`), so the region is
|
||||||
/// nominal, but it must be present for `ARN::parse` to succeed.
|
/// nominal, but it must be present for `ARN::parse` to succeed.
|
||||||
const NOTIFY_REGION: &str = "us-east-1";
|
const NOTIFY_REGION: &str = "us-east-1";
|
||||||
const CLIENT_REQUEST_ID: &str = "client-supplied-request-id";
|
|
||||||
const CLIENT_AMZ_REQUEST_ID: &str = "client-supplied-amz-request-id";
|
|
||||||
|
|
||||||
/// Webhook targets are registered as `TargetID { id: <name>, name: "webhook" }`,
|
/// Webhook targets are registered as `TargetID { id: <name>, name: "webhook" }`,
|
||||||
/// so the ARN a notification rule references is
|
/// so the ARN a notification rule references is
|
||||||
@@ -587,36 +579,6 @@ fn trimmed_etag(value: Option<&str>) -> Option<String> {
|
|||||||
value.map(|e| e.trim_matches('"').to_string())
|
value.map(|e| e.trim_matches('"').to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn assert_conflicting_request_id_correlation(record: &Value, server_request_id: &str) {
|
|
||||||
assert_eq!(
|
|
||||||
record["requestParameters"][REQUEST_ID_HEADER].as_str(),
|
|
||||||
Some(CLIENT_REQUEST_ID),
|
|
||||||
"notification request parameters should retain the actual client header: {record}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
record["requestParameters"][AMZ_REQUEST_ID].as_str(),
|
|
||||||
Some(CLIENT_AMZ_REQUEST_ID),
|
|
||||||
"notification request parameters should retain the actual client header: {record}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
record["responseElements"][AMZ_REQUEST_ID].as_str(),
|
|
||||||
Some(server_request_id),
|
|
||||||
"notification response elements should use the canonical request ID: {record}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn assert_generated_request_id_correlation(record: &Value, request_id: &str) {
|
|
||||||
assert!(
|
|
||||||
record["requestParameters"][AMZ_REQUEST_ID].is_null(),
|
|
||||||
"notification request parameters must not invent a client request header: {record}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
record["responseElements"][AMZ_REQUEST_ID].as_str(),
|
|
||||||
Some(request_id),
|
|
||||||
"notification response elements should match the S3 response request ID: {record}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -709,17 +671,8 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
|||||||
.bucket(bucket)
|
.bucket(bucket)
|
||||||
.key(put_key)
|
.key(put_key)
|
||||||
.body(ByteStream::from_static(b"peri-1 put body"))
|
.body(ByteStream::from_static(b"peri-1 put body"))
|
||||||
.customize()
|
|
||||||
.mutate_request(|request| {
|
|
||||||
request.headers_mut().insert(REQUEST_ID_HEADER, CLIENT_REQUEST_ID);
|
|
||||||
request.headers_mut().insert(AMZ_REQUEST_ID, CLIENT_AMZ_REQUEST_ID);
|
|
||||||
})
|
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
let put_request_id = put.request_id().ok_or("PUT response missing request ID")?.to_owned();
|
|
||||||
assert!(uuid::Uuid::parse_str(&put_request_id).is_ok());
|
|
||||||
assert_ne!(put_request_id, CLIENT_REQUEST_ID);
|
|
||||||
assert_ne!(put_request_id, CLIENT_AMZ_REQUEST_ID);
|
|
||||||
let put_version = put
|
let put_version = put
|
||||||
.version_id()
|
.version_id()
|
||||||
.ok_or("PUT response missing versionId (versioning not enabled?)")?;
|
.ok_or("PUT response missing versionId (versioning not enabled?)")?;
|
||||||
@@ -739,7 +692,6 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
|||||||
"record eventName: {record}"
|
"record eventName: {record}"
|
||||||
);
|
);
|
||||||
assert_eq!(record["s3"]["bucket"]["name"].as_str(), Some(bucket), "bucket in event: {record}");
|
assert_eq!(record["s3"]["bucket"]["name"].as_str(), Some(bucket), "bucket in event: {record}");
|
||||||
assert_conflicting_request_id_correlation(record, &put_request_id);
|
|
||||||
assert_eq!(object["versionId"].as_str(), Some(put_version), "versionId in event: {object}");
|
assert_eq!(object["versionId"].as_str(), Some(put_version), "versionId in event: {object}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
trimmed_etag(object["eTag"].as_str()),
|
trimmed_etag(object["eTag"].as_str()),
|
||||||
@@ -792,35 +744,6 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
|||||||
"multipart eTag in event: {mp_record}"
|
"multipart eTag in event: {mp_record}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- Snowball extract: direct notification path keeps response correlation
|
|
||||||
let snowball_key = "uploads/snowball.dat";
|
|
||||||
let snowball_body = b"snowball notification body";
|
|
||||||
let mut archive_builder = tokio_tar::Builder::new(Cursor::new(Vec::new()));
|
|
||||||
let mut archive_header = tokio_tar::Header::new_gnu();
|
|
||||||
archive_header.set_size(u64::try_from(snowball_body.len()).expect("snowball fixture length should fit in u64"));
|
|
||||||
archive_header.set_mode(0o644);
|
|
||||||
archive_header.set_cksum();
|
|
||||||
archive_builder
|
|
||||||
.append_data(&mut archive_header, snowball_key, Cursor::new(snowball_body))
|
|
||||||
.await?;
|
|
||||||
let archive = archive_builder.into_inner().await?.into_inner();
|
|
||||||
let snowball = client
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key("snowball-fixture.tar")
|
|
||||||
.body(ByteStream::from(archive))
|
|
||||||
.customize()
|
|
||||||
.mutate_request(|request| {
|
|
||||||
request.headers_mut().insert("x-amz-meta-snowball-auto-extract", "true");
|
|
||||||
})
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let snowball_request_id = snowball.request_id().ok_or("Snowball response missing request ID")?;
|
|
||||||
|
|
||||||
let snowball_event = wait_for_event(&mut rx, snowball_key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;
|
|
||||||
let snowball_record = &snowball_event["Records"][0];
|
|
||||||
assert_generated_request_id_correlation(snowball_record, snowball_request_id);
|
|
||||||
|
|
||||||
// --- Filter: non-matching prefix and suffix must never be delivered ------
|
// --- Filter: non-matching prefix and suffix must never be delivered ------
|
||||||
let wrong_prefix = "logs/report.dat"; // right suffix, wrong prefix
|
let wrong_prefix = "logs/report.dat"; // right suffix, wrong prefix
|
||||||
let wrong_suffix = "uploads/report.txt"; // right prefix, wrong suffix
|
let wrong_suffix = "uploads/report.txt"; // right prefix, wrong suffix
|
||||||
@@ -849,32 +772,7 @@ async fn test_webhook_event_delivery_and_filtering() -> TestResult {
|
|||||||
"wrong-suffix key {wrong_suffix} bypassed the filter; saw {seen:?}"
|
"wrong-suffix key {wrong_suffix} bypassed the filter; saw {seen:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
// --- DeleteObjects: direct notification path keeps response correlation --
|
// --- DELETE on a versioned bucket: ObjectRemoved:* with delete-marker version
|
||||||
let delete_many_key = "uploads/delete-many.dat";
|
|
||||||
client
|
|
||||||
.put_object()
|
|
||||||
.bucket(bucket)
|
|
||||||
.key(delete_many_key)
|
|
||||||
.body(ByteStream::from_static(b"delete objects notification body"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
wait_for_event(&mut rx, delete_many_key, "s3:ObjectCreated:", Duration::from_secs(20)).await?;
|
|
||||||
|
|
||||||
let delete_many = client
|
|
||||||
.delete_objects()
|
|
||||||
.bucket(bucket)
|
|
||||||
.delete(
|
|
||||||
Delete::builder()
|
|
||||||
.objects(ObjectIdentifier::builder().key(delete_many_key).build()?)
|
|
||||||
.build()?,
|
|
||||||
)
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
let delete_many_request_id = delete_many.request_id().ok_or("DeleteObjects response missing request ID")?;
|
|
||||||
let delete_many_event = wait_for_event(&mut rx, delete_many_key, "s3:ObjectRemoved:", Duration::from_secs(20)).await?;
|
|
||||||
assert_generated_request_id_correlation(&delete_many_event["Records"][0], delete_many_request_id);
|
|
||||||
|
|
||||||
// --- DeleteObject on a versioned bucket: delete-marker version ----------
|
|
||||||
let delete = client.delete_object().bucket(bucket).key(put_key).send().await?;
|
let delete = client.delete_object().bucket(bucket).key(put_key).send().await?;
|
||||||
let removed = wait_for_event(&mut rx, put_key, "s3:ObjectRemoved:", Duration::from_secs(20)).await?;
|
let removed = wait_for_event(&mut rx, put_key, "s3:ObjectRemoved:", Duration::from_secs(20)).await?;
|
||||||
let removed_record = &removed["Records"][0];
|
let removed_record = &removed["Records"][0];
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ use rustfs_protos::proto_gen::node_service::{BatchGenerallyLockRequest, Generall
|
|||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::storage_api::grpc_lock::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
use crate::storage_api::grpc_lock::{TonicInterceptor, node_service_time_out_client_no_auth};
|
||||||
|
|
||||||
/// gRPC lock client without authentication for testing
|
/// gRPC lock client without authentication for testing
|
||||||
/// Similar to RemoteClient but uses no_auth client
|
/// Similar to RemoteClient but uses no_auth client
|
||||||
@@ -42,7 +42,7 @@ impl GrpcLockClient {
|
|||||||
&self,
|
&self,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
rustfs_protos::proto_gen::node_service::node_service_client::NodeServiceClient<
|
rustfs_protos::proto_gen::node_service::node_service_client::NodeServiceClient<
|
||||||
tonic::service::interceptor::InterceptedService<AuthenticatedChannel, TonicInterceptor>,
|
tonic::service::interceptor::InterceptedService<tonic::transport::Channel, TonicInterceptor>,
|
||||||
>,
|
>,
|
||||||
> {
|
> {
|
||||||
node_service_time_out_client_no_auth(&self.addr)
|
node_service_time_out_client_no_auth(&self.addr)
|
||||||
|
|||||||
@@ -16,12 +16,9 @@
|
|||||||
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
pub(crate) use rustfs_ecstore::api::bucket::bucket_target_sys::BucketTargetSys;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
|
pub(crate) use rustfs_ecstore::api::disk::{VolumeInfo, WalkDirOptions};
|
||||||
pub(crate) use rustfs_ecstore::api::rpc::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::rpc::{
|
pub(crate) use rustfs_ecstore::api::rpc::{TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers};
|
||||||
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
pub(crate) use rustfs_ecstore::api::rpc::{TonicInterceptor, node_service_time_out_client_no_auth};
|
||||||
verify_tonic_boot_epoch_response,
|
|
||||||
};
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
|
pub(crate) use rustfs_ecstore::api::rpc::{gen_tonic_signature_interceptor, node_service_time_out_client};
|
||||||
|
|
||||||
@@ -33,7 +30,7 @@ pub(crate) mod node_interact {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) mod grpc_lock {
|
pub(crate) mod grpc_lock {
|
||||||
pub(crate) use super::{AuthenticatedChannel, TonicInterceptor, node_service_time_out_client_no_auth};
|
pub(crate) use super::{TonicInterceptor, node_service_time_out_client_no_auth};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signing/transport surface used by the cross-process internode RPC signature
|
/// Signing/transport surface used by the cross-process internode RPC signature
|
||||||
@@ -43,8 +40,7 @@ pub(crate) mod grpc_lock {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod internode_rpc_signature {
|
pub(crate) mod internode_rpc_signature {
|
||||||
pub(crate) use super::{
|
pub(crate) use super::{
|
||||||
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
TONIC_RPC_PREFIX, gen_signature_headers, gen_tonic_signature_headers, node_service_time_out_client_no_auth,
|
||||||
node_service_time_out_client_no_auth, verify_tonic_boot_epoch_response,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,98 +33,14 @@ workspace = true
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
rio-v2 = ["dep:rustfs-rio-v2"]
|
rio-v2 = ["dep:rustfs-rio-v2"]
|
||||||
hotpath = [
|
hotpath = ["dep:hotpath", "hotpath/hotpath", "rustfs-filemeta/hotpath", "rustfs-rio/hotpath"]
|
||||||
"hotpath/hotpath",
|
|
||||||
"hotpath/tokio",
|
|
||||||
"hotpath/futures",
|
|
||||||
"hotpath/async-channel",
|
|
||||||
"hotpath/parking_lot",
|
|
||||||
"hotpath/reqwest-0-13",
|
|
||||||
"rustfs-checksums/hotpath",
|
|
||||||
"rustfs-common/hotpath",
|
|
||||||
"rustfs-concurrency/hotpath",
|
|
||||||
"rustfs-config/hotpath",
|
|
||||||
"rustfs-credentials/hotpath",
|
|
||||||
"rustfs-data-usage/hotpath",
|
|
||||||
"rustfs-filemeta/hotpath",
|
|
||||||
"rustfs-io-metrics/hotpath",
|
|
||||||
"rustfs-lifecycle/hotpath",
|
|
||||||
"rustfs-lock/hotpath",
|
|
||||||
"rustfs-madmin/hotpath",
|
|
||||||
"rustfs-object-capacity/hotpath",
|
|
||||||
"rustfs-policy/hotpath",
|
|
||||||
"rustfs-protos/hotpath",
|
|
||||||
"rustfs-replication/hotpath",
|
|
||||||
"rustfs-rio/hotpath",
|
|
||||||
"rustfs-rio-v2?/hotpath",
|
|
||||||
"rustfs-s3-types/hotpath",
|
|
||||||
"rustfs-signer/hotpath",
|
|
||||||
"rustfs-storage-api/hotpath",
|
|
||||||
"rustfs-tls-runtime/hotpath",
|
|
||||||
"rustfs-utils/hotpath",
|
|
||||||
"rustfs-crypto/hotpath",
|
|
||||||
]
|
|
||||||
hotpath-alloc = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-alloc",
|
|
||||||
"rustfs-checksums/hotpath-alloc",
|
|
||||||
"rustfs-common/hotpath-alloc",
|
|
||||||
"rustfs-concurrency/hotpath-alloc",
|
|
||||||
"rustfs-config/hotpath-alloc",
|
|
||||||
"rustfs-credentials/hotpath-alloc",
|
|
||||||
"rustfs-data-usage/hotpath-alloc",
|
|
||||||
"rustfs-filemeta/hotpath-alloc",
|
|
||||||
"rustfs-io-metrics/hotpath-alloc",
|
|
||||||
"rustfs-lifecycle/hotpath-alloc",
|
|
||||||
"rustfs-lock/hotpath-alloc",
|
|
||||||
"rustfs-madmin/hotpath-alloc",
|
|
||||||
"rustfs-object-capacity/hotpath-alloc",
|
|
||||||
"rustfs-policy/hotpath-alloc",
|
|
||||||
"rustfs-protos/hotpath-alloc",
|
|
||||||
"rustfs-replication/hotpath-alloc",
|
|
||||||
"rustfs-rio/hotpath-alloc",
|
|
||||||
"rustfs-rio-v2?/hotpath-alloc",
|
|
||||||
"rustfs-s3-types/hotpath-alloc",
|
|
||||||
"rustfs-signer/hotpath-alloc",
|
|
||||||
"rustfs-storage-api/hotpath-alloc",
|
|
||||||
"rustfs-tls-runtime/hotpath-alloc",
|
|
||||||
"rustfs-utils/hotpath-alloc",
|
|
||||||
"rustfs-crypto/hotpath-alloc",
|
|
||||||
]
|
|
||||||
hotpath-cpu = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-cpu",
|
|
||||||
"rustfs-checksums/hotpath-cpu",
|
|
||||||
"rustfs-common/hotpath-cpu",
|
|
||||||
"rustfs-concurrency/hotpath-cpu",
|
|
||||||
"rustfs-config/hotpath-cpu",
|
|
||||||
"rustfs-credentials/hotpath-cpu",
|
|
||||||
"rustfs-data-usage/hotpath-cpu",
|
|
||||||
"rustfs-filemeta/hotpath-cpu",
|
|
||||||
"rustfs-io-metrics/hotpath-cpu",
|
|
||||||
"rustfs-lifecycle/hotpath-cpu",
|
|
||||||
"rustfs-lock/hotpath-cpu",
|
|
||||||
"rustfs-madmin/hotpath-cpu",
|
|
||||||
"rustfs-object-capacity/hotpath-cpu",
|
|
||||||
"rustfs-policy/hotpath-cpu",
|
|
||||||
"rustfs-protos/hotpath-cpu",
|
|
||||||
"rustfs-replication/hotpath-cpu",
|
|
||||||
"rustfs-rio/hotpath-cpu",
|
|
||||||
"rustfs-rio-v2?/hotpath-cpu",
|
|
||||||
"rustfs-s3-types/hotpath-cpu",
|
|
||||||
"rustfs-signer/hotpath-cpu",
|
|
||||||
"rustfs-storage-api/hotpath-cpu",
|
|
||||||
"rustfs-tls-runtime/hotpath-cpu",
|
|
||||||
"rustfs-utils/hotpath-cpu",
|
|
||||||
"rustfs-crypto/hotpath-cpu",
|
|
||||||
]
|
|
||||||
# Exposes shared lifecycle/tier test utilities (MockWarmBackend, fault
|
# Exposes shared lifecycle/tier test utilities (MockWarmBackend, fault
|
||||||
# injection, xl.meta transition assertions) via `api::tier::test_util`.
|
# injection, xl.meta transition assertions) via `api::tier::test_util`.
|
||||||
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
|
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
|
||||||
test-util = []
|
test-util = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
hotpath = { workspace = true, optional = true }
|
||||||
rustfs-filemeta.workspace = true
|
rustfs-filemeta.workspace = true
|
||||||
rustfs-utils = { workspace = true, features = ["full"] }
|
rustfs-utils = { workspace = true, features = ["full"] }
|
||||||
rustfs-rio.workspace = true
|
rustfs-rio.workspace = true
|
||||||
@@ -141,6 +57,7 @@ rustfs-policy.workspace = true
|
|||||||
rustfs-protos.workspace = true
|
rustfs-protos.workspace = true
|
||||||
rustfs-replication.workspace = true
|
rustfs-replication.workspace = true
|
||||||
rustfs-lifecycle.workspace = true
|
rustfs-lifecycle.workspace = true
|
||||||
|
rustfs-kms.workspace = true
|
||||||
rustfs-s3-types = { workspace = true }
|
rustfs-s3-types = { workspace = true }
|
||||||
rustfs-data-usage.workspace = true
|
rustfs-data-usage.workspace = true
|
||||||
rustfs-object-capacity.workspace = true
|
rustfs-object-capacity.workspace = true
|
||||||
@@ -188,7 +105,6 @@ tempfile.workspace = true
|
|||||||
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
||||||
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
hyper-util = { workspace = true, features = ["tokio", "server-auto", "server-graceful", "tracing"] }
|
||||||
hyper-rustls = { workspace = true, default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs"] }
|
hyper-rustls = { workspace = true, default-features = false, features = ["native-tokio", "http1", "tls12", "logging", "http2", "aws-lc-rs"] }
|
||||||
hostname.workspace = true
|
|
||||||
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
||||||
rustls-pki-types.workspace = true
|
rustls-pki-types.workspace = true
|
||||||
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread"] }
|
||||||
@@ -208,6 +124,8 @@ libc.workspace = true
|
|||||||
rustix = { workspace = true, features = ["process", "fs"] }
|
rustix = { workspace = true, features = ["process", "fs"] }
|
||||||
rustfs-madmin.workspace = true
|
rustfs-madmin.workspace = true
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
|
aes-gcm = { workspace = true, features = ["rand_core"] }
|
||||||
|
chacha20poly1305.workspace = true
|
||||||
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
aws-sdk-s3 = { workspace = true, default-features = false, features = ["sigv4a", "default-https-client", "rt-tokio"] }
|
||||||
urlencoding = { workspace = true }
|
urlencoding = { workspace = true }
|
||||||
smallvec = { workspace = true, features = ["serde"] }
|
smallvec = { workspace = true, features = ["serde"] }
|
||||||
@@ -235,18 +153,11 @@ metrics = { workspace = true }
|
|||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
rustfs-uring = "0.2.1"
|
rustfs-uring = "0.2.1"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
winapi-util.workspace = true
|
|
||||||
windows-sys = { workspace = true, features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util", "fs"] }
|
||||||
criterion = { workspace = true, features = ["html_reports"] }
|
criterion = { workspace = true, features = ["html_reports"] }
|
||||||
temp-env = { workspace = true, features = ["async_closure"] }
|
temp-env = { workspace = true, features = ["async_closure"] }
|
||||||
tracing-subscriber = { workspace = true, features = ["json", "env-filter", "time"] }
|
tracing-subscriber = { workspace = true, features = ["json", "env-filter", "time"] }
|
||||||
# Only for `pin_callsite_interest_for_test`, which registers a `NoSubscriber`
|
|
||||||
# dispatcher to keep tracing's process-global callsite-interest cache honest.
|
|
||||||
tracing-core = { workspace = true }
|
|
||||||
serial_test = { workspace = true }
|
serial_test = { workspace = true }
|
||||||
opentelemetry_sdk = { workspace = true, features = ["rt-tokio"] }
|
opentelemetry_sdk = { workspace = true, features = ["rt-tokio"] }
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
|||||||
@@ -67,14 +67,6 @@ pub mod bucket {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod transition_transaction {
|
|
||||||
pub use crate::bucket::lifecycle::transition_transaction::{
|
|
||||||
TransitionOperatorDeleteResult, TransitionOperatorError, TransitionOperatorProbe, TransitionOperatorStatus,
|
|
||||||
delete_transition_candidate_for_operator, finalize_missing_transition_transaction_for_operator,
|
|
||||||
inspect_transition_transaction_for_operator,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub mod evaluator {
|
pub mod evaluator {
|
||||||
pub use crate::bucket::lifecycle::evaluator::Evaluator;
|
pub use crate::bucket::lifecycle::evaluator::Evaluator;
|
||||||
}
|
}
|
||||||
@@ -130,13 +122,13 @@ pub mod bucket {
|
|||||||
|
|
||||||
pub mod metadata_sys {
|
pub mod metadata_sys {
|
||||||
pub use crate::bucket::metadata_sys::{
|
pub use crate::bucket::metadata_sys::{
|
||||||
BucketMetadataSys, acquire_bucket_metadata_transaction_lock, delete, get, get_accelerate_config, get_bucket_policy,
|
BucketMetadataSys, acquire_bucket_targets_transaction_lock, delete, get, get_accelerate_config, get_bucket_policy,
|
||||||
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
get_bucket_policy_raw, get_bucket_targets_config, get_config_from_disk, get_cors_config, get_durability_config,
|
||||||
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
get_global_bucket_metadata_sys, get_lifecycle_config, get_logging_config, get_notification_config,
|
||||||
get_object_lock_config, get_public_access_block_config, get_quota_config, get_replication_config,
|
get_object_lock_config, get_public_access_block_config, get_quota_config, get_replication_config,
|
||||||
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
get_request_payment_config, get_sse_config, get_tagging_config, get_versioning_config, get_website_config,
|
||||||
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
init_bucket_metadata_sys, list_bucket_targets, reload_bucket_metadata, remove_bucket_metadata, set_bucket_metadata,
|
||||||
update, update_bucket_targets_under_transaction_lock, update_config_with, update_under_transaction_lock,
|
update, update_bucket_targets_under_transaction_lock, update_config_with,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,18 +377,15 @@ pub mod metrics {
|
|||||||
pub mod notification {
|
pub mod notification {
|
||||||
pub use crate::services::notification_sys::{
|
pub use crate::services::notification_sys::{
|
||||||
NotificationPeerErr, NotificationSys, get_global_notification_sys, new_global_notification_sys,
|
NotificationPeerErr, NotificationSys, get_global_notification_sys, new_global_notification_sys,
|
||||||
start_remote_version_state_fleet_probe,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub mod object {
|
pub mod object {
|
||||||
pub use crate::object_api::{
|
pub use crate::object_api::{
|
||||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
|
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, GetObjectBodySource,
|
||||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, ObjectEncryptionResolver, ObjectInfo,
|
GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, StreamConsumer,
|
||||||
ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode,
|
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
|
||||||
ReadEncryptionRequest, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
|
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||||
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
|
|
||||||
unregister_object_mutation_hook,
|
|
||||||
};
|
};
|
||||||
pub use crate::store::PreparedGetObjectReader;
|
pub use crate::store::PreparedGetObjectReader;
|
||||||
}
|
}
|
||||||
@@ -418,15 +407,13 @@ pub mod rio {
|
|||||||
|
|
||||||
pub mod rpc {
|
pub mod rpc {
|
||||||
pub use crate::cluster::rpc::{
|
pub use crate::cluster::rpc::{
|
||||||
AuthenticatedChannel, LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient,
|
LocalPeerS3Client, PEER_RESTDRY_RUN, PEER_RESTSIGNAL, PEER_RESTSUB_SYS, PeerRestClient, PeerS3Client, S3PeerSys,
|
||||||
PeerS3Client, S3PeerSys, SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerBucketListing,
|
SERVICE_SIGNAL_REFRESH_CONFIG, SERVICE_SIGNAL_RELOAD_DYNAMIC, ScannerBucketListing, ScannerPeerActivity,
|
||||||
ScannerPeerActivity, TONIC_RPC_PREFIX, TonicInterceptor, gen_signature_headers, gen_tonic_replay_scope_headers,
|
TONIC_RPC_PREFIX, TonicInterceptor, gen_signature_headers, gen_tonic_signature_headers, gen_tonic_signature_interceptor,
|
||||||
gen_tonic_signature_headers, gen_tonic_signature_interceptor, node_service_time_out_client,
|
node_service_time_out_client, node_service_time_out_client_no_auth, normalize_tonic_rpc_audience,
|
||||||
node_service_time_out_client_no_auth, normalize_tonic_rpc_audience, set_tonic_canonical_body_digest,
|
set_tonic_canonical_body_digest, sign_ns_scanner_capability, sign_tonic_rpc_response_proof, verify_rpc_signature,
|
||||||
sign_ns_scanner_capability, sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers,
|
verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof,
|
||||||
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
verify_tonic_rpc_signature,
|
||||||
verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
|
|
||||||
verify_tonic_rpc_signature_with_bootstrap,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ use uuid::Uuid;
|
|||||||
use crate::bucket::lifecycle::config_boundary;
|
use crate::bucket::lifecycle::config_boundary;
|
||||||
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
use crate::bucket::lifecycle::lifecycle::TRANSITION_COMPLETE;
|
||||||
use crate::bucket::lifecycle::tier_sweeper::{
|
use crate::bucket::lifecycle::tier_sweeper::{
|
||||||
delete_confirmed_transition_candidate_exact_with_lease_idempotent,
|
|
||||||
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
delete_confirmed_transition_candidate_exact_with_manager_and_identity,
|
||||||
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
delete_object_from_remote_tier_idempotent_with_manager_and_identity,
|
||||||
};
|
};
|
||||||
@@ -617,199 +616,6 @@ pub enum TransitionTransactionRecoveryOutcome {
|
|||||||
Retained,
|
Retained,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum TransitionOperatorProbe {
|
|
||||||
Missing,
|
|
||||||
UnversionedPresent,
|
|
||||||
VersionedPresent(String),
|
|
||||||
Ambiguous,
|
|
||||||
Unsupported,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<TransitionCandidateProbe> for TransitionOperatorProbe {
|
|
||||||
fn from(value: TransitionCandidateProbe) -> Self {
|
|
||||||
match value {
|
|
||||||
TransitionCandidateProbe::Missing => Self::Missing,
|
|
||||||
TransitionCandidateProbe::UnversionedPresent => Self::UnversionedPresent,
|
|
||||||
TransitionCandidateProbe::VersionedPresent(version_id) => Self::VersionedPresent(version_id),
|
|
||||||
TransitionCandidateProbe::Ambiguous => Self::Ambiguous,
|
|
||||||
TransitionCandidateProbe::Unsupported => Self::Unsupported,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct TransitionOperatorStatus {
|
|
||||||
pub transaction_id: Uuid,
|
|
||||||
pub state: TransitionTransactionState,
|
|
||||||
pub tier_name: String,
|
|
||||||
pub remote_object: String,
|
|
||||||
pub not_after_unix_nanos: i64,
|
|
||||||
pub probe: TransitionOperatorProbe,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
||||||
pub struct TransitionOperatorDeleteResult {
|
|
||||||
pub status: TransitionOperatorStatus,
|
|
||||||
pub journal_observed_after_delete: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
|
||||||
pub enum TransitionOperatorError {
|
|
||||||
#[error("transition transaction was not found")]
|
|
||||||
NotFound,
|
|
||||||
#[error("transition transaction is still inside its active ownership window")]
|
|
||||||
NotExpired,
|
|
||||||
#[error("transition transaction state is not eligible for operator reconciliation: {0:?}")]
|
|
||||||
InvalidState(TransitionTransactionState),
|
|
||||||
#[error("an exact non-empty remote version is required")]
|
|
||||||
RemoteVersionRequired,
|
|
||||||
#[error("remote candidate is not proven missing: {0:?}")]
|
|
||||||
CandidateNotMissing(TransitionOperatorProbe),
|
|
||||||
#[error("remote candidate version does not match requested exact version: expected {expected}, observed {actual:?}")]
|
|
||||||
CandidateVersionMismatch {
|
|
||||||
expected: String,
|
|
||||||
actual: TransitionOperatorProbe,
|
|
||||||
},
|
|
||||||
#[error("transition transaction store failed: {0}")]
|
|
||||||
Store(#[source] Error),
|
|
||||||
#[error("remote tier reconciliation failed: {0}")]
|
|
||||||
Remote(#[source] std::io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
type TransitionOperatorResult<T> = std::result::Result<T, TransitionOperatorError>;
|
|
||||||
|
|
||||||
fn validate_operator_reconcile_transaction(
|
|
||||||
transaction: &TransitionTransaction,
|
|
||||||
now_unix_nanos: i128,
|
|
||||||
) -> TransitionOperatorResult<()> {
|
|
||||||
transaction
|
|
||||||
.validate()
|
|
||||||
.map_err(|err| TransitionOperatorError::Store(Error::other(err)))?;
|
|
||||||
if transaction.state != TransitionTransactionState::UploadOutcomeUnknown {
|
|
||||||
return Err(TransitionOperatorError::InvalidState(transaction.state));
|
|
||||||
}
|
|
||||||
if now_unix_nanos < i128::from(transaction.not_after_unix_nanos) {
|
|
||||||
return Err(TransitionOperatorError::NotExpired);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_operator_reconcile_transaction(
|
|
||||||
api: Arc<ECStore>,
|
|
||||||
transaction_id: Uuid,
|
|
||||||
) -> TransitionOperatorResult<TransitionTransaction> {
|
|
||||||
match load_transition_transaction_record(api, transaction_id).await {
|
|
||||||
Ok(transaction) => Ok(transaction),
|
|
||||||
Err(Error::ConfigNotFound) => Err(TransitionOperatorError::NotFound),
|
|
||||||
Err(err) => Err(TransitionOperatorError::Store(err)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn operator_probe_transition_candidate(
|
|
||||||
api: Arc<ECStore>,
|
|
||||||
transaction: &TransitionTransaction,
|
|
||||||
) -> TransitionOperatorResult<TransitionOperatorProbe> {
|
|
||||||
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(
|
|
||||||
&api.tier_config_mgr(),
|
|
||||||
&transaction.tier_name,
|
|
||||||
transaction.backend_fingerprint,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| TransitionOperatorError::Remote(std::io::Error::other(err)))?;
|
|
||||||
lease
|
|
||||||
.probe_transition_candidate_for(&transaction.remote_object, transaction.transaction_id)
|
|
||||||
.await
|
|
||||||
.map(TransitionOperatorProbe::from)
|
|
||||||
.map_err(TransitionOperatorError::Remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn inspect_transition_transaction_for_operator(
|
|
||||||
api: Arc<ECStore>,
|
|
||||||
transaction_id: Uuid,
|
|
||||||
) -> TransitionOperatorResult<TransitionOperatorStatus> {
|
|
||||||
let transaction = load_operator_reconcile_transaction(api.clone(), transaction_id).await?;
|
|
||||||
validate_operator_reconcile_transaction(&transaction, time::OffsetDateTime::now_utc().unix_timestamp_nanos())?;
|
|
||||||
let probe = operator_probe_transition_candidate(api, &transaction).await?;
|
|
||||||
Ok(TransitionOperatorStatus {
|
|
||||||
transaction_id,
|
|
||||||
state: transaction.state,
|
|
||||||
tier_name: transaction.tier_name,
|
|
||||||
remote_object: transaction.remote_object,
|
|
||||||
not_after_unix_nanos: transaction.not_after_unix_nanos,
|
|
||||||
probe,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete_transition_candidate_for_operator(
|
|
||||||
api: Arc<ECStore>,
|
|
||||||
transaction_id: Uuid,
|
|
||||||
remote_version_id: &str,
|
|
||||||
) -> TransitionOperatorResult<TransitionOperatorDeleteResult> {
|
|
||||||
if remote_version_id.is_empty() {
|
|
||||||
return Err(TransitionOperatorError::RemoteVersionRequired);
|
|
||||||
}
|
|
||||||
let transaction = load_operator_reconcile_transaction(api.clone(), transaction_id).await?;
|
|
||||||
validate_operator_reconcile_transaction(&transaction, time::OffsetDateTime::now_utc().unix_timestamp_nanos())?;
|
|
||||||
let lease = TierConfigMgr::acquire_operation_lease_for_backend_identity(
|
|
||||||
&api.tier_config_mgr(),
|
|
||||||
&transaction.tier_name,
|
|
||||||
transaction.backend_fingerprint,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|err| TransitionOperatorError::Remote(std::io::Error::other(err)))?;
|
|
||||||
lease
|
|
||||||
.validate_remote_version_id(remote_version_id)
|
|
||||||
.map_err(TransitionOperatorError::Remote)?;
|
|
||||||
let before_delete_probe = lease
|
|
||||||
.probe_transition_candidate_for(&transaction.remote_object, transaction.transaction_id)
|
|
||||||
.await
|
|
||||||
.map(TransitionOperatorProbe::from)
|
|
||||||
.map_err(TransitionOperatorError::Remote)?;
|
|
||||||
if !matches!(&before_delete_probe, TransitionOperatorProbe::VersionedPresent(version_id) if version_id == remote_version_id) {
|
|
||||||
return Err(TransitionOperatorError::CandidateVersionMismatch {
|
|
||||||
expected: remote_version_id.to_string(),
|
|
||||||
actual: before_delete_probe,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
delete_confirmed_transition_candidate_exact_with_lease_idempotent(&transaction.remote_object, remote_version_id, &lease)
|
|
||||||
.await
|
|
||||||
.map_err(TransitionOperatorError::Remote)?;
|
|
||||||
let probe = operator_probe_transition_candidate(api.clone(), &transaction).await?;
|
|
||||||
let journal_observed_after_delete = match load_transition_transaction_record(api, transaction_id).await {
|
|
||||||
Ok(_) => true,
|
|
||||||
Err(Error::ConfigNotFound) => false,
|
|
||||||
Err(err) => return Err(TransitionOperatorError::Store(err)),
|
|
||||||
};
|
|
||||||
Ok(TransitionOperatorDeleteResult {
|
|
||||||
status: TransitionOperatorStatus {
|
|
||||||
transaction_id,
|
|
||||||
state: transaction.state,
|
|
||||||
tier_name: transaction.tier_name,
|
|
||||||
remote_object: transaction.remote_object,
|
|
||||||
not_after_unix_nanos: transaction.not_after_unix_nanos,
|
|
||||||
probe,
|
|
||||||
},
|
|
||||||
journal_observed_after_delete,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn finalize_missing_transition_transaction_for_operator(
|
|
||||||
api: Arc<ECStore>,
|
|
||||||
transaction_id: Uuid,
|
|
||||||
) -> TransitionOperatorResult<()> {
|
|
||||||
let transaction = load_operator_reconcile_transaction(api.clone(), transaction_id).await?;
|
|
||||||
validate_operator_reconcile_transaction(&transaction, time::OffsetDateTime::now_utc().unix_timestamp_nanos())?;
|
|
||||||
let probe = operator_probe_transition_candidate(api.clone(), &transaction).await?;
|
|
||||||
if probe != TransitionOperatorProbe::Missing {
|
|
||||||
return Err(TransitionOperatorError::CandidateNotMissing(probe));
|
|
||||||
}
|
|
||||||
delete_transition_transaction_record(api, transaction_id)
|
|
||||||
.await
|
|
||||||
.map_err(TransitionOperatorError::Store)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn decode_transition_transaction_record(object: &str, data: &[u8]) -> Result<TransitionTransaction> {
|
pub(crate) fn decode_transition_transaction_record(object: &str, data: &[u8]) -> Result<TransitionTransaction> {
|
||||||
let transaction_id = transition_transaction_id_from_record_object_name(object)?;
|
let transaction_id = transition_transaction_id_from_record_object_name(object)?;
|
||||||
TransitionTransaction::decode(transaction_id, data)
|
TransitionTransaction::decode(transaction_id, data)
|
||||||
@@ -894,7 +700,7 @@ async fn recover_unknown_upload_outcome(
|
|||||||
.map_err(Error::other)?;
|
.map_err(Error::other)?;
|
||||||
|
|
||||||
match lease
|
match lease
|
||||||
.probe_transition_candidate_for(&transaction.remote_object, transaction.transaction_id)
|
.probe_transition_candidate(&transaction.remote_object)
|
||||||
.await
|
.await
|
||||||
.map_err(Error::other)?
|
.map_err(Error::other)?
|
||||||
{
|
{
|
||||||
@@ -1291,27 +1097,6 @@ mod tests {
|
|||||||
.expect("upload state change should succeed")
|
.expect("upload state change should succeed")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn operator_reconcile_requires_expired_unknown_upload_outcome() {
|
|
||||||
let mut transaction = new_transaction();
|
|
||||||
let active_deadline = transaction.not_after_unix_nanos;
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
validate_operator_reconcile_transaction(&transaction, i128::from(active_deadline) + 1),
|
|
||||||
Err(TransitionOperatorError::InvalidState(TransitionTransactionState::UploadStarted))
|
|
||||||
));
|
|
||||||
|
|
||||||
transaction
|
|
||||||
.advance(transaction.fence(), TransitionTransactionState::UploadOutcomeUnknown, None)
|
|
||||||
.expect("unknown upload outcome should be recorded");
|
|
||||||
assert!(matches!(
|
|
||||||
validate_operator_reconcile_transaction(&transaction, i128::from(active_deadline) - 1),
|
|
||||||
Err(TransitionOperatorError::NotExpired)
|
|
||||||
));
|
|
||||||
validate_operator_reconcile_transaction(&transaction, i128::from(active_deadline))
|
|
||||||
.expect("expired unknown upload outcome should be eligible");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cleanup_proof(transaction: &TransitionTransaction, decision: TransitionCleanupDecision) -> TransitionCleanupProof {
|
fn cleanup_proof(transaction: &TransitionTransaction, decision: TransitionCleanupDecision) -> TransitionCleanupProof {
|
||||||
TransitionCleanupProof {
|
TransitionCleanupProof {
|
||||||
transaction_id: transaction.transaction_id,
|
transaction_id: transaction.transaction_id,
|
||||||
|
|||||||
@@ -242,138 +242,73 @@ pub(crate) async fn remove_bucket_metadata_in(ctx: &crate::runtime::instance::In
|
|||||||
Ok(lock.remove(bucket).await)
|
Ok(lock.remove(bucket).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rewrite one config file of a bucket's metadata, serialized cluster-wide.
|
|
||||||
///
|
|
||||||
/// See [`acquire_bucket_metadata_transaction_lock`] for why every config
|
|
||||||
/// write — not just the replication-targets one — has to hold that lock.
|
|
||||||
pub async fn update(bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
pub async fn update(bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||||
update_with_sys(get_bucket_metadata_sys()?, bucket, config_file, data).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn delete(bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
|
||||||
delete_with_sys(get_bucket_metadata_sys()?, bucket, config_file).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`update`] against an explicitly supplied metadata system.
|
|
||||||
///
|
|
||||||
/// The free functions resolve the instance's own system; this variant takes
|
|
||||||
/// it as an argument so a test can drive two independent systems over one
|
|
||||||
/// backing store — the in-process stand-in for two nodes, which is the only
|
|
||||||
/// configuration where the transaction lock is what does the serializing.
|
|
||||||
async fn update_with_sys(
|
|
||||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
|
||||||
bucket: &str,
|
|
||||||
config_file: &str,
|
|
||||||
data: Vec<u8>,
|
|
||||||
) -> Result<OffsetDateTime> {
|
|
||||||
let (_transaction_guard, mut sys) = acquire_config_write_guards(sys, bucket).await?;
|
|
||||||
sys.update(bucket, config_file, data).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`delete`] against an explicitly supplied metadata system. See
|
|
||||||
/// [`update_with_sys`].
|
|
||||||
async fn delete_with_sys(sys: Arc<RwLock<BucketMetadataSys>>, bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
|
||||||
let (_transaction_guard, mut sys) = acquire_config_write_guards(sys, bucket).await?;
|
|
||||||
sys.delete(bucket, config_file).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take, in the one order every config write uses, the two guards a
|
|
||||||
/// read-modify-write of a bucket's metadata needs: the cluster-wide
|
|
||||||
/// transaction lock first, then this process's metadata-system write guard.
|
|
||||||
///
|
|
||||||
/// The order is load-bearing. Acquiring the process-local guard first would
|
|
||||||
/// park every local reader and writer of *every* bucket behind a lock whose
|
|
||||||
/// holder may be another node, turning remote contention into a local stall.
|
|
||||||
async fn acquire_config_write_guards(
|
|
||||||
sys: Arc<RwLock<BucketMetadataSys>>,
|
|
||||||
bucket: &str,
|
|
||||||
) -> Result<(rustfs_lock::NamespaceLockGuard, tokio::sync::OwnedRwLockWriteGuard<BucketMetadataSys>)> {
|
|
||||||
let transaction_guard = acquire_transaction_lock_with_sys(&sys, bucket).await?;
|
|
||||||
let sys_guard = sys.write_owned().await;
|
|
||||||
Ok((transaction_guard, sys_guard))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rewrite one config file while the caller already holds this bucket's
|
|
||||||
/// transaction lock.
|
|
||||||
///
|
|
||||||
/// [`update`] would deadlock here: the lock is not reentrant, so a holder
|
|
||||||
/// that called it would block until its own guard timed out.
|
|
||||||
pub async fn update_under_transaction_lock(bucket: &str, config_file: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
|
||||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||||
|
let _targets_guard = if config_file == BUCKET_TARGETS_FILE {
|
||||||
|
Some(acquire_bucket_targets_transaction_lock(bucket).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||||
|
|
||||||
bucket_meta_sys.update(bucket, config_file, data).await
|
bucket_meta_sys.update(bucket, config_file, data).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_bucket_targets_under_transaction_lock(bucket: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
pub async fn update_bucket_targets_under_transaction_lock(bucket: &str, data: Vec<u8>) -> Result<OffsetDateTime> {
|
||||||
update_under_transaction_lock(bucket, BUCKET_TARGETS_FILE, data).await
|
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||||
|
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||||
|
bucket_meta_sys.update(bucket, BUCKET_TARGETS_FILE, data).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read-modify-write one bucket config file under both guards a config
|
/// Read-modify-write one bucket config file under the metadata system's
|
||||||
/// write takes.
|
/// outer write guard.
|
||||||
///
|
///
|
||||||
/// `mutate` sees the freshly loaded on-disk metadata and returns the
|
/// `mutate` sees the freshly loaded on-disk metadata and returns the
|
||||||
/// replacement payload for `config_file` (empty clears it, like
|
/// replacement payload for `config_file` (empty clears it, like
|
||||||
/// [`delete`]). Both the read and the persisted write happen inside the
|
/// [`delete`]). Both the read and the persisted write happen inside the
|
||||||
/// same guards [`update`] takes, so the rewrite can neither clobber a
|
/// same guard that [`update`] uses, so within this process the rewrite can
|
||||||
/// concurrent update to another config file nor lose a concurrent write to
|
/// neither clobber a concurrent update to another config file nor lose a
|
||||||
/// the same one — unlike caching a mutated clone of previously read
|
/// concurrent write to the same one — unlike caching a mutated clone of
|
||||||
/// metadata.
|
/// previously read metadata.
|
||||||
///
|
///
|
||||||
/// That exclusion is cluster-wide, not merely process-local: the transaction
|
/// This guard is process-local. Writers on other nodes still race, exactly
|
||||||
/// lock is now taken for every config file rather than only the replication
|
/// as they do for [`update`]: each rewrites the whole metadata file, so the
|
||||||
/// targets one, so a writer on another node cannot land a whole-file save in
|
/// later save wins. What this narrows is the window — from "as stale as the
|
||||||
/// the middle of this read-modify-write.
|
/// local cache" down to a single metadata read plus write.
|
||||||
pub async fn update_config_with<F>(bucket: &str, config_file: &str, mutate: F) -> Result<OffsetDateTime>
|
pub async fn update_config_with<F>(bucket: &str, config_file: &str, mutate: F) -> Result<OffsetDateTime>
|
||||||
where
|
where
|
||||||
F: FnOnce(&BucketMetadata) -> Result<Vec<u8>> + Send,
|
F: FnOnce(&BucketMetadata) -> Result<Vec<u8>> + Send,
|
||||||
{
|
{
|
||||||
let (_transaction_guard, mut sys) = acquire_config_write_guards(get_bucket_metadata_sys()?, bucket).await?;
|
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||||
sys.update_config_with(bucket, config_file, mutate).await
|
let _targets_guard = if config_file == BUCKET_TARGETS_FILE {
|
||||||
|
Some(acquire_bucket_targets_transaction_lock(bucket).await?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||||
|
bucket_meta_sys.update_config_with(bucket, config_file, mutate).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Acquire a bucket's metadata transaction lock, held across a whole
|
pub async fn acquire_bucket_targets_transaction_lock(bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
|
||||||
/// read-modify-write of its metadata file.
|
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||||
///
|
let api = bucket_meta_sys_lock.read().await.object_store();
|
||||||
/// Every config write loads the entire [`BucketMetadata`] blob, replaces one
|
|
||||||
/// field, and saves the whole thing back. The namespace locks inside
|
|
||||||
/// `read_config`/`save_config` are taken and released separately, so they do
|
|
||||||
/// not span that cycle: two nodes updating *different* config files of one
|
|
||||||
/// bucket both load the same blob, each set their own field, and the later
|
|
||||||
/// save drops the other's — with both clients already told 2xx. This is not
|
|
||||||
/// last-writer-wins on one document; an orthogonal config silently vanishes.
|
|
||||||
///
|
|
||||||
/// So the lock is per bucket, not per config file: a per-file key would let
|
|
||||||
/// exactly that pair run concurrently.
|
|
||||||
///
|
|
||||||
/// Callers that hold this guard must use [`update_under_transaction_lock`]
|
|
||||||
/// rather than [`update`] — see that function.
|
|
||||||
pub async fn acquire_bucket_metadata_transaction_lock(bucket: &str) -> Result<rustfs_lock::NamespaceLockGuard> {
|
|
||||||
acquire_transaction_lock_with_sys(&get_bucket_metadata_sys()?, bucket).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn acquire_transaction_lock_with_sys(
|
|
||||||
sys: &Arc<RwLock<BucketMetadataSys>>,
|
|
||||||
bucket: &str,
|
|
||||||
) -> Result<rustfs_lock::NamespaceLockGuard> {
|
|
||||||
// Resolve the store under a short-lived read guard: this runs before the
|
|
||||||
// write guard in `acquire_config_write_guards`, and must not still hold a
|
|
||||||
// read guard when the namespace lock is awaited.
|
|
||||||
let api = sys.read().await.object_store();
|
|
||||||
let lock = api
|
let lock = api
|
||||||
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_metadata_transaction_lock_key(bucket))
|
.new_ns_lock(RUSTFS_META_BUCKET, &bucket_targets_transaction_lock_key(bucket))
|
||||||
.await?;
|
.await?;
|
||||||
Ok(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?)
|
Ok(lock.get_write_lock(crate::set_disk::get_lock_acquire_timeout()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The lock resource name is deliberately still the `bucket-targets` one it
|
fn bucket_targets_transaction_lock_key(bucket: &str) -> String {
|
||||||
/// had when only replication-target writes took it. The key is what nodes
|
|
||||||
/// agree on, so renaming it would leave a mixed-version cluster with two
|
|
||||||
/// disjoint keys — and old and new nodes would stop excluding each other on
|
|
||||||
/// the very writes that are serialized today.
|
|
||||||
fn bucket_metadata_transaction_lock_key(bucket: &str) -> String {
|
|
||||||
format!("bucket-targets/{bucket}/transaction.lock")
|
format!("bucket-targets/{bucket}/transaction.lock")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn delete(bucket: &str, config_file: &str) -> Result<OffsetDateTime> {
|
||||||
|
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||||
|
let mut bucket_meta_sys = bucket_meta_sys_lock.write().await;
|
||||||
|
|
||||||
|
bucket_meta_sys.delete(bucket, config_file).await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_bucket_policy(bucket: &str) -> Result<(BucketPolicy, OffsetDateTime)> {
|
pub async fn get_bucket_policy(bucket: &str) -> Result<(BucketPolicy, OffsetDateTime)> {
|
||||||
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
let bucket_meta_sys_lock = get_bucket_metadata_sys()?;
|
||||||
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
let bucket_meta_sys = bucket_meta_sys_lock.read().await;
|
||||||
@@ -579,26 +514,8 @@ pub struct BucketMetadataSys {
|
|||||||
/// Serializes metadata-map commits and their derived cache updates for one
|
/// Serializes metadata-map commits and their derived cache updates for one
|
||||||
/// bucket. Namespace locks, when present, are acquired before this lock.
|
/// bucket. Namespace locks, when present, are acquired before this lock.
|
||||||
metadata_publish_locks: Arc<MetadataPublishLockRegistry>,
|
metadata_publish_locks: Arc<MetadataPublishLockRegistry>,
|
||||||
/// Deduplicates concurrent lazy loads of one bucket's metadata, so N
|
|
||||||
/// simultaneous cache misses issue a single disk read instead of N.
|
|
||||||
///
|
|
||||||
/// This is the `singleflight` that upstream applies to its own lazy
|
|
||||||
/// `GetConfig`. Without it the namespace *read* lock the load holds is no
|
|
||||||
/// help: read locks are shared, so it excludes concurrent config writers
|
|
||||||
/// but not concurrent readers, and every caller still pays a full
|
|
||||||
/// erasure-set metadata fanout. A separate registry from
|
|
||||||
/// `metadata_publish_locks`, reusing the same per-bucket lock machinery.
|
|
||||||
///
|
|
||||||
/// Lock order: this lock, then the namespace lock, then the publish lock,
|
|
||||||
/// then the metadata map. It is only ever taken as the first of those, so
|
|
||||||
/// it cannot invert against a path that already holds one of the others.
|
|
||||||
lazy_load_locks: Arc<MetadataPublishLockRegistry>,
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
lazy_load_lock_probe: std::sync::atomic::AtomicBool,
|
lazy_load_lock_probe: std::sync::atomic::AtomicBool,
|
||||||
/// Counts disk loads taken by the lazy `get_config` path, so a test can
|
|
||||||
/// prove concurrent misses collapse into one.
|
|
||||||
#[cfg(test)]
|
|
||||||
lazy_disk_loads: std::sync::atomic::AtomicUsize,
|
|
||||||
/// Buckets recently observed to have no persisted metadata. Serving the
|
/// Buckets recently observed to have no persisted metadata. Serving the
|
||||||
/// fabricated default from here (instead of re-reading disk) keeps the
|
/// fabricated default from here (instead of re-reading disk) keeps the
|
||||||
/// per-request cost of repeated lookups for such names bounded — without
|
/// per-request cost of repeated lookups for such names bounded — without
|
||||||
@@ -617,13 +534,8 @@ impl BucketMetadataSys {
|
|||||||
metadata_publish_locks: Arc::new(MetadataPublishLockRegistry {
|
metadata_publish_locks: Arc::new(MetadataPublishLockRegistry {
|
||||||
locks: StdMutex::new(HashMap::new()),
|
locks: StdMutex::new(HashMap::new()),
|
||||||
}),
|
}),
|
||||||
lazy_load_locks: Arc::new(MetadataPublishLockRegistry {
|
|
||||||
locks: StdMutex::new(HashMap::new()),
|
|
||||||
}),
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
lazy_load_lock_probe: std::sync::atomic::AtomicBool::new(false),
|
lazy_load_lock_probe: std::sync::atomic::AtomicBool::new(false),
|
||||||
#[cfg(test)]
|
|
||||||
lazy_disk_loads: std::sync::atomic::AtomicUsize::new(0),
|
|
||||||
absent_metadata: moka::future::Cache::builder()
|
absent_metadata: moka::future::Cache::builder()
|
||||||
.max_capacity(ABSENT_BUCKET_METADATA_MAX_ENTRIES)
|
.max_capacity(ABSENT_BUCKET_METADATA_MAX_ENTRIES)
|
||||||
.time_to_live(ABSENT_BUCKET_METADATA_TTL)
|
.time_to_live(ABSENT_BUCKET_METADATA_TTL)
|
||||||
@@ -638,22 +550,16 @@ impl BucketMetadataSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn metadata_publish_lock(&self, bucket: &str) -> Arc<Mutex<MetadataPublishLockState>> {
|
fn metadata_publish_lock(&self, bucket: &str) -> Arc<Mutex<MetadataPublishLockState>> {
|
||||||
Self::bucket_lock_in(&self.metadata_publish_locks, bucket)
|
let mut locks = self
|
||||||
}
|
.metadata_publish_locks
|
||||||
|
.locks
|
||||||
/// Per-bucket gate for the lazy `get_config` disk load. See
|
.lock()
|
||||||
/// [`Self::lazy_load_locks`].
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
fn lazy_load_lock(&self, bucket: &str) -> Arc<Mutex<MetadataPublishLockState>> {
|
|
||||||
Self::bucket_lock_in(&self.lazy_load_locks, bucket)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bucket_lock_in(registry: &Arc<MetadataPublishLockRegistry>, bucket: &str) -> Arc<Mutex<MetadataPublishLockState>> {
|
|
||||||
let mut locks = registry.locks.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
|
||||||
locks.get(bucket).and_then(Weak::upgrade).unwrap_or_else(|| {
|
locks.get(bucket).and_then(Weak::upgrade).unwrap_or_else(|| {
|
||||||
let lock = Arc::new_cyclic(|lock| {
|
let lock = Arc::new_cyclic(|lock| {
|
||||||
Mutex::new(MetadataPublishLockState {
|
Mutex::new(MetadataPublishLockState {
|
||||||
bucket: bucket.to_string(),
|
bucket: bucket.to_string(),
|
||||||
registry: Arc::downgrade(registry),
|
registry: Arc::downgrade(&self.metadata_publish_locks),
|
||||||
lock: lock.clone(),
|
lock: lock.clone(),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -958,11 +864,11 @@ impl BucketMetadataSys {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_and_parse(&mut self, bucket: &str, config_file: &str, data: Vec<u8>, parse: bool) -> Result<OffsetDateTime> {
|
async fn update_and_parse(&mut self, bucket: &str, config_file: &str, data: Vec<u8>, parse: bool) -> Result<OffsetDateTime> {
|
||||||
// Load through this system's own store, the one `save` persists to
|
let Some(store) = runtime_sources::object_store_handle() else {
|
||||||
// (backlog#1052 S7). Reading from the ambient handle instead made the
|
return Err(Error::other("errServerNotInitialized"));
|
||||||
// read and the write of a single read-modify-write able to target
|
};
|
||||||
// different instances.
|
|
||||||
let mut bm = Self::load_bucket_metadata_for_update(self.api.clone(), bucket, parse).await?;
|
let mut bm = Self::load_bucket_metadata_for_update(store, bucket, parse).await?;
|
||||||
|
|
||||||
let updated = bm.update_config(config_file, data)?;
|
let updated = bm.update_config(config_file, data)?;
|
||||||
|
|
||||||
@@ -1108,27 +1014,6 @@ impl BucketMetadataSys {
|
|||||||
return Ok((Arc::new(bm), true));
|
return Ok((Arc::new(bm), true));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse concurrent misses for this bucket into one disk load.
|
|
||||||
// Taken before the namespace lock — see `lazy_load_locks` for the
|
|
||||||
// ordering rule.
|
|
||||||
let load_lock = self.lazy_load_lock(bucket);
|
|
||||||
let _load_guard = load_lock.lock_owned().await;
|
|
||||||
|
|
||||||
// Re-check both caches: whoever held the gate before us may have
|
|
||||||
// already answered this exact question, and repeating the fanout
|
|
||||||
// is the whole cost this gate exists to avoid.
|
|
||||||
if let Some(bm) = self.metadata_map.read().await.get(bucket).cloned() {
|
|
||||||
return Ok((bm, true));
|
|
||||||
}
|
|
||||||
if self.absent_metadata.get(bucket).await.is_some() {
|
|
||||||
let mut bm = BucketMetadata::new(bucket);
|
|
||||||
bm.default_timestamps();
|
|
||||||
return Ok((Arc::new(bm), true));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
self.lazy_disk_loads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
|
|
||||||
let lock = self.api.new_ns_lock(bucket, bucket).await?;
|
let lock = self.api.new_ns_lock(bucket, bucket).await?;
|
||||||
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1481,43 +1366,6 @@ mod tests {
|
|||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
/// Concurrent cache misses for one bucket must collapse into a single disk
|
|
||||||
/// load.
|
|
||||||
///
|
|
||||||
/// The namespace read lock the lazy path already holds does not provide
|
|
||||||
/// this: read locks are shared, so it excludes concurrent config writers
|
|
||||||
/// but not concurrent readers. Without the dedup gate every caller pays its
|
|
||||||
/// own namespace-lock acquisition plus a full erasure-set metadata fanout —
|
|
||||||
/// and the paths that reach `get_config` are per-request, so the multiplier
|
|
||||||
/// is request concurrency.
|
|
||||||
#[tokio::test]
|
|
||||||
async fn concurrent_lazy_loads_of_one_bucket_issue_a_single_disk_read() {
|
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
|
|
||||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
|
||||||
let sys = Arc::new(BucketMetadataSys::new(ecstore));
|
|
||||||
|
|
||||||
// A name with no persisted metadata: every caller misses the map, and
|
|
||||||
// the absent-cache entry does not exist until the first load records it.
|
|
||||||
let bucket = "singleflight-bucket";
|
|
||||||
let waiters = 8;
|
|
||||||
|
|
||||||
let results = futures::future::join_all((0..waiters).map(|_| {
|
|
||||||
let sys = Arc::clone(&sys);
|
|
||||||
async move { sys.get_config(bucket).await.map(|(bm, _)| bm.name.clone()) }
|
|
||||||
}))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
for result in results {
|
|
||||||
assert_eq!(result.expect("every caller must get an answer"), bucket);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
sys.lazy_disk_loads.load(Ordering::Relaxed),
|
|
||||||
1,
|
|
||||||
"concurrent misses for one bucket must share a single disk load"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pins the fail-closed caching contract of the lazy `get_config` path
|
/// Pins the fail-closed caching contract of the lazy `get_config` path
|
||||||
/// and the refresh no-replace rule: fabricated defaults are returned but
|
/// and the refresh no-replace rule: fabricated defaults are returned but
|
||||||
/// never served by the map-only `get()`, persisted metadata is cached on
|
/// never served by the map-only `get()`, persisted metadata is cached on
|
||||||
@@ -1967,160 +1815,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two metadata systems over one backing store: the in-process stand-in
|
|
||||||
/// for two nodes. They share no `RwLock`, so nothing but the transaction
|
|
||||||
/// lock can serialize them — exactly the cross-node case.
|
|
||||||
async fn two_nodes_over_one_store() -> (Vec<tempfile::TempDir>, Arc<RwLock<BucketMetadataSys>>, Arc<RwLock<BucketMetadataSys>>)
|
|
||||||
{
|
|
||||||
let (dirs, ecstore) = isolated_store_over_temp_disks().await;
|
|
||||||
let node_a = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore.clone())));
|
|
||||||
let node_b = Arc::new(RwLock::new(BucketMetadataSys::new(ecstore)));
|
|
||||||
(dirs, node_a, node_b)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Writers on different nodes updating *different* config files of one
|
|
||||||
/// bucket must both survive. Each rewrites the whole metadata blob, so
|
|
||||||
/// without a lock spanning the read-modify-write the later save carries
|
|
||||||
/// the earlier writer's field back to its pre-update value — losing an
|
|
||||||
/// orthogonal config while both clients were told the write succeeded.
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
#[serial]
|
|
||||||
async fn concurrent_config_writes_from_separate_nodes_do_not_lose_writes() {
|
|
||||||
use crate::bucket::metadata::{BUCKET_POLICY_CONFIG, BUCKET_TAGGING_CONFIG};
|
|
||||||
|
|
||||||
let (_dirs, node_a, node_b) = two_nodes_over_one_store().await;
|
|
||||||
let bucket = "cross-node-config-writes";
|
|
||||||
node_a
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.persist_and_set(BucketMetadata::new(bucket))
|
|
||||||
.await
|
|
||||||
.expect("initial metadata should persist");
|
|
||||||
|
|
||||||
// Several rounds: a single pass can serialize by luck, but a lost
|
|
||||||
// update only needs one interleaving to show up.
|
|
||||||
const ROUNDS: usize = 8;
|
|
||||||
for round in 0..ROUNDS {
|
|
||||||
let tagging = format!("<Tagging><Round>{round}</Round></Tagging>").into_bytes();
|
|
||||||
let policy = format!(r#"{{"Version":"2012-10-17","Round":{round}}}"#).into_bytes();
|
|
||||||
|
|
||||||
let start = Arc::new(tokio::sync::Barrier::new(2));
|
|
||||||
let tagging_writer = {
|
|
||||||
let (node, start, tagging) = (node_a.clone(), start.clone(), tagging.clone());
|
|
||||||
tokio::spawn(async move {
|
|
||||||
start.wait().await;
|
|
||||||
update_with_sys(node, bucket, BUCKET_TAGGING_CONFIG, tagging).await
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let policy_writer = {
|
|
||||||
let (node, start, policy) = (node_b.clone(), start.clone(), policy.clone());
|
|
||||||
tokio::spawn(async move {
|
|
||||||
start.wait().await;
|
|
||||||
update_with_sys(node, bucket, BUCKET_POLICY_CONFIG, policy).await
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
tagging_writer
|
|
||||||
.await
|
|
||||||
.expect("tagging writer should join")
|
|
||||||
.expect("tagging update should succeed");
|
|
||||||
policy_writer
|
|
||||||
.await
|
|
||||||
.expect("policy writer should join")
|
|
||||||
.expect("policy update should succeed");
|
|
||||||
|
|
||||||
// Disk truth, not either node's cache: the losing write is the one
|
|
||||||
// that never reached the metadata file.
|
|
||||||
let persisted = node_a
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get_config_from_disk(bucket)
|
|
||||||
.await
|
|
||||||
.expect("metadata should load from disk");
|
|
||||||
assert_eq!(
|
|
||||||
persisted.tagging_config_xml, tagging,
|
|
||||||
"round {round}: the policy write clobbered the concurrent tagging write"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
persisted.policy_config_json, policy,
|
|
||||||
"round {round}: the tagging write clobbered the concurrent policy write"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The guard has to cover the load as well as the save. If it were taken
|
|
||||||
/// only around the save, a second node could load between the two and
|
|
||||||
/// still overwrite with pre-update state.
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
#[serial]
|
|
||||||
async fn bucket_metadata_transaction_lock_blocks_a_concurrent_config_write() {
|
|
||||||
use crate::bucket::metadata::BUCKET_TAGGING_CONFIG;
|
|
||||||
|
|
||||||
let (_dirs, node_a, node_b) = two_nodes_over_one_store().await;
|
|
||||||
let bucket = "cross-node-transaction-lock";
|
|
||||||
node_a
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.persist_and_set(BucketMetadata::new(bucket))
|
|
||||||
.await
|
|
||||||
.expect("initial metadata should persist");
|
|
||||||
|
|
||||||
let held = acquire_transaction_lock_with_sys(&node_a, bucket)
|
|
||||||
.await
|
|
||||||
.expect("transaction lock should be acquirable");
|
|
||||||
|
|
||||||
let blocked = tokio::spawn({
|
|
||||||
let node_b = node_b.clone();
|
|
||||||
async move { update_with_sys(node_b, bucket, BUCKET_TAGGING_CONFIG, b"<Tagging/>".to_vec()).await }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Long enough for the write to have finished had it not waited: the
|
|
||||||
// whole read-modify-write against temp disks is far quicker than this.
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
||||||
assert!(
|
|
||||||
!blocked.is_finished(),
|
|
||||||
"a config write must not proceed while another node holds the bucket's transaction lock"
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(held);
|
|
||||||
|
|
||||||
let updated = timeout(Duration::from_secs(10), blocked)
|
|
||||||
.await
|
|
||||||
.expect("the blocked write should proceed once the lock is released")
|
|
||||||
.expect("blocked writer should join");
|
|
||||||
updated.expect("the write should succeed after acquiring the lock");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A holder of the transaction lock must not call the locking entry
|
|
||||||
/// point: the namespace lock is not reentrant, so it would block on
|
|
||||||
/// itself until the acquire timeout.
|
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
||||||
#[serial]
|
|
||||||
async fn transaction_lock_is_not_reentrant() {
|
|
||||||
let (_dirs, node_a, node_b) = two_nodes_over_one_store().await;
|
|
||||||
let bucket = "transaction-lock-reentrancy";
|
|
||||||
|
|
||||||
let held = acquire_transaction_lock_with_sys(&node_a, bucket)
|
|
||||||
.await
|
|
||||||
.expect("first acquisition should succeed");
|
|
||||||
|
|
||||||
// Same store, hence the same locker owner: exclusion must not depend
|
|
||||||
// on the two acquisitions coming from different owners. Either
|
|
||||||
// outcome is acceptable — still waiting, or refused — as long as no
|
|
||||||
// second guard is handed out.
|
|
||||||
let reacquired = timeout(Duration::from_millis(500), acquire_transaction_lock_with_sys(&node_b, bucket)).await;
|
|
||||||
assert!(
|
|
||||||
!matches!(reacquired, Ok(Ok(_))),
|
|
||||||
"the transaction lock must exclude a second holder even under the same owner"
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(held);
|
|
||||||
timeout(Duration::from_secs(10), acquire_transaction_lock_with_sys(&node_b, bucket))
|
|
||||||
.await
|
|
||||||
.expect("re-acquisition should not time out once released")
|
|
||||||
.expect("the lock should be acquirable after release");
|
|
||||||
}
|
|
||||||
|
|
||||||
fn target(bucket: &str, id: &str) -> BucketTarget {
|
fn target(bucket: &str, id: &str) -> BucketTarget {
|
||||||
BucketTarget {
|
BucketTarget {
|
||||||
source_bucket: bucket.to_string(),
|
source_bucket: bucket.to_string(),
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ impl ProviderVersionCapabilities {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn validate_remote_version_id(version_id: &str) -> Result<(), Error> {
|
fn validate_remote_version_id(version_id: &str) -> Result<(), Error> {
|
||||||
if version_id.is_empty() {
|
if version_id.is_empty() {
|
||||||
return Err(Error::new(
|
return Err(Error::new(
|
||||||
ErrorKind::InvalidData,
|
ErrorKind::InvalidData,
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ lazy_static! {
|
|||||||
m.insert("x-amz-replication-status".to_string(), true);
|
m.insert("x-amz-replication-status".to_string(), true);
|
||||||
m
|
m
|
||||||
};
|
};
|
||||||
|
static ref SSE_HEADERS: HashMap<String, bool> = {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert("x-amz-server-side-encryption".to_string(), true);
|
||||||
|
m.insert("x-amz-server-side-encryption-aws-kms-key-id".to_string(), true);
|
||||||
|
m.insert("x-amz-server-side-encryption-context".to_string(), true);
|
||||||
|
m.insert("x-amz-server-side-encryption-customer-algorithm".to_string(), true);
|
||||||
|
m.insert("x-amz-server-side-encryption-customer-key".to_string(), true);
|
||||||
|
m.insert("x-amz-server-side-encryption-customer-key-md5".to_string(), true);
|
||||||
|
m
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_standard_query_value(qs_key: &str) -> bool {
|
pub fn is_standard_query_value(qs_key: &str) -> bool {
|
||||||
@@ -60,12 +70,16 @@ pub fn is_standard_header(header_key: &str) -> bool {
|
|||||||
*SUPPORTED_HEADERS.get(&header_key.to_lowercase()).unwrap_or(&false)
|
*SUPPORTED_HEADERS.get(&header_key.to_lowercase()).unwrap_or(&false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_sse_header(header_key: &str) -> bool {
|
||||||
|
*SSE_HEADERS.get(&header_key.to_lowercase()).unwrap_or(&false)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_amz_header(header_key: &str) -> bool {
|
pub fn is_amz_header(header_key: &str) -> bool {
|
||||||
let key = header_key.to_lowercase();
|
let key = header_key.to_lowercase();
|
||||||
key.starts_with("x-amz-meta-")
|
key.starts_with("x-amz-meta-")
|
||||||
|| key.starts_with("x-amz-grant-")
|
|| key.starts_with("x-amz-grant-")
|
||||||
|| key == "x-amz-acl"
|
|| key == "x-amz-acl"
|
||||||
|| rustfs_utils::http::is_sse_header(header_key)
|
|| is_sse_header(header_key)
|
||||||
|| key.starts_with("x-amz-checksum-")
|
|| key.starts_with("x-amz-checksum-")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,20 +12,11 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
#[cfg(test)]
|
use crate::cluster::rpc::http_auth::RPC_CONTENT_SHA256_HEADER;
|
||||||
use crate::cluster::rpc::http_auth::RPC_REPLAY_SCOPE_VERSION_HEADER;
|
use crate::cluster::rpc::{gen_tonic_signature_headers, normalize_tonic_rpc_audience};
|
||||||
use crate::cluster::rpc::http_auth::{
|
|
||||||
RPC_AUTH_VERSION_HEADER, RPC_AUTH_VERSION_V2, RPC_BOOT_EPOCH_CHALLENGE_HEADER, RPC_BOOT_EPOCH_HEADER,
|
|
||||||
RPC_BOOT_EPOCH_PROOF_HEADER, RPC_CONTENT_SHA256_HEADER, TIMESTAMP_HEADER,
|
|
||||||
};
|
|
||||||
use crate::cluster::rpc::{
|
|
||||||
gen_tonic_replay_scope_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience, verify_tonic_boot_epoch_response,
|
|
||||||
};
|
|
||||||
#[cfg(test)]
|
|
||||||
use crate::cluster::rpc::{tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers};
|
|
||||||
use crate::disk::error::{DiskError, Error as DiskErrorType, RpcStatusError};
|
use crate::disk::error::{DiskError, Error as DiskErrorType, RpcStatusError};
|
||||||
use crate::runtime::sources as runtime_sources;
|
use crate::runtime::sources as runtime_sources;
|
||||||
use http::{Request as HttpRequest, Response as HttpResponse, Uri};
|
use http::Uri;
|
||||||
use rustfs_protos::{
|
use rustfs_protos::{
|
||||||
ChannelClass, create_new_channel, get_channel_for_class,
|
ChannelClass, create_new_channel, get_channel_for_class,
|
||||||
proto_gen::node_service::{
|
proto_gen::node_service::{
|
||||||
@@ -33,19 +24,9 @@ use rustfs_protos::{
|
|||||||
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
tier_mutation_control_service_client::TierMutationControlServiceClient,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use std::{
|
use std::{error::Error, io::ErrorKind};
|
||||||
collections::HashMap,
|
|
||||||
error::Error,
|
|
||||||
future::Future,
|
|
||||||
io::ErrorKind,
|
|
||||||
pin::Pin,
|
|
||||||
sync::{LazyLock, Mutex},
|
|
||||||
task::{Context, Poll},
|
|
||||||
};
|
|
||||||
use tonic::{service::interceptor::InterceptedService, transport::Channel};
|
use tonic::{service::interceptor::InterceptedService, transport::Channel};
|
||||||
use tower::Service;
|
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::context_propagation::{inject_request_id_into_metadata, inject_trace_context_into_metadata};
|
use super::context_propagation::{inject_request_id_into_metadata, inject_trace_context_into_metadata};
|
||||||
|
|
||||||
@@ -54,7 +35,7 @@ use super::context_propagation::{inject_request_id_into_metadata, inject_trace_c
|
|||||||
pub async fn node_service_time_out_client(
|
pub async fn node_service_time_out_client(
|
||||||
addr: &String,
|
addr: &String,
|
||||||
interceptor: TonicInterceptor,
|
interceptor: TonicInterceptor,
|
||||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>, Box<dyn Error>> {
|
||||||
// Default to the latency-sensitive control channel; bulk `bytes` RPCs opt in via the
|
// Default to the latency-sensitive control channel; bulk `bytes` RPCs opt in via the
|
||||||
// `_for_class` variant below (grpc-optimization P1).
|
// `_for_class` variant below (grpc-optimization P1).
|
||||||
node_service_time_out_client_for_class(addr, interceptor, ChannelClass::Control).await
|
node_service_time_out_client_for_class(addr, interceptor, ChannelClass::Control).await
|
||||||
@@ -63,14 +44,13 @@ pub async fn node_service_time_out_client(
|
|||||||
pub async fn heal_control_time_out_client(
|
pub async fn heal_control_time_out_client(
|
||||||
addr: &str,
|
addr: &str,
|
||||||
interceptor: TonicInterceptor,
|
interceptor: TonicInterceptor,
|
||||||
) -> Result<HealControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
) -> Result<HealControlServiceClient<InterceptedService<Channel, TonicInterceptor>>, Box<dyn Error>> {
|
||||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||||
let channel = match runtime_sources::cached_node_channel(addr).await {
|
let channel = match runtime_sources::cached_node_channel(addr).await {
|
||||||
Some(channel) => channel,
|
Some(channel) => channel,
|
||||||
None => create_new_channel(addr).await?,
|
None => create_new_channel(addr).await?,
|
||||||
};
|
};
|
||||||
let max_message_size = rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE;
|
let max_message_size = rustfs_protos::HEAL_CONTROL_RPC_MAX_MESSAGE_SIZE;
|
||||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
|
||||||
Ok(HealControlServiceClient::with_interceptor(channel, interceptor)
|
Ok(HealControlServiceClient::with_interceptor(channel, interceptor)
|
||||||
.max_decoding_message_size(max_message_size)
|
.max_decoding_message_size(max_message_size)
|
||||||
.max_encoding_message_size(max_message_size))
|
.max_encoding_message_size(max_message_size))
|
||||||
@@ -79,14 +59,13 @@ pub async fn heal_control_time_out_client(
|
|||||||
pub async fn tier_mutation_control_time_out_client(
|
pub async fn tier_mutation_control_time_out_client(
|
||||||
addr: &str,
|
addr: &str,
|
||||||
interceptor: TonicInterceptor,
|
interceptor: TonicInterceptor,
|
||||||
) -> Result<TierMutationControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
) -> Result<TierMutationControlServiceClient<InterceptedService<Channel, TonicInterceptor>>, Box<dyn Error>> {
|
||||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||||
let channel = match runtime_sources::cached_node_channel(addr).await {
|
let channel = match runtime_sources::cached_node_channel(addr).await {
|
||||||
Some(channel) => channel,
|
Some(channel) => channel,
|
||||||
None => create_new_channel(addr).await?,
|
None => create_new_channel(addr).await?,
|
||||||
};
|
};
|
||||||
let max_message_size = rustfs_protos::TIER_MUTATION_RPC_MAX_MESSAGE_SIZE;
|
let max_message_size = rustfs_protos::TIER_MUTATION_RPC_MAX_MESSAGE_SIZE;
|
||||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
|
||||||
Ok(TierMutationControlServiceClient::with_interceptor(channel, interceptor)
|
Ok(TierMutationControlServiceClient::with_interceptor(channel, interceptor)
|
||||||
.max_decoding_message_size(max_message_size)
|
.max_decoding_message_size(max_message_size)
|
||||||
.max_encoding_message_size(max_message_size))
|
.max_encoding_message_size(max_message_size))
|
||||||
@@ -102,7 +81,7 @@ pub async fn node_service_time_out_client_for_class(
|
|||||||
addr: &String,
|
addr: &String,
|
||||||
interceptor: TonicInterceptor,
|
interceptor: TonicInterceptor,
|
||||||
class: ChannelClass,
|
class: ChannelClass,
|
||||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>, Box<dyn Error>> {
|
||||||
let interceptor = interceptor.with_rpc_audience(addr)?;
|
let interceptor = interceptor.with_rpc_audience(addr)?;
|
||||||
let channel = match class {
|
let channel = match class {
|
||||||
ChannelClass::Control => match runtime_sources::cached_node_channel(addr).await {
|
ChannelClass::Control => match runtime_sources::cached_node_channel(addr).await {
|
||||||
@@ -117,7 +96,6 @@ pub async fn node_service_time_out_client_for_class(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
|
let max_message_size = rustfs_protos::internode_rpc_max_message_size();
|
||||||
let channel = ReplayScopeChannel::new(channel, interceptor.replay_scope_audience());
|
|
||||||
Ok(NodeServiceClient::with_interceptor(channel, interceptor)
|
Ok(NodeServiceClient::with_interceptor(channel, interceptor)
|
||||||
.max_decoding_message_size(max_message_size)
|
.max_decoding_message_size(max_message_size)
|
||||||
.max_encoding_message_size(max_message_size))
|
.max_encoding_message_size(max_message_size))
|
||||||
@@ -125,7 +103,7 @@ pub async fn node_service_time_out_client_for_class(
|
|||||||
|
|
||||||
pub async fn node_service_time_out_client_no_auth(
|
pub async fn node_service_time_out_client_no_auth(
|
||||||
addr: &String,
|
addr: &String,
|
||||||
) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>, Box<dyn Error>> {
|
) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>, Box<dyn Error>> {
|
||||||
node_service_time_out_client(addr, TonicInterceptor::NoOp(NoOpInterceptor)).await
|
node_service_time_out_client(addr, TonicInterceptor::NoOp(NoOpInterceptor)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,104 +199,6 @@ pub(crate) fn is_network_like_disk_error(err: &DiskErrorType) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The transport service that learns an authenticated peer boot epoch and adds the replay-scoped
|
|
||||||
/// signature only after one has been observed. The v1/v2 interceptor stays inside this wrapper so
|
|
||||||
/// old servers continue receiving precisely the metadata they understand.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct ReplayScopeChannel<S> {
|
|
||||||
inner: S,
|
|
||||||
audience: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The channel type used by internode clients after v2 authentication and replay-scope handling.
|
|
||||||
pub type AuthenticatedChannel = ReplayScopeChannel<Channel>;
|
|
||||||
|
|
||||||
static PEER_BOOT_EPOCHS: LazyLock<Mutex<HashMap<String, Uuid>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
impl<S> ReplayScopeChannel<S> {
|
|
||||||
fn new(inner: S, audience: Option<String>) -> Self {
|
|
||||||
Self { inner, audience }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cached_peer_boot_epoch(audience: &str) -> Option<Uuid> {
|
|
||||||
PEER_BOOT_EPOCHS.lock().ok().and_then(|epochs| epochs.get(audience).copied())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remember_peer_boot_epoch(audience: String, epoch: Uuid) {
|
|
||||||
if let Ok(mut epochs) = PEER_BOOT_EPOCHS.lock() {
|
|
||||||
epochs.insert(audience, epoch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<S, ReqBody, ResBody> Service<HttpRequest<ReqBody>> for ReplayScopeChannel<S>
|
|
||||||
where
|
|
||||||
S: Service<HttpRequest<ReqBody>, Response = HttpResponse<ResBody>>,
|
|
||||||
S::Error: Send + 'static,
|
|
||||||
S::Future: Send + 'static,
|
|
||||||
ReqBody: Send + 'static,
|
|
||||||
ResBody: Send + 'static,
|
|
||||||
{
|
|
||||||
type Response = HttpResponse<ResBody>;
|
|
||||||
type Error = S::Error;
|
|
||||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
|
|
||||||
|
|
||||||
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
self.inner.poll_ready(cx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call(&mut self, mut request: HttpRequest<ReqBody>) -> Self::Future {
|
|
||||||
let authenticated = self.audience.as_ref().is_some_and(|_| {
|
|
||||||
request
|
|
||||||
.headers()
|
|
||||||
.get(RPC_AUTH_VERSION_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
== Some(RPC_AUTH_VERSION_V2)
|
|
||||||
});
|
|
||||||
let challenge = authenticated.then(Uuid::new_v4);
|
|
||||||
if let (Some(audience), Some(challenge)) = (self.audience.as_deref(), challenge) {
|
|
||||||
// The challenge is independently HMAC-authenticated by the response proof. It is not
|
|
||||||
// part of v2 so old peers ignore it, while a new peer can safely advertise its epoch.
|
|
||||||
request.headers_mut().insert(
|
|
||||||
RPC_BOOT_EPOCH_CHALLENGE_HEADER,
|
|
||||||
challenge.to_string().parse().expect("UUID must be a valid header value"),
|
|
||||||
);
|
|
||||||
if let (Some(boot_epoch), Some(timestamp), Some(content_sha256)) = (
|
|
||||||
cached_peer_boot_epoch(audience),
|
|
||||||
request.headers().get(TIMESTAMP_HEADER).and_then(|value| value.to_str().ok()),
|
|
||||||
request
|
|
||||||
.headers()
|
|
||||||
.get(RPC_CONTENT_SHA256_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok()),
|
|
||||||
) {
|
|
||||||
match gen_tonic_replay_scope_headers(audience, request.uri().path(), timestamp, content_sha256, boot_epoch) {
|
|
||||||
Ok(headers) => request.headers_mut().extend(headers),
|
|
||||||
Err(error) => debug!(error = %error, "could not attach replay-scoped RPC signature"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let audience = self.audience.clone();
|
|
||||||
let future = self.inner.call(request);
|
|
||||||
Box::pin(async move {
|
|
||||||
let response = future.await?;
|
|
||||||
if let (Some(audience), Some(challenge)) = (audience, challenge) {
|
|
||||||
match verify_tonic_boot_epoch_response(&audience, challenge, response.headers()) {
|
|
||||||
Ok(epoch) => remember_peer_boot_epoch(audience, epoch),
|
|
||||||
Err(error)
|
|
||||||
if response.headers().contains_key(RPC_BOOT_EPOCH_HEADER)
|
|
||||||
|| response.headers().contains_key(RPC_BOOT_EPOCH_PROOF_HEADER) =>
|
|
||||||
{
|
|
||||||
debug!(error = %error, "peer boot epoch response proof was rejected")
|
|
||||||
}
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(response)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct TonicSignatureInterceptor {
|
pub struct TonicSignatureInterceptor {
|
||||||
audience: Option<String>,
|
audience: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -377,13 +257,6 @@ impl TonicInterceptor {
|
|||||||
}
|
}
|
||||||
Ok(self)
|
Ok(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn replay_scope_audience(&self) -> Option<String> {
|
|
||||||
match self {
|
|
||||||
Self::Signature(interceptor) => interceptor.audience.clone(),
|
|
||||||
Self::NoOp(_) => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl tonic::service::Interceptor for TonicInterceptor {
|
impl tonic::service::Interceptor for TonicInterceptor {
|
||||||
@@ -406,38 +279,6 @@ mod tests {
|
|||||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||||
use tracing_subscriber::{Registry, layer::SubscriberExt};
|
use tracing_subscriber::{Registry, layer::SubscriberExt};
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct EpochProofService {
|
|
||||||
audience: String,
|
|
||||||
seen_headers: std::sync::Arc<Mutex<Vec<http::HeaderMap>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Service<HttpRequest<()>> for EpochProofService {
|
|
||||||
type Response = HttpResponse<()>;
|
|
||||||
type Error = std::convert::Infallible;
|
|
||||||
type Future = std::future::Ready<Result<Self::Response, Self::Error>>;
|
|
||||||
|
|
||||||
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
|
||||||
Poll::Ready(Ok(()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn call(&mut self, request: HttpRequest<()>) -> Self::Future {
|
|
||||||
self.seen_headers
|
|
||||||
.lock()
|
|
||||||
.expect("test header capture lock must not be poisoned")
|
|
||||||
.push(request.headers().clone());
|
|
||||||
let challenge = tonic_boot_epoch_challenge(request.headers())
|
|
||||||
.expect("client challenge must be syntactically valid")
|
|
||||||
.expect("authenticated client request must carry a boot epoch challenge");
|
|
||||||
let mut response = HttpResponse::new(());
|
|
||||||
response.headers_mut().extend(
|
|
||||||
tonic_boot_epoch_response_headers(&self.audience, challenge)
|
|
||||||
.expect("test server must be able to sign an epoch proof"),
|
|
||||||
);
|
|
||||||
std::future::ready(Ok(response))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_test_rpc_secret() {
|
fn ensure_test_rpc_secret() {
|
||||||
runtime_sources::ensure_test_rpc_secret();
|
runtime_sources::ensure_test_rpc_secret();
|
||||||
}
|
}
|
||||||
@@ -579,52 +420,6 @@ mod tests {
|
|||||||
assert_eq!(interceptor.audience.as_deref(), Some("node-a:9000"));
|
assert_eq!(interceptor.audience.as_deref(), Some("node-a:9000"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replay_scope_channel_uses_epoch_proof_before_sending_v3() {
|
|
||||||
ensure_test_rpc_secret();
|
|
||||||
let audience = "replay-scope-client-test:9000";
|
|
||||||
PEER_BOOT_EPOCHS
|
|
||||||
.lock()
|
|
||||||
.expect("peer epoch cache lock must not be poisoned")
|
|
||||||
.remove(audience);
|
|
||||||
let seen_headers = std::sync::Arc::new(Mutex::new(Vec::new()));
|
|
||||||
let service = EpochProofService {
|
|
||||||
audience: audience.to_string(),
|
|
||||||
seen_headers: seen_headers.clone(),
|
|
||||||
};
|
|
||||||
let mut channel = ReplayScopeChannel::new(service, Some(audience.to_string()));
|
|
||||||
let make_request = || {
|
|
||||||
let mut request = HttpRequest::builder()
|
|
||||||
.uri("/node_service.NodeService/Ping")
|
|
||||||
.body(())
|
|
||||||
.expect("test RPC request must build");
|
|
||||||
request.headers_mut().extend(
|
|
||||||
gen_tonic_signature_headers(audience, "node_service.NodeService", "Ping", None)
|
|
||||||
.expect("v2 test headers must mint"),
|
|
||||||
);
|
|
||||||
request
|
|
||||||
};
|
|
||||||
|
|
||||||
futures::executor::block_on(channel.call(make_request())).expect("first request must complete");
|
|
||||||
futures::executor::block_on(channel.call(make_request())).expect("second request must complete");
|
|
||||||
|
|
||||||
let headers = seen_headers.lock().expect("test header capture lock must not be poisoned");
|
|
||||||
assert_eq!(headers.len(), 2);
|
|
||||||
assert!(headers[0].contains_key(RPC_BOOT_EPOCH_CHALLENGE_HEADER));
|
|
||||||
assert!(
|
|
||||||
!headers[0].contains_key(RPC_REPLAY_SCOPE_VERSION_HEADER),
|
|
||||||
"the first request must remain v2-compatible until the peer proves its epoch"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
headers[1].contains_key(RPC_REPLAY_SCOPE_VERSION_HEADER),
|
|
||||||
"the second request must carry the replay-scoped v3 signature"
|
|
||||||
);
|
|
||||||
PEER_BOOT_EPOCHS
|
|
||||||
.lock()
|
|
||||||
.expect("peer epoch cache lock must not be poisoned")
|
|
||||||
.remove(audience);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_signature_interceptor_requires_generated_method_metadata() {
|
fn test_signature_interceptor_requires_generated_method_metadata() {
|
||||||
ensure_test_rpc_secret();
|
ensure_test_rpc_secret();
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
//! rustfs/rustfs#4402) is anchored by the `ghsa_r5qv_*` tests in the module
|
//! rustfs/rustfs#4402) is anchored by the `ghsa_r5qv_*` tests in the module
|
||||||
//! below, plus the broader negative-signature suite. The advisory class is: a
|
//! below, plus the broader negative-signature suite. The advisory class is: a
|
||||||
//! node must never accept an RPC whose auth is missing, malformed, or signed
|
//! node must never accept an RPC whose auth is missing, malformed, or signed
|
||||||
//! with the default/empty shared secret. Body-bound v2 requests and all replay-scoped v3
|
//! with the default/empty shared secret. Body-bound v2 requests additionally
|
||||||
//! requests additionally receive process-local replay protection. See
|
//! receive process-local replay protection. See
|
||||||
//! `docs/testing/security-regressions.md` for the full advisory -> test map.
|
//! `docs/testing/security-regressions.md` for the full advisory -> test map.
|
||||||
//!
|
//!
|
||||||
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-r5qv-rc46-hv8q>
|
//! Advisory: <https://github.com/rustfs/rustfs/security/advisories/GHSA-r5qv-rc46-hv8q>
|
||||||
@@ -50,22 +50,13 @@ use uuid::Uuid;
|
|||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
|
|
||||||
const SIGNATURE_HEADER: &str = "x-rustfs-signature";
|
const SIGNATURE_HEADER: &str = "x-rustfs-signature";
|
||||||
pub(crate) const TIMESTAMP_HEADER: &str = "x-rustfs-timestamp";
|
const TIMESTAMP_HEADER: &str = "x-rustfs-timestamp";
|
||||||
pub(crate) const RPC_AUTH_VERSION_HEADER: &str = "x-rustfs-rpc-auth-version";
|
const RPC_AUTH_VERSION_HEADER: &str = "x-rustfs-rpc-auth-version";
|
||||||
const RPC_SIGNATURE_V2_HEADER: &str = "x-rustfs-rpc-signature-v2";
|
const RPC_SIGNATURE_V2_HEADER: &str = "x-rustfs-rpc-signature-v2";
|
||||||
const RPC_NONCE_HEADER: &str = "x-rustfs-rpc-nonce";
|
const RPC_NONCE_HEADER: &str = "x-rustfs-rpc-nonce";
|
||||||
pub(crate) const RPC_CONTENT_SHA256_HEADER: &str = "x-rustfs-content-sha256";
|
pub(crate) const RPC_CONTENT_SHA256_HEADER: &str = "x-rustfs-content-sha256";
|
||||||
pub(crate) const RPC_AUTH_VERSION_V2: &str = "2";
|
const RPC_AUTH_VERSION_V2: &str = "2";
|
||||||
pub const RPC_REPLAY_SCOPE_VERSION_HEADER: &str = "x-rustfs-rpc-replay-scope-version";
|
|
||||||
pub const RPC_REPLAY_SCOPE_SIGNATURE_HEADER: &str = "x-rustfs-rpc-signature-v3";
|
|
||||||
pub const RPC_REPLAY_SCOPE_NONCE_HEADER: &str = "x-rustfs-rpc-replay-nonce";
|
|
||||||
pub const RPC_BOOT_EPOCH_HEADER: &str = "x-rustfs-rpc-boot-epoch";
|
|
||||||
pub const RPC_BOOT_EPOCH_CHALLENGE_HEADER: &str = "x-rustfs-rpc-boot-epoch-challenge";
|
|
||||||
pub const RPC_BOOT_EPOCH_PROOF_HEADER: &str = "x-rustfs-rpc-boot-epoch-proof";
|
|
||||||
const RPC_REPLAY_SCOPE_VERSION_V3: &str = "3";
|
|
||||||
const RPC_RESPONSE_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-response-proof-v1\0";
|
const RPC_RESPONSE_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-response-proof-v1\0";
|
||||||
const RPC_REPLAY_SCOPE_DOMAIN: &[u8] = b"rustfs-rpc-replay-scope-v3\0";
|
|
||||||
const RPC_BOOT_EPOCH_PROOF_DOMAIN: &[u8] = b"rustfs-rpc-boot-epoch-proof-v1\0";
|
|
||||||
const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
|
||||||
const UNSIGNED_PAYLOAD_NONCE: &str = "unsigned";
|
const UNSIGNED_PAYLOAD_NONCE: &str = "unsigned";
|
||||||
const SIGNATURE_VALID_DURATION: i64 = 300; // 5 minutes
|
const SIGNATURE_VALID_DURATION: i64 = 300; // 5 minutes
|
||||||
@@ -84,15 +75,9 @@ static INTERNODE_RPC_BODY_DIGEST_STRICT: LazyLock<bool> = LazyLock::new(|| {
|
|||||||
rustfs_config::DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT,
|
rustfs_config::DEFAULT_INTERNODE_RPC_BODY_DIGEST_STRICT,
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
static INTERNODE_RPC_REPLAY_SCOPE_STRICT: LazyLock<bool> = LazyLock::new(|| {
|
// Sized for peak legitimate body-bound mutation RPS x the retention window; overflow fails closed
|
||||||
get_env_bool(
|
// and increments the replay-cache overflow counter. Clamped to at least 1 so a misconfigured zero
|
||||||
rustfs_config::ENV_INTERNODE_RPC_REPLAY_SCOPE_STRICT,
|
// cannot disable replay protection by rejecting every body-bound request.
|
||||||
rustfs_config::DEFAULT_INTERNODE_RPC_REPLAY_SCOPE_STRICT,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
// Sized for peak legitimate authenticated RPC RPS x the retention window once replay scope is
|
|
||||||
// active; overflow fails closed and increments the replay-cache overflow counter. Clamped to at
|
|
||||||
// least 1 so a misconfigured zero cannot disable replay protection by rejecting every request.
|
|
||||||
static REPLAY_CACHE_CAPACITY: LazyLock<usize> = LazyLock::new(|| {
|
static REPLAY_CACHE_CAPACITY: LazyLock<usize> = LazyLock::new(|| {
|
||||||
rustfs_utils::get_env_usize(
|
rustfs_utils::get_env_usize(
|
||||||
rustfs_config::ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY,
|
rustfs_config::ENV_INTERNODE_RPC_REPLAY_CACHE_CAPACITY,
|
||||||
@@ -101,7 +86,6 @@ static REPLAY_CACHE_CAPACITY: LazyLock<usize> = LazyLock::new(|| {
|
|||||||
.max(1)
|
.max(1)
|
||||||
});
|
});
|
||||||
static RPC_SECRET_RESOLUTION_LOG_ONCE: Once = Once::new();
|
static RPC_SECRET_RESOLUTION_LOG_ONCE: Once = Once::new();
|
||||||
static RPC_BOOT_EPOCH: LazyLock<Uuid> = LazyLock::new(Uuid::new_v4);
|
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct RpcNonceCache {
|
struct RpcNonceCache {
|
||||||
@@ -329,189 +313,6 @@ fn verify_signature_v2(secret: &str, scope: SignatureV2Scope<'_>, signature: &st
|
|||||||
mac.verify_slice(&signature).is_ok()
|
mac.verify_slice(&signature).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct ReplayScope<'a> {
|
|
||||||
audience: &'a str,
|
|
||||||
path: &'a str,
|
|
||||||
timestamp: &'a str,
|
|
||||||
nonce: Uuid,
|
|
||||||
content_sha256: &'a str,
|
|
||||||
boot_epoch: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_replay_scope(mac: &mut HmacSha256, scope: ReplayScope<'_>) {
|
|
||||||
mac.update(RPC_REPLAY_SCOPE_DOMAIN);
|
|
||||||
for part in [
|
|
||||||
scope.audience.as_bytes(),
|
|
||||||
b"|",
|
|
||||||
scope.path.as_bytes(),
|
|
||||||
b"|POST|",
|
|
||||||
scope.timestamp.as_bytes(),
|
|
||||||
b"|",
|
|
||||||
scope.nonce.as_bytes(),
|
|
||||||
b"|",
|
|
||||||
scope.content_sha256.as_bytes(),
|
|
||||||
b"|",
|
|
||||||
scope.boot_epoch.as_bytes(),
|
|
||||||
] {
|
|
||||||
mac.update(part);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_replay_scope_signature(secret: &str, scope: ReplayScope<'_>) -> std::io::Result<String> {
|
|
||||||
let mut mac =
|
|
||||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
|
||||||
update_replay_scope(&mut mac, scope);
|
|
||||||
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_replay_scope_signature(secret: &str, scope: ReplayScope<'_>, signature: &str) -> bool {
|
|
||||||
let Ok(signature) = general_purpose::STANDARD.decode(signature) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let Ok(mut mac) = <HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
update_replay_scope(&mut mac, scope);
|
|
||||||
mac.verify_slice(&signature).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update_boot_epoch_proof(mac: &mut HmacSha256, audience: &str, challenge: Uuid, boot_epoch: Uuid) {
|
|
||||||
mac.update(RPC_BOOT_EPOCH_PROOF_DOMAIN);
|
|
||||||
mac.update(audience.as_bytes());
|
|
||||||
mac.update(b"|");
|
|
||||||
mac.update(challenge.as_bytes());
|
|
||||||
mac.update(boot_epoch.as_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn generate_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot_epoch: Uuid) -> std::io::Result<String> {
|
|
||||||
if audience.is_empty() || challenge.is_nil() || boot_epoch.is_nil() {
|
|
||||||
return Err(std::io::Error::other("Invalid RPC boot epoch proof scope"));
|
|
||||||
}
|
|
||||||
let mut mac =
|
|
||||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
|
||||||
update_boot_epoch_proof(&mut mac, audience, challenge, boot_epoch);
|
|
||||||
Ok(general_purpose::STANDARD.encode(mac.finalize().into_bytes()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_boot_epoch_proof(secret: &str, audience: &str, challenge: Uuid, boot_epoch: Uuid, proof: &str) -> std::io::Result<()> {
|
|
||||||
if audience.is_empty() || challenge.is_nil() || boot_epoch.is_nil() {
|
|
||||||
return Err(std::io::Error::other("Invalid RPC boot epoch proof scope"));
|
|
||||||
}
|
|
||||||
let proof = general_purpose::STANDARD
|
|
||||||
.decode(proof)
|
|
||||||
.map_err(|_| std::io::Error::other("Invalid RPC boot epoch proof"))?;
|
|
||||||
let mut mac =
|
|
||||||
<HmacSha256 as KeyInit>::new_from_slice(secret.as_bytes()).map_err(|_| std::io::Error::other("Invalid RPC HMAC key"))?;
|
|
||||||
update_boot_epoch_proof(&mut mac, audience, challenge, boot_epoch);
|
|
||||||
mac.verify_slice(&proof)
|
|
||||||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Invalid RPC boot epoch proof"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn non_nil_uuid(value: &str, name: &str) -> std::io::Result<Uuid> {
|
|
||||||
let value = Uuid::parse_str(value).map_err(|_| std::io::Error::other(format!("Invalid {name}")))?;
|
|
||||||
(!value.is_nil())
|
|
||||||
.then_some(value)
|
|
||||||
.ok_or_else(|| std::io::Error::other(format!("Invalid {name}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_tonic_rpc_path(path: &str) -> std::io::Result<(&str, &str)> {
|
|
||||||
path.strip_prefix('/')
|
|
||||||
.and_then(|path| path.split_once('/'))
|
|
||||||
.filter(|(service, rpc_method)| !service.is_empty() && !rpc_method.is_empty() && !rpc_method.contains('/'))
|
|
||||||
.ok_or_else(|| std::io::Error::other("Invalid RPC request path"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The process-unique epoch included in every replay-scoped server verification.
|
|
||||||
///
|
|
||||||
/// A fresh process gets a fresh value, so a signature captured before a server restart cannot be
|
|
||||||
/// admitted even though the bounded in-memory nonce cache necessarily starts empty again.
|
|
||||||
pub fn tonic_rpc_boot_epoch() -> Uuid {
|
|
||||||
*RPC_BOOT_EPOCH
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the additive replay-scope headers for a request that already carries rolling-upgrade-safe
|
|
||||||
/// v1/v2 metadata. `timestamp` and `content_sha256` are deliberately reused from the v2 scope so
|
|
||||||
/// old servers can continue validating the same request unchanged.
|
|
||||||
pub fn gen_tonic_replay_scope_headers(
|
|
||||||
audience: &str,
|
|
||||||
path: &str,
|
|
||||||
timestamp: &str,
|
|
||||||
content_sha256: &str,
|
|
||||||
boot_epoch: Uuid,
|
|
||||||
) -> std::io::Result<HeaderMap> {
|
|
||||||
if audience.is_empty() || !path.starts_with('/') || !valid_content_sha256(content_sha256) || boot_epoch.is_nil() {
|
|
||||||
return Err(std::io::Error::other("Invalid replay-scoped RPC signing scope"));
|
|
||||||
}
|
|
||||||
parse_tonic_rpc_path(path)?;
|
|
||||||
timestamp
|
|
||||||
.parse::<i64>()
|
|
||||||
.map_err(|_| std::io::Error::other("Invalid timestamp format"))?;
|
|
||||||
|
|
||||||
let nonce = Uuid::new_v4();
|
|
||||||
let signature = generate_replay_scope_signature(
|
|
||||||
&get_shared_secret()?,
|
|
||||||
ReplayScope {
|
|
||||||
audience,
|
|
||||||
path,
|
|
||||||
timestamp,
|
|
||||||
nonce,
|
|
||||||
content_sha256,
|
|
||||||
boot_epoch,
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(RPC_REPLAY_SCOPE_VERSION_HEADER, HeaderValue::from_static(RPC_REPLAY_SCOPE_VERSION_V3));
|
|
||||||
headers.insert(
|
|
||||||
RPC_REPLAY_SCOPE_SIGNATURE_HEADER,
|
|
||||||
header_value(&signature, RPC_REPLAY_SCOPE_SIGNATURE_HEADER)?,
|
|
||||||
);
|
|
||||||
headers.insert(
|
|
||||||
RPC_REPLAY_SCOPE_NONCE_HEADER,
|
|
||||||
header_value(&nonce.to_string(), RPC_REPLAY_SCOPE_NONCE_HEADER)?,
|
|
||||||
);
|
|
||||||
headers.insert(RPC_BOOT_EPOCH_HEADER, header_value(&boot_epoch.to_string(), RPC_BOOT_EPOCH_HEADER)?);
|
|
||||||
Ok(headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the optional client challenge used to authenticate a server boot-epoch advertisement.
|
|
||||||
pub fn tonic_boot_epoch_challenge(headers: &HeaderMap) -> std::io::Result<Option<Uuid>> {
|
|
||||||
headers
|
|
||||||
.get(RPC_BOOT_EPOCH_CHALLENGE_HEADER)
|
|
||||||
.map(|value| {
|
|
||||||
value
|
|
||||||
.to_str()
|
|
||||||
.map_err(|_| std::io::Error::other("Invalid RPC boot epoch challenge"))
|
|
||||||
.and_then(|value| non_nil_uuid(value, "RPC boot epoch challenge"))
|
|
||||||
})
|
|
||||||
.transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the authenticated response headers for a client boot-epoch challenge.
|
|
||||||
pub fn tonic_boot_epoch_response_headers(audience: &str, challenge: Uuid) -> std::io::Result<HeaderMap> {
|
|
||||||
let boot_epoch = tonic_rpc_boot_epoch();
|
|
||||||
let proof = generate_boot_epoch_proof(&get_shared_secret()?, audience, challenge, boot_epoch)?;
|
|
||||||
let mut headers = HeaderMap::new();
|
|
||||||
headers.insert(RPC_BOOT_EPOCH_HEADER, header_value(&boot_epoch.to_string(), RPC_BOOT_EPOCH_HEADER)?);
|
|
||||||
headers.insert(RPC_BOOT_EPOCH_PROOF_HEADER, header_value(&proof, RPC_BOOT_EPOCH_PROOF_HEADER)?);
|
|
||||||
Ok(headers)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify the server boot-epoch response for a challenge generated by this client.
|
|
||||||
pub fn verify_tonic_boot_epoch_response(audience: &str, challenge: Uuid, headers: &HeaderMap) -> std::io::Result<Uuid> {
|
|
||||||
let boot_epoch = headers
|
|
||||||
.get(RPC_BOOT_EPOCH_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| std::io::Error::other("Missing RPC boot epoch"))
|
|
||||||
.and_then(|value| non_nil_uuid(value, "RPC boot epoch"))?;
|
|
||||||
let proof = headers
|
|
||||||
.get(RPC_BOOT_EPOCH_PROOF_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| std::io::Error::other("Missing RPC boot epoch proof"))?;
|
|
||||||
verify_boot_epoch_proof(&get_shared_secret()?, audience, challenge, boot_epoch, proof)?;
|
|
||||||
Ok(boot_epoch)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_content_sha256(value: &str) -> bool {
|
fn valid_content_sha256(value: &str) -> bool {
|
||||||
value == UNSIGNED_PAYLOAD
|
value == UNSIGNED_PAYLOAD
|
||||||
|| (value.len() == 64
|
|| (value.len() == 64
|
||||||
@@ -730,17 +531,6 @@ fn has_v2_auth_headers(headers: &HeaderMap) -> bool {
|
|||||||
.any(|name| headers.contains_key(*name))
|
.any(|name| headers.contains_key(*name))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_replay_scope_headers(headers: &HeaderMap) -> bool {
|
|
||||||
[
|
|
||||||
RPC_REPLAY_SCOPE_VERSION_HEADER,
|
|
||||||
RPC_REPLAY_SCOPE_SIGNATURE_HEADER,
|
|
||||||
RPC_REPLAY_SCOPE_NONCE_HEADER,
|
|
||||||
RPC_BOOT_EPOCH_HEADER,
|
|
||||||
]
|
|
||||||
.iter()
|
|
||||||
.any(|name| headers.contains_key(*name))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the server requires target-bound v2 authentication on every internode gRPC request,
|
/// Whether the server requires target-bound v2 authentication on every internode gRPC request,
|
||||||
/// rejecting the legacy constant-target fallback instead of accepting it. Default-off rollout
|
/// rejecting the legacy constant-target fallback instead of accepting it. Default-off rollout
|
||||||
/// lever gated on the v1-fallback counter reading zero fleet-wide; see
|
/// lever gated on the v1-fallback counter reading zero fleet-wide; see
|
||||||
@@ -750,127 +540,9 @@ fn internode_rpc_signature_strict() -> bool {
|
|||||||
*INTERNODE_RPC_SIGNATURE_STRICT
|
*INTERNODE_RPC_SIGNATURE_STRICT
|
||||||
}
|
}
|
||||||
|
|
||||||
fn internode_rpc_replay_scope_strict() -> bool {
|
|
||||||
*INTERNODE_RPC_REPLAY_SCOPE_STRICT
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tonic_replay_scope_signature(audience: &str, path: &str, headers: &HeaderMap) -> std::io::Result<()> {
|
|
||||||
if audience.is_empty() {
|
|
||||||
return Err(std::io::Error::other("Missing RPC audience"));
|
|
||||||
}
|
|
||||||
parse_tonic_rpc_path(path)?;
|
|
||||||
|
|
||||||
let version = headers
|
|
||||||
.get(RPC_REPLAY_SCOPE_VERSION_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| std::io::Error::other("Missing RPC replay scope version"))?;
|
|
||||||
if version != RPC_REPLAY_SCOPE_VERSION_V3 {
|
|
||||||
return Err(std::io::Error::other("Unsupported RPC replay scope version"));
|
|
||||||
}
|
|
||||||
let signature = headers
|
|
||||||
.get(RPC_REPLAY_SCOPE_SIGNATURE_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| std::io::Error::other("Missing RPC replay scope signature"))?;
|
|
||||||
let timestamp = headers
|
|
||||||
.get(TIMESTAMP_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| std::io::Error::other("Missing timestamp header"))?;
|
|
||||||
let signed_at = timestamp
|
|
||||||
.parse::<i64>()
|
|
||||||
.map_err(|_| std::io::Error::other("Invalid timestamp format"))?;
|
|
||||||
check_timestamp(signed_at)?;
|
|
||||||
let nonce = headers
|
|
||||||
.get(RPC_REPLAY_SCOPE_NONCE_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| std::io::Error::other("Missing RPC replay scope nonce"))
|
|
||||||
.and_then(|value| non_nil_uuid(value, "RPC replay scope nonce"))?;
|
|
||||||
let content_sha256 = headers
|
|
||||||
.get(RPC_CONTENT_SHA256_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| std::io::Error::other("Missing RPC content SHA-256"))?;
|
|
||||||
if !valid_content_sha256(content_sha256) {
|
|
||||||
return Err(std::io::Error::other("Invalid RPC content SHA-256"));
|
|
||||||
}
|
|
||||||
let boot_epoch = headers
|
|
||||||
.get(RPC_BOOT_EPOCH_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.ok_or_else(|| std::io::Error::other("Missing RPC boot epoch"))
|
|
||||||
.and_then(|value| non_nil_uuid(value, "RPC boot epoch"))?;
|
|
||||||
let secret = get_shared_secret()?;
|
|
||||||
if !verify_replay_scope_signature(
|
|
||||||
&secret,
|
|
||||||
ReplayScope {
|
|
||||||
audience,
|
|
||||||
path,
|
|
||||||
timestamp,
|
|
||||||
nonce,
|
|
||||||
content_sha256,
|
|
||||||
boot_epoch,
|
|
||||||
},
|
|
||||||
signature,
|
|
||||||
) {
|
|
||||||
return Err(std::io::Error::other("Invalid RPC replay scope signature"));
|
|
||||||
}
|
|
||||||
if boot_epoch != tonic_rpc_boot_epoch() {
|
|
||||||
return Err(std::io::Error::other("RPC boot epoch is stale"));
|
|
||||||
}
|
|
||||||
check_and_record_nonce(nonce, signed_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify gRPC authentication, preferring v2 without downgrade on malformed v2 metadata.
|
/// Verify gRPC authentication, preferring v2 without downgrade on malformed v2 metadata.
|
||||||
pub fn verify_tonic_rpc_signature(audience: &str, path: &str, headers: &HeaderMap) -> std::io::Result<()> {
|
pub fn verify_tonic_rpc_signature(audience: &str, path: &str, headers: &HeaderMap) -> std::io::Result<()> {
|
||||||
verify_tonic_rpc_signature_with_policy(
|
verify_tonic_rpc_signature_with_strictness(audience, path, headers, internode_rpc_signature_strict())
|
||||||
audience,
|
|
||||||
path,
|
|
||||||
headers,
|
|
||||||
internode_rpc_signature_strict(),
|
|
||||||
internode_rpc_replay_scope_strict(),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verify gRPC authentication while allowing the narrowly scoped v2 `Ping` bootstrap used to
|
|
||||||
/// obtain an authenticated server boot epoch when replay-scope strictness is enabled.
|
|
||||||
pub fn verify_tonic_rpc_signature_with_bootstrap(
|
|
||||||
audience: &str,
|
|
||||||
path: &str,
|
|
||||||
headers: &HeaderMap,
|
|
||||||
allow_replay_scope_bootstrap: bool,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
verify_tonic_rpc_signature_with_policy(
|
|
||||||
audience,
|
|
||||||
path,
|
|
||||||
headers,
|
|
||||||
internode_rpc_signature_strict(),
|
|
||||||
internode_rpc_replay_scope_strict(),
|
|
||||||
allow_replay_scope_bootstrap,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn verify_tonic_rpc_signature_with_policy(
|
|
||||||
audience: &str,
|
|
||||||
path: &str,
|
|
||||||
headers: &HeaderMap,
|
|
||||||
signature_strict: bool,
|
|
||||||
replay_scope_strict: bool,
|
|
||||||
allow_replay_scope_bootstrap: bool,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
if has_replay_scope_headers(headers) {
|
|
||||||
return verify_tonic_replay_scope_signature(audience, path, headers);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only a method-bound v2 Ping with a syntactically valid challenge may bootstrap a strict
|
|
||||||
// client after its peer restarts. Legacy metadata never gets this exception.
|
|
||||||
let bootstrap = allow_replay_scope_bootstrap
|
|
||||||
&& has_v2_auth_headers(headers)
|
|
||||||
&& tonic_boot_epoch_challenge(headers).is_ok_and(|challenge| challenge.is_some());
|
|
||||||
if replay_scope_strict && !bootstrap {
|
|
||||||
return Err(std::io::Error::other("RPC replay-scoped authentication required"));
|
|
||||||
}
|
|
||||||
|
|
||||||
verify_tonic_rpc_signature_with_strictness(audience, path, headers, signature_strict)?;
|
|
||||||
global_internode_metrics().record_replay_scope_fallback();
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`verify_tonic_rpc_signature`] with the strict gate injected as a parameter, so both rollout
|
/// [`verify_tonic_rpc_signature`] with the strict gate injected as a parameter, so both rollout
|
||||||
@@ -1601,104 +1273,6 @@ mod tests {
|
|||||||
assert_eq!(error.to_string(), "Invalid RPC v2 signature");
|
assert_eq!(error.to_string(), "Invalid RPC v2 signature");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replay_scope_binds_path_epoch_and_random_nonce() {
|
|
||||||
ensure_test_rpc_secret();
|
|
||||||
let path = "/node_service.NodeService/Ping";
|
|
||||||
let mut headers = gen_tonic_signature_headers("node-a:9000", "node_service.NodeService", "Ping", None)
|
|
||||||
.expect("v2 compatibility headers should build");
|
|
||||||
let timestamp = headers
|
|
||||||
.get(TIMESTAMP_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.expect("v2 timestamp")
|
|
||||||
.to_string();
|
|
||||||
let content_sha256 = headers
|
|
||||||
.get(RPC_CONTENT_SHA256_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.expect("v2 content digest")
|
|
||||||
.to_string();
|
|
||||||
headers.extend(
|
|
||||||
gen_tonic_replay_scope_headers("node-a:9000", path, ×tamp, &content_sha256, tonic_rpc_boot_epoch())
|
|
||||||
.expect("replay-scope headers should build"),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
verify_tonic_rpc_signature_with_policy("node-a:9000", path, &headers, false, false, false).is_ok(),
|
|
||||||
"the first replay-scoped request must be accepted"
|
|
||||||
);
|
|
||||||
let replay = verify_tonic_rpc_signature_with_policy("node-a:9000", path, &headers, false, false, false)
|
|
||||||
.expect_err("the random replay-scope nonce must be single-use");
|
|
||||||
assert_eq!(replay.to_string(), "RPC request replay detected");
|
|
||||||
|
|
||||||
let path_error = verify_tonic_replay_scope_signature("node-a:9000", "/node_service.NodeService/SignalService", &headers)
|
|
||||||
.expect_err("a replay-scoped signature must not move to another method");
|
|
||||||
assert_eq!(path_error.to_string(), "Invalid RPC replay scope signature");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replay_scope_rejects_partial_metadata_and_stale_epoch_without_fallback() {
|
|
||||||
ensure_test_rpc_secret();
|
|
||||||
let path = "/node_service.NodeService/Ping";
|
|
||||||
let mut partial = gen_tonic_signature_headers("node-a:9000", "node_service.NodeService", "Ping", None)
|
|
||||||
.expect("v2 compatibility headers should build");
|
|
||||||
partial.insert(RPC_REPLAY_SCOPE_VERSION_HEADER, HeaderValue::from_static(RPC_REPLAY_SCOPE_VERSION_V3));
|
|
||||||
let error = verify_tonic_rpc_signature_with_policy("node-a:9000", path, &partial, false, false, false)
|
|
||||||
.expect_err("partial replay-scope metadata must never downgrade to v2");
|
|
||||||
assert_eq!(error.to_string(), "Missing RPC replay scope signature");
|
|
||||||
|
|
||||||
let timestamp = partial
|
|
||||||
.get(TIMESTAMP_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.expect("v2 timestamp")
|
|
||||||
.to_string();
|
|
||||||
let content_sha256 = partial
|
|
||||||
.get(RPC_CONTENT_SHA256_HEADER)
|
|
||||||
.and_then(|value| value.to_str().ok())
|
|
||||||
.expect("v2 content digest")
|
|
||||||
.to_string();
|
|
||||||
let stale_epoch = Uuid::new_v4();
|
|
||||||
partial.extend(
|
|
||||||
gen_tonic_replay_scope_headers("node-a:9000", path, ×tamp, &content_sha256, stale_epoch)
|
|
||||||
.expect("replay-scope headers should build"),
|
|
||||||
);
|
|
||||||
let stale = verify_tonic_rpc_signature_with_policy("node-a:9000", path, &partial, false, false, false)
|
|
||||||
.expect_err("a signature from a prior server boot epoch must be rejected");
|
|
||||||
assert_eq!(stale.to_string(), "RPC boot epoch is stale");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn replay_scope_strictness_allows_only_authenticated_ping_bootstrap() {
|
|
||||||
ensure_test_rpc_secret();
|
|
||||||
let mut headers = gen_tonic_signature_headers("node-a:9000", "node_service.NodeService", "Ping", None)
|
|
||||||
.expect("v2 compatibility headers should build");
|
|
||||||
let rejected =
|
|
||||||
verify_tonic_rpc_signature_with_policy("node-a:9000", "/node_service.NodeService/Ping", &headers, false, true, false)
|
|
||||||
.expect_err("strict replay scope must reject stripped new metadata");
|
|
||||||
assert_eq!(rejected.to_string(), "RPC replay-scoped authentication required");
|
|
||||||
|
|
||||||
headers.insert(
|
|
||||||
RPC_BOOT_EPOCH_CHALLENGE_HEADER,
|
|
||||||
HeaderValue::from_str(&Uuid::new_v4().to_string()).expect("UUID header"),
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
verify_tonic_rpc_signature_with_policy("node-a:9000", "/node_service.NodeService/Ping", &headers, false, true, true,)
|
|
||||||
.is_ok(),
|
|
||||||
"only the signed Ping bootstrap may obtain a new server epoch in strict mode"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn boot_epoch_response_proof_binds_audience_challenge_and_epoch() {
|
|
||||||
ensure_test_rpc_secret();
|
|
||||||
let challenge = Uuid::new_v4();
|
|
||||||
let headers = tonic_boot_epoch_response_headers("node-a:9000", challenge).expect("proof headers should build");
|
|
||||||
let epoch =
|
|
||||||
verify_tonic_boot_epoch_response("node-a:9000", challenge, &headers).expect("matching proof headers should verify");
|
|
||||||
assert_eq!(epoch, tonic_rpc_boot_epoch());
|
|
||||||
assert!(verify_tonic_boot_epoch_response("node-b:9000", challenge, &headers).is_err());
|
|
||||||
assert!(verify_tonic_boot_epoch_response("node-a:9000", Uuid::new_v4(), &headers).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn malformed_v2_auth_does_not_downgrade_to_valid_legacy_signature() {
|
fn malformed_v2_auth_does_not_downgrade_to_valid_legacy_signature() {
|
||||||
ensure_test_rpc_secret();
|
ensure_test_rpc_secret();
|
||||||
|
|||||||
@@ -26,18 +26,13 @@ pub(crate) mod runtime_sources;
|
|||||||
pub use background_monitor::shutdown_background_monitors;
|
pub use background_monitor::shutdown_background_monitors;
|
||||||
pub(crate) use background_monitor::spawn_background_monitor;
|
pub(crate) use background_monitor::spawn_background_monitor;
|
||||||
pub use client::{
|
pub use client::{
|
||||||
AuthenticatedChannel, TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client,
|
TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client, node_service_time_out_client_no_auth,
|
||||||
node_service_time_out_client_no_auth,
|
|
||||||
};
|
};
|
||||||
// Re-exported through `api::rpc`; not every item is consumed inside this crate.
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
pub use http_auth::{
|
pub use http_auth::{
|
||||||
TONIC_RPC_PREFIX, build_auth_headers, gen_signature_headers, gen_tonic_replay_scope_headers, gen_tonic_signature_headers,
|
TONIC_RPC_PREFIX, build_auth_headers, gen_signature_headers, gen_tonic_signature_headers, normalize_tonic_rpc_audience,
|
||||||
normalize_tonic_rpc_audience, set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, sign_ns_scanner_capability,
|
set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, sign_ns_scanner_capability, sign_tonic_rpc_response_proof,
|
||||||
sign_tonic_rpc_response_proof, tonic_boot_epoch_challenge, tonic_boot_epoch_response_headers, verify_ns_scanner_capability,
|
verify_ns_scanner_capability, verify_rpc_signature, verify_tonic_canonical_body_digest, verify_tonic_mutation_body_digest,
|
||||||
verify_rpc_signature, verify_tonic_boot_epoch_response, verify_tonic_canonical_body_digest,
|
verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
|
||||||
verify_tonic_mutation_body_digest, verify_tonic_rpc_response_proof, verify_tonic_rpc_signature,
|
|
||||||
verify_tonic_rpc_signature_with_bootstrap,
|
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
|
pub(crate) use internode_data_transport::TcpHttpInternodeDataTransport;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::cluster::rpc::client::{
|
use crate::cluster::rpc::client::{
|
||||||
AuthenticatedChannel, TonicInterceptor, embedded_tonic_status, gen_tonic_signature_interceptor, heal_control_time_out_client,
|
TonicInterceptor, embedded_tonic_status, gen_tonic_signature_interceptor, heal_control_time_out_client,
|
||||||
is_network_like_status, message_has_network_needle, node_service_time_out_client, tier_mutation_control_time_out_client,
|
is_network_like_status, message_has_network_needle, node_service_time_out_client, tier_mutation_control_time_out_client,
|
||||||
};
|
};
|
||||||
use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, verify_tonic_rpc_response_proof};
|
use crate::cluster::rpc::{set_tonic_canonical_body_digest, set_tonic_mutation_body_digest, verify_tonic_rpc_response_proof};
|
||||||
@@ -58,7 +58,7 @@ use std::{
|
|||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
io::Cursor,
|
io::Cursor,
|
||||||
sync::{
|
sync::{
|
||||||
Arc, Weak,
|
Arc,
|
||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
},
|
},
|
||||||
time::SystemTime,
|
time::SystemTime,
|
||||||
@@ -66,6 +66,7 @@ use std::{
|
|||||||
use tokio::{net::TcpStream, time::Duration};
|
use tokio::{net::TcpStream, time::Duration};
|
||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
use tonic::service::interceptor::InterceptedService;
|
use tonic::service::interceptor::InterceptedService;
|
||||||
|
use tonic::transport::Channel;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -221,21 +222,6 @@ fn validate_heal_control_response_proof(canonical_response: &[u8], proof: &[u8])
|
|||||||
.map_err(|_| Error::other("peer returned an invalid heal control response proof"))
|
.map_err(|_| Error::other("peer returned an invalid heal control response proof"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_remote_version_state_capability(expected_member: &str, result: &[u8]) -> Result<Uuid> {
|
|
||||||
let (topology_member, process_epoch) = rustfs_protos::decode_remote_version_state_capability(result).map_err(Error::other)?;
|
|
||||||
if topology_member != expected_member {
|
|
||||||
return Err(Error::other(
|
|
||||||
"peer returned a remote version state capability for a different topology member",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let server_epoch =
|
|
||||||
Uuid::from_slice(process_epoch).map_err(|_| Error::other("peer returned an invalid remote version state epoch"))?;
|
|
||||||
if server_epoch.is_nil() {
|
|
||||||
return Err(Error::other("peer returned a nil remote version state epoch"));
|
|
||||||
}
|
|
||||||
Ok(server_epoch)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct PeerLiveEventsBatch {
|
pub struct PeerLiveEventsBatch {
|
||||||
pub events: Vec<u8>,
|
pub events: Vec<u8>,
|
||||||
@@ -247,7 +233,6 @@ pub struct PeerLiveEventsBatch {
|
|||||||
pub struct PeerRestClient {
|
pub struct PeerRestClient {
|
||||||
pub host: XHost,
|
pub host: XHost,
|
||||||
pub grid_host: String,
|
pub grid_host: String,
|
||||||
topology_member: String,
|
|
||||||
offline: Arc<AtomicBool>,
|
offline: Arc<AtomicBool>,
|
||||||
recovery_running: Arc<AtomicBool>,
|
recovery_running: Arc<AtomicBool>,
|
||||||
}
|
}
|
||||||
@@ -340,54 +325,14 @@ impl PeerRestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(host: XHost, grid_host: String) -> Self {
|
pub fn new(host: XHost, grid_host: String) -> Self {
|
||||||
let topology_member = host.to_string();
|
|
||||||
Self {
|
Self {
|
||||||
host,
|
host,
|
||||||
grid_host,
|
grid_host,
|
||||||
topology_member,
|
|
||||||
offline: Arc::new(AtomicBool::new(false)),
|
offline: Arc::new(AtomicBool::new(false)),
|
||||||
recovery_running: Arc::new(AtomicBool::new(false)),
|
recovery_running: Arc::new(AtomicBool::new(false)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_topology_host(peer_host_port: &str, grid_host: &str) -> Result<XHost> {
|
|
||||||
let url = url::Url::parse(grid_host).map_err(|_| Error::other("peer grid host is not a valid URL"))?;
|
|
||||||
if !matches!(url.scheme(), "http" | "https")
|
|
||||||
|| !url.username().is_empty()
|
|
||||||
|| url.password().is_some()
|
|
||||||
|| url.query().is_some()
|
|
||||||
|| url.fragment().is_some()
|
|
||||||
|| url.path() != "/"
|
|
||||||
{
|
|
||||||
return Err(Error::other("peer grid host has an invalid URL shape"));
|
|
||||||
}
|
|
||||||
let url_host = url.host().ok_or_else(|| Error::other("peer grid host is missing a host"))?;
|
|
||||||
let topology_host = match url.port() {
|
|
||||||
Some(port) => format!("{url_host}:{port}"),
|
|
||||||
None => url_host.to_string(),
|
|
||||||
};
|
|
||||||
let explicit_port = url.port();
|
|
||||||
let name = match url_host {
|
|
||||||
url::Host::Domain(domain) => domain.to_string(),
|
|
||||||
url::Host::Ipv4(address) => address.to_string(),
|
|
||||||
url::Host::Ipv6(address) if explicit_port.is_none() => format!("[{address}]"),
|
|
||||||
url::Host::Ipv6(address) => address.to_string(),
|
|
||||||
};
|
|
||||||
let port = url
|
|
||||||
.port_or_known_default()
|
|
||||||
.filter(|port| *port > 0)
|
|
||||||
.ok_or_else(|| Error::other("peer grid host is missing a valid port"))?;
|
|
||||||
let host = XHost {
|
|
||||||
name,
|
|
||||||
port,
|
|
||||||
is_port_set: explicit_port.is_some(),
|
|
||||||
};
|
|
||||||
if topology_host != peer_host_port {
|
|
||||||
return Err(Error::other("peer topology host does not match its grid URL"));
|
|
||||||
}
|
|
||||||
Ok(host)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_clients_from_slots(
|
fn build_clients_from_slots(
|
||||||
slots: Vec<(String, Option<String>, bool)>,
|
slots: Vec<(String, Option<String>, bool)>,
|
||||||
) -> (Vec<Option<Self>>, Vec<Option<Self>>, Vec<String>) {
|
) -> (Vec<Option<Self>>, Vec<Option<Self>>, Vec<String>) {
|
||||||
@@ -401,14 +346,10 @@ impl PeerRestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let client = match grid_host {
|
let client = match grid_host {
|
||||||
Some(grid_host) => match Self::parse_topology_host(&peer_host_port, &grid_host) {
|
Some(grid_host) => match XHost::try_from(peer_host_port.clone()) {
|
||||||
Ok(host) => {
|
Ok(host) => Some(PeerRestClient::new(host, grid_host)),
|
||||||
let mut client = PeerRestClient::new(host, grid_host);
|
|
||||||
client.topology_member = peer_host_port.clone();
|
|
||||||
Some(client)
|
|
||||||
}
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(peer = %peer_host_port, "peer topology host parse failed while constructing peer client: {err:?}");
|
warn!(peer = %peer_host_port, "Xhost parse failed while constructing peer client: {err:?}");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -449,7 +390,7 @@ impl PeerRestClient {
|
|||||||
(remote, all, remote_topology_hosts)
|
(remote, all, remote_topology_hosts)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>> {
|
pub async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>> {
|
||||||
if self.offline.load(Ordering::Acquire) {
|
if self.offline.load(Ordering::Acquire) {
|
||||||
self.mark_offline_and_spawn_recovery();
|
self.mark_offline_and_spawn_recovery();
|
||||||
return Err(Error::other(format!("peer {} is temporarily offline", self.grid_host)));
|
return Err(Error::other(format!("peer {} is temporarily offline", self.grid_host)));
|
||||||
@@ -470,7 +411,7 @@ impl PeerRestClient {
|
|||||||
&self,
|
&self,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
rustfs_protos::proto_gen::node_service::heal_control_service_client::HealControlServiceClient<
|
rustfs_protos::proto_gen::node_service::heal_control_service_client::HealControlServiceClient<
|
||||||
InterceptedService<AuthenticatedChannel, TonicInterceptor>,
|
InterceptedService<Channel, TonicInterceptor>,
|
||||||
>,
|
>,
|
||||||
> {
|
> {
|
||||||
if self.offline.load(Ordering::Acquire) {
|
if self.offline.load(Ordering::Acquire) {
|
||||||
@@ -491,7 +432,7 @@ impl PeerRestClient {
|
|||||||
|
|
||||||
async fn get_tier_mutation_control_client(
|
async fn get_tier_mutation_control_client(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<TierMutationControlServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>> {
|
) -> Result<TierMutationControlServiceClient<InterceptedService<Channel, TonicInterceptor>>> {
|
||||||
if self.offline.load(Ordering::Acquire) {
|
if self.offline.load(Ordering::Acquire) {
|
||||||
self.mark_offline_and_spawn_recovery();
|
self.mark_offline_and_spawn_recovery();
|
||||||
return Err(Error::other(format!("peer {} is temporarily offline", self.grid_host)));
|
return Err(Error::other(format!("peer {} is temporarily offline", self.grid_host)));
|
||||||
@@ -557,8 +498,9 @@ impl PeerRestClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let grid_host = self.grid_host.clone();
|
let grid_host = self.grid_host.clone();
|
||||||
let offline = Arc::downgrade(&self.offline);
|
let offline = Arc::clone(&self.offline);
|
||||||
let recovery_running = Arc::downgrade(&self.recovery_running);
|
let recovery_running = Arc::clone(&self.recovery_running);
|
||||||
|
let span = Self::recovery_monitor_span(&grid_host);
|
||||||
// The offline flag and its recovery are the silent half of
|
// The offline flag and its recovery are the silent half of
|
||||||
// rustfs/backlog#888: log the monitor's start and its success so an
|
// rustfs/backlog#888: log the monitor's start and its success so an
|
||||||
// "offline then back" episode leaves a trace on the observing node.
|
// "offline then back" episode leaves a trace on the observing node.
|
||||||
@@ -567,34 +509,13 @@ impl PeerRestClient {
|
|||||||
grid_host = %self.grid_host,
|
grid_host = %self.grid_host,
|
||||||
"peer RPC connection marked offline after a network-like failure; starting background recovery monitor"
|
"peer RPC connection marked offline after a network-like failure; starting background recovery monitor"
|
||||||
);
|
);
|
||||||
drop(Self::spawn_recovery_monitor(grid_host, offline, recovery_running));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_recovery_monitor(
|
|
||||||
grid_host: String,
|
|
||||||
offline: Weak<AtomicBool>,
|
|
||||||
recovery_running: Weak<AtomicBool>,
|
|
||||||
) -> tokio::task::JoinHandle<()> {
|
|
||||||
let span = Self::recovery_monitor_span(&grid_host);
|
|
||||||
super::spawn_background_monitor(span, async move {
|
super::spawn_background_monitor(span, async move {
|
||||||
let mut delay = get_drive_active_check_interval();
|
let mut delay = get_drive_active_check_interval();
|
||||||
let connect_timeout = get_drive_active_check_timeout();
|
let connect_timeout = get_drive_active_check_timeout();
|
||||||
|
|
||||||
for attempt in 1..=PEER_REST_RECOVERY_MAX_ATTEMPTS {
|
for attempt in 1..=PEER_REST_RECOVERY_MAX_ATTEMPTS {
|
||||||
if offline.strong_count() == 0 || recovery_running.strong_count() == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
if offline.strong_count() == 0 || recovery_running.strong_count() == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if Self::perform_connectivity_check(&grid_host, connect_timeout).await.is_ok() {
|
if Self::perform_connectivity_check(&grid_host, connect_timeout).await.is_ok() {
|
||||||
let Some(offline) = offline.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(recovery_running) = recovery_running.upgrade() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
offline.store(false, Ordering::Release);
|
offline.store(false, Ordering::Release);
|
||||||
recovery_running.store(false, Ordering::Release);
|
recovery_running.store(false, Ordering::Release);
|
||||||
info!(
|
info!(
|
||||||
@@ -614,10 +535,8 @@ impl PeerRestClient {
|
|||||||
attempts = PEER_REST_RECOVERY_MAX_ATTEMPTS,
|
attempts = PEER_REST_RECOVERY_MAX_ATTEMPTS,
|
||||||
"peer recovery monitor reached max attempts; will retry on next request"
|
"peer recovery monitor reached max attempts; will retry on next request"
|
||||||
);
|
);
|
||||||
if let Some(recovery_running) = recovery_running.upgrade() {
|
recovery_running.store(false, Ordering::Release);
|
||||||
recovery_running.store(false, Ordering::Release);
|
});
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1235,15 +1154,6 @@ impl PeerRestClient {
|
|||||||
validate_heal_control_capability_proof(&canonical_ack, &proof)
|
validate_heal_control_capability_proof(&canonical_ack, &proof)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn probe_remote_version_state(&self, topology_fingerprint: String) -> Result<(String, Uuid)> {
|
|
||||||
let probe = rustfs_protos::remote_version_state_capability_probe(Uuid::new_v4().as_bytes());
|
|
||||||
let result = self
|
|
||||||
.heal_control(rustfs_protos::HEAL_CONTROL_PROTOCOL_VERSION, topology_fingerprint, probe)
|
|
||||||
.await?;
|
|
||||||
let epoch = decode_remote_version_state_capability(&self.topology_member, &result)?;
|
|
||||||
Ok((self.topology_member.clone(), epoch))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
|
pub async fn load_bucket_metadata(&self, bucket: &str, scanner_maintenance_change: bool) -> Result<()> {
|
||||||
self.finalize_result(
|
self.finalize_result(
|
||||||
async {
|
async {
|
||||||
@@ -1867,13 +1777,9 @@ fn tier_config_reload_status_outcome(status: tonic::Status) -> TierConfigReloadO
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::com::STORAGE_CLASS_SUB_SYS;
|
use crate::config::com::STORAGE_CLASS_SUB_SYS;
|
||||||
use crate::layout::{disks_layout::DisksLayout, endpoints::SetupType};
|
|
||||||
use rustfs_config::{ENV_KUBERNETES_SERVICE_HOST, ENV_LOCAL_ENDPOINT_HOST, ENV_STARTUP_TOPOLOGY_WAIT_MODE};
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serial_test::serial;
|
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use temp_env::async_with_vars;
|
|
||||||
use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt};
|
use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1991,115 +1897,30 @@ mod tests {
|
|||||||
fn build_clients_from_slots_preserves_missing_remote_topology_slots() {
|
fn build_clients_from_slots_preserves_missing_remote_topology_slots() {
|
||||||
let slots = vec![
|
let slots = vec![
|
||||||
("127.0.0.1:9000".to_string(), None, true),
|
("127.0.0.1:9000".to_string(), None, true),
|
||||||
(
|
("127.0.0.1:9001".to_string(), Some("http://127.0.0.1:9001".to_string()), false),
|
||||||
"rustfs-1.invalid:9001".to_string(),
|
|
||||||
Some("http://rustfs-1.invalid:9001".to_string()),
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
("rustfs-2.invalid".to_string(), Some("http://rustfs-2.invalid".to_string()), false),
|
|
||||||
("127.0.0.1:notaport".to_string(), Some("http://127.0.0.1:notaport".to_string()), false),
|
("127.0.0.1:notaport".to_string(), Some("http://127.0.0.1:notaport".to_string()), false),
|
||||||
("127.0.0.1:9003".to_string(), None, false),
|
("127.0.0.1:9003".to_string(), None, false),
|
||||||
];
|
];
|
||||||
|
|
||||||
let (remote, all, remote_topology_hosts) = PeerRestClient::build_clients_from_slots(slots);
|
let (remote, all, remote_topology_hosts) = PeerRestClient::build_clients_from_slots(slots);
|
||||||
|
|
||||||
assert_eq!(remote.len(), 4, "local node is excluded but remote slots are not compacted away");
|
assert_eq!(remote.len(), 3, "local node is excluded but remote slots are not compacted away");
|
||||||
assert_eq!(all.len(), 5, "all slots preserve the sorted cluster topology shape");
|
assert_eq!(all.len(), 4, "all slots preserve the sorted cluster topology shape");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
remote_topology_hosts,
|
remote_topology_hosts,
|
||||||
vec![
|
vec![
|
||||||
"rustfs-1.invalid:9001".to_string(),
|
"127.0.0.1:9001".to_string(),
|
||||||
"rustfs-2.invalid".to_string(),
|
|
||||||
"127.0.0.1:notaport".to_string(),
|
"127.0.0.1:notaport".to_string(),
|
||||||
"127.0.0.1:9003".to_string()
|
"127.0.0.1:9003".to_string()
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
let unresolved = remote[0]
|
assert!(remote[0].is_some(), "valid remote peer should get a client");
|
||||||
.as_ref()
|
assert!(remote[1].is_none(), "unparseable remote peer should remain observable as a missing slot");
|
||||||
.expect("temporarily unresolved remote peer should retain a client");
|
assert!(remote[2].is_none(), "missing grid host should remain observable as a missing slot");
|
||||||
assert_eq!(unresolved.host.to_string(), "rustfs-1.invalid:9001");
|
|
||||||
let default_port = remote[1]
|
|
||||||
.as_ref()
|
|
||||||
.expect("temporarily unresolved scheme-default remote peer should retain a client");
|
|
||||||
assert_eq!(default_port.host.to_string(), "rustfs-2.invalid");
|
|
||||||
assert_eq!(default_port.host.port, 80);
|
|
||||||
assert!(!default_port.host.is_port_set);
|
|
||||||
assert!(remote[2].is_none(), "unparseable remote peer should remain observable as a missing slot");
|
|
||||||
assert!(remote[3].is_none(), "missing grid host should remain observable as a missing slot");
|
|
||||||
assert!(all[0].is_none(), "local node is represented by the local server_info row");
|
assert!(all[0].is_none(), "local node is represented by the local server_info row");
|
||||||
assert!(all[1].is_some());
|
assert!(all[1].is_some());
|
||||||
assert!(all[2].is_some());
|
assert!(all[2].is_none());
|
||||||
assert!(all[3].is_none());
|
assert!(all[3].is_none());
|
||||||
assert!(all[4].is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn topology_host_parser_preserves_names_and_bracketed_ipv6() {
|
|
||||||
let domain = PeerRestClient::parse_topology_host("rustfs-1.invalid", "https://rustfs-1.invalid")
|
|
||||||
.expect("unresolved HTTPS topology host should parse without DNS");
|
|
||||||
assert_eq!(domain.to_string(), "rustfs-1.invalid");
|
|
||||||
assert_eq!(domain.port, 443);
|
|
||||||
assert!(!domain.is_port_set);
|
|
||||||
|
|
||||||
let ipv6 = PeerRestClient::parse_topology_host("[2001:db8::1]:9000", "http://[2001:db8::1]:9000")
|
|
||||||
.expect("bracketed IPv6 topology host should parse without changing its identity");
|
|
||||||
assert_eq!(ipv6.to_string(), "[2001:db8::1]:9000");
|
|
||||||
|
|
||||||
let default_port_ipv6 = PeerRestClient::parse_topology_host("[2001:db8::2]", "http://[2001:db8::2]")
|
|
||||||
.expect("scheme-default IPv6 topology host should parse without DNS");
|
|
||||||
assert_eq!(default_port_ipv6.to_string(), "[2001:db8::2]");
|
|
||||||
assert_eq!(default_port_ipv6.port, 80);
|
|
||||||
assert!(!default_port_ipv6.is_port_set);
|
|
||||||
|
|
||||||
assert!(PeerRestClient::parse_topology_host("peer.invalid:0", "http://peer.invalid:0").is_err());
|
|
||||||
assert!(PeerRestClient::parse_topology_host("peer-a.invalid:9000", "http://peer-b.invalid:9000").is_err());
|
|
||||||
assert!(PeerRestClient::parse_topology_host("peer.invalid:9000", "http://peer.invalid:9000/unexpected").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn unresolved_default_port_endpoint_topology_retains_all_peer_clients() {
|
|
||||||
let volumes = (0..4)
|
|
||||||
.map(|index| format!("http://rustfs-{index}.invalid:80/data{index}"))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let layout = DisksLayout::from_volumes(&volumes).expect("distributed default-port topology should parse");
|
|
||||||
|
|
||||||
async_with_vars(
|
|
||||||
[
|
|
||||||
(ENV_STARTUP_TOPOLOGY_WAIT_MODE, Some("orchestrated")),
|
|
||||||
(ENV_LOCAL_ENDPOINT_HOST, Some("rustfs-0.invalid")),
|
|
||||||
(ENV_KUBERNETES_SERVICE_HOST, None),
|
|
||||||
],
|
|
||||||
async {
|
|
||||||
let (server_pools, setup_type) = EndpointServerPools::create_server_endpoints("0.0.0.0:80", &layout)
|
|
||||||
.await
|
|
||||||
.expect("explicit local identity should avoid peer DNS during endpoint construction");
|
|
||||||
assert_eq!(setup_type, SetupType::DistErasure);
|
|
||||||
|
|
||||||
let (remote, all, remote_topology_hosts) =
|
|
||||||
PeerRestClient::build_clients_from_slots(server_pools.peer_grid_host_slots_sorted());
|
|
||||||
assert_eq!(remote.len(), 3);
|
|
||||||
assert!(
|
|
||||||
remote.iter().all(Option::is_some),
|
|
||||||
"unresolved remote peers must retain reconnectable clients"
|
|
||||||
);
|
|
||||||
assert_eq!(all.len(), 4);
|
|
||||||
assert_eq!(all.iter().filter(|client| client.is_none()).count(), 1);
|
|
||||||
assert_eq!(remote_topology_hosts.len(), 3);
|
|
||||||
assert!(
|
|
||||||
remote_topology_hosts.iter().all(|host| !host.contains(':')),
|
|
||||||
"scheme-default ports must preserve the legacy topology identity"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
remote
|
|
||||||
.iter()
|
|
||||||
.flatten()
|
|
||||||
.all(|client| client.host.port == 80 && !client.host.is_port_set),
|
|
||||||
"scheme-default peers must retain the effective dial port"
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2649,22 +2470,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_capability_decoder_fails_closed() {
|
|
||||||
let epoch = Uuid::new_v4();
|
|
||||||
let result = rustfs_protos::encode_remote_version_state_capability("node-a:9000", epoch.as_bytes())
|
|
||||||
.expect("small capability response should encode");
|
|
||||||
assert_eq!(
|
|
||||||
decode_remote_version_state_capability("node-a:9000", &result).expect("valid epoch should decode"),
|
|
||||||
epoch
|
|
||||||
);
|
|
||||||
assert!(decode_remote_version_state_capability("node-b:9000", &result).is_err());
|
|
||||||
assert!(decode_remote_version_state_capability("node-a:9000", &result[..result.len() - 1]).is_err());
|
|
||||||
let nil = rustfs_protos::encode_remote_version_state_capability("node-a:9000", Uuid::nil().as_bytes())
|
|
||||||
.expect("small capability response should encode");
|
|
||||||
assert!(decode_remote_version_state_capability("node-a:9000", &nil).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TierMutationResponseFixture<'a> {
|
struct TierMutationResponseFixture<'a> {
|
||||||
version: u32,
|
version: u32,
|
||||||
phase: TierMutationRpcPhase,
|
phase: TierMutationRpcPhase,
|
||||||
@@ -2889,31 +2694,6 @@ mod tests {
|
|||||||
assert!(!client.offline.load(Ordering::Acquire));
|
assert!(!client.offline.load(Ordering::Acquire));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
|
||||||
async fn dropped_peer_client_releases_and_stops_its_recovery_monitor() {
|
|
||||||
let client = test_peer_client();
|
|
||||||
client.offline.store(true, Ordering::Release);
|
|
||||||
client.recovery_running.store(true, Ordering::Release);
|
|
||||||
let offline = Arc::downgrade(&client.offline);
|
|
||||||
let recovery_running = Arc::downgrade(&client.recovery_running);
|
|
||||||
let handle = PeerRestClient::spawn_recovery_monitor(client.grid_host.clone(), offline.clone(), recovery_running.clone());
|
|
||||||
let started = tokio::time::Instant::now();
|
|
||||||
|
|
||||||
drop(client);
|
|
||||||
|
|
||||||
assert!(offline.upgrade().is_none(), "detached recovery must not retain offline state");
|
|
||||||
assert!(
|
|
||||||
recovery_running.upgrade().is_none(),
|
|
||||||
"detached recovery must not retain its running state"
|
|
||||||
);
|
|
||||||
handle.await.expect("recovery monitor should not panic");
|
|
||||||
assert_eq!(
|
|
||||||
tokio::time::Instant::now(),
|
|
||||||
started,
|
|
||||||
"recovery monitor should stop before advancing to its first delayed probe"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn peer_rest_client_finalize_result_keeps_online_for_app_errors_mentioning_unavailable() {
|
async fn peer_rest_client_finalize_result_keeps_online_for_app_errors_mentioning_unavailable() {
|
||||||
// Regression: application error text containing "unavailable" (a
|
// Regression: application error text containing "unavailable" (a
|
||||||
@@ -2961,11 +2741,6 @@ mod tests {
|
|||||||
.with_span_list(true),
|
.with_span_list(true),
|
||||||
);
|
);
|
||||||
let _guard = tracing::subscriber::set_default(subscriber);
|
let _guard = tracing::subscriber::set_default(subscriber);
|
||||||
// The `recovery-monitor` callsite is shared with the production
|
|
||||||
// `mark_offline_and_spawn_recovery` path that sibling tests exercise from
|
|
||||||
// subscriber-less threads; without this the span can be cached as
|
|
||||||
// `Interest::never()` and silently degrade to `Span::none()`.
|
|
||||||
let _callsite_pin = crate::test_tracing::pin_callsite_interest_for_test();
|
|
||||||
|
|
||||||
let client = test_peer_client();
|
let client = test_peer_client();
|
||||||
let span = tracing::info_span!("request-span", request_id = "req-peer-rest");
|
let span = tracing::info_span!("request-span", request_id = "req-peer-rest");
|
||||||
|
|||||||
@@ -14,8 +14,7 @@
|
|||||||
|
|
||||||
use crate::bucket::metadata_sys;
|
use crate::bucket::metadata_sys;
|
||||||
use crate::cluster::rpc::client::{
|
use crate::cluster::rpc::client::{
|
||||||
AuthenticatedChannel, TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error,
|
TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error, node_service_time_out_client,
|
||||||
node_service_time_out_client,
|
|
||||||
};
|
};
|
||||||
use crate::cluster::rpc::set_tonic_mutation_body_digest;
|
use crate::cluster::rpc::set_tonic_mutation_body_digest;
|
||||||
use crate::disk::error::DiskError;
|
use crate::disk::error::DiskError;
|
||||||
@@ -41,84 +40,16 @@ use rustfs_protos::proto_gen::node_service::node_service_client::NodeServiceClie
|
|||||||
use rustfs_protos::proto_gen::node_service::{
|
use rustfs_protos::proto_gen::node_service::{
|
||||||
DeleteBucketRequest, GetBucketInfoRequest, HealBucketRequest, ListBucketRequest, MakeBucketRequest,
|
DeleteBucketRequest, GetBucketInfoRequest, HealBucketRequest, ListBucketRequest, MakeBucketRequest,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
|
||||||
use std::sync::{
|
|
||||||
Mutex as StdMutex,
|
|
||||||
atomic::{AtomicBool, Ordering},
|
|
||||||
};
|
|
||||||
use std::{collections::HashMap, fmt::Debug, sync::Arc, time::Duration};
|
use std::{collections::HashMap, fmt::Debug, sync::Arc, time::Duration};
|
||||||
#[cfg(test)]
|
|
||||||
use tokio::sync::Notify;
|
|
||||||
use tokio::{net::TcpStream, sync::RwLock, time};
|
use tokio::{net::TcpStream, sync::RwLock, time};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
use tonic::service::interceptor::InterceptedService;
|
use tonic::service::interceptor::InterceptedService;
|
||||||
|
use tonic::transport::Channel;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
type Client = Arc<Box<dyn PeerS3Client>>;
|
type Client = Arc<Box<dyn PeerS3Client>>;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
#[derive(Default)]
|
|
||||||
pub(crate) struct DeleteBucketEmptyScanBarrier {
|
|
||||||
arrived: AtomicBool,
|
|
||||||
arrived_notify: Notify,
|
|
||||||
released: AtomicBool,
|
|
||||||
release_notify: Notify,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
impl DeleteBucketEmptyScanBarrier {
|
|
||||||
pub(crate) async fn wait_until_paused(&self) {
|
|
||||||
loop {
|
|
||||||
let notified = self.arrived_notify.notified();
|
|
||||||
if self.arrived.load(Ordering::Acquire) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notified.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn release(&self) {
|
|
||||||
self.released.store(true, Ordering::Release);
|
|
||||||
self.release_notify.notify_waiters();
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn pause(&self) {
|
|
||||||
self.arrived.store(true, Ordering::Release);
|
|
||||||
self.arrived_notify.notify_waiters();
|
|
||||||
loop {
|
|
||||||
let notified = self.release_notify.notified();
|
|
||||||
if self.released.load(Ordering::Acquire) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
notified.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
static DELETE_BUCKET_EMPTY_SCAN_BARRIER: StdMutex<Option<Arc<DeleteBucketEmptyScanBarrier>>> = StdMutex::new(None);
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn install_delete_bucket_empty_scan_barrier() -> Arc<DeleteBucketEmptyScanBarrier> {
|
|
||||||
let barrier = Arc::new(DeleteBucketEmptyScanBarrier::default());
|
|
||||||
*DELETE_BUCKET_EMPTY_SCAN_BARRIER
|
|
||||||
.lock()
|
|
||||||
.expect("empty scan barrier lock should not be poisoned") = Some(barrier.clone());
|
|
||||||
barrier
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
async fn pause_after_delete_bucket_empty_scan() {
|
|
||||||
let barrier = DELETE_BUCKET_EMPTY_SCAN_BARRIER
|
|
||||||
.lock()
|
|
||||||
.expect("empty scan barrier lock should not be poisoned")
|
|
||||||
.take();
|
|
||||||
if let Some(barrier) = barrier {
|
|
||||||
barrier.pause().await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ScannerBucketListing {
|
pub struct ScannerBucketListing {
|
||||||
pub buckets: Vec<BucketInfo>,
|
pub buckets: Vec<BucketInfo>,
|
||||||
@@ -719,22 +650,24 @@ impl PeerS3Client for LocalPeerS3Client {
|
|||||||
return Err(Error::ErasureWriteQuorum);
|
return Err(Error::ErasureWriteQuorum);
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.force_if_empty && !opts.force {
|
let force = if opts.force_if_empty && !opts.force {
|
||||||
for disk in local_disks.iter() {
|
for disk in local_disks.iter() {
|
||||||
if has_xlmeta_files(&disk.path().join(bucket)).await.map_err(Error::Io)? {
|
if has_xlmeta_files(&disk.path().join(bucket)).await.map_err(Error::Io)? {
|
||||||
return Err(Error::VolumeNotEmpty);
|
return Err(Error::VolumeNotEmpty);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
true
|
||||||
pause_after_delete_bucket_empty_scan().await;
|
} else {
|
||||||
}
|
opts.force
|
||||||
|
};
|
||||||
|
|
||||||
let mut futures = Vec::with_capacity(local_disks.len());
|
let mut futures = Vec::with_capacity(local_disks.len());
|
||||||
|
|
||||||
for disk in local_disks.iter() {
|
for disk in local_disks.iter() {
|
||||||
// `force_if_empty` is validation-only. Passing it as force would let
|
// Non-force delete refuses a non-empty bucket (VolumeNotEmpty), which
|
||||||
// a PutObject committed after the scan be removed recursively.
|
// the recreate loop below turns into BucketNotEmpty; only an explicit
|
||||||
futures.push(disk.delete_volume(bucket, opts.force));
|
// force delete removes recursively (backlog#799 B1).
|
||||||
|
futures.push(disk.delete_volume(bucket, force));
|
||||||
}
|
}
|
||||||
|
|
||||||
let results = join_all(futures).await;
|
let results = join_all(futures).await;
|
||||||
@@ -797,15 +730,6 @@ pub struct RemotePeerS3Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RemotePeerS3Client {
|
impl RemotePeerS3Client {
|
||||||
fn encode_delete_bucket_options(opts: &DeleteBucketOptions) -> Result<String> {
|
|
||||||
let mut remote_opts = opts.clone();
|
|
||||||
// Older peers promote `force_if_empty` to recursive force after their
|
|
||||||
// metadata scan. Keep this coordinator-only hint off the wire so a
|
|
||||||
// mixed-version delete fails closed on non-empty directory remnants.
|
|
||||||
remote_opts.force_if_empty = false;
|
|
||||||
serde_json::to_string(&remote_opts).map_err(Into::into)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn recovery_monitor_span(addr: &str) -> tracing::Span {
|
fn recovery_monitor_span(addr: &str) -> tracing::Span {
|
||||||
tracing::info_span!(
|
tracing::info_span!(
|
||||||
"recovery-monitor",
|
"recovery-monitor",
|
||||||
@@ -832,7 +756,7 @@ impl RemotePeerS3Client {
|
|||||||
client
|
client
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>> {
|
pub async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>> {
|
||||||
node_service_time_out_client(&self.addr, TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
node_service_time_out_client(&self.addr, TonicInterceptor::Signature(gen_tonic_signature_interceptor()))
|
||||||
.await
|
.await
|
||||||
.map_err(|err| Error::other(format!("can not get client, err: {err}")))
|
.map_err(|err| Error::other(format!("can not get client, err: {err}")))
|
||||||
@@ -1116,7 +1040,7 @@ impl PeerS3Client for RemotePeerS3Client {
|
|||||||
async fn delete_bucket(&self, bucket: &str, opts: &DeleteBucketOptions) -> Result<()> {
|
async fn delete_bucket(&self, bucket: &str, opts: &DeleteBucketOptions) -> Result<()> {
|
||||||
self.execute_with_timeout(
|
self.execute_with_timeout(
|
||||||
|| async {
|
|| async {
|
||||||
let options = Self::encode_delete_bucket_options(opts)?;
|
let options = serde_json::to_string(opts)?;
|
||||||
let mut client = self.get_client().await?;
|
let mut client = self.get_client().await?;
|
||||||
|
|
||||||
let mut request = Request::new(DeleteBucketRequest {
|
let mut request = Request::new(DeleteBucketRequest {
|
||||||
@@ -1490,49 +1414,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_delete_bucket_options_fail_closed_for_legacy_peers() {
|
|
||||||
let encoded = RemotePeerS3Client::encode_delete_bucket_options(&DeleteBucketOptions {
|
|
||||||
no_lock: true,
|
|
||||||
no_recreate: true,
|
|
||||||
force_if_empty: true,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.expect("remote delete options should serialize");
|
|
||||||
let legacy_opts: DeleteBucketOptions =
|
|
||||||
serde_json::from_str(&encoded).expect("legacy peer should decode remote delete options");
|
|
||||||
|
|
||||||
assert!(legacy_opts.no_lock);
|
|
||||||
assert!(legacy_opts.no_recreate);
|
|
||||||
assert!(!legacy_opts.force);
|
|
||||||
assert!(!legacy_opts.force_if_empty);
|
|
||||||
|
|
||||||
let legacy_recursive_force = if legacy_opts.force_if_empty && !legacy_opts.force {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
legacy_opts.force
|
|
||||||
};
|
|
||||||
assert!(
|
|
||||||
!legacy_recursive_force,
|
|
||||||
"legacy peer must not upgrade empty-only delete to recursive force"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_delete_bucket_options_preserve_explicit_force() {
|
|
||||||
let encoded = RemotePeerS3Client::encode_delete_bucket_options(&DeleteBucketOptions {
|
|
||||||
force: true,
|
|
||||||
force_if_empty: true,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.expect("remote force-delete options should serialize");
|
|
||||||
let remote_opts: DeleteBucketOptions =
|
|
||||||
serde_json::from_str(&encoded).expect("remote peer should decode force-delete options");
|
|
||||||
|
|
||||||
assert!(remote_opts.force);
|
|
||||||
assert!(!remote_opts.force_if_empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_execute_with_timeout_marks_remote_peer_faulty_on_network_like_error() {
|
async fn test_execute_with_timeout_marks_remote_peer_faulty_on_network_like_error() {
|
||||||
let client = test_remote_peer("http://peer-network-error:9000");
|
let client = test_remote_peer("http://peer-network-error:9000");
|
||||||
@@ -1690,54 +1571,6 @@ mod tests {
|
|||||||
reset_local_disk_test_state().await;
|
reset_local_disk_test_state().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn local_peer_force_if_empty_preserves_unclassified_file_in_selected_pool() {
|
|
||||||
reset_local_disk_test_state().await;
|
|
||||||
|
|
||||||
let temp_dir = TempDir::new().expect("create temp dir for empty-only delete regression");
|
|
||||||
let disks = init_test_local_disks_for_pools(
|
|
||||||
&temp_dir,
|
|
||||||
&[(0, 1), (1, 1)],
|
|
||||||
"local-peer-force-if-empty-preserves-unclassified-file",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let bucket = "empty-only-delete-bucket";
|
|
||||||
let marker = "object/commit-marker";
|
|
||||||
let data = bytes::Bytes::from_static(b"committed object data");
|
|
||||||
|
|
||||||
disks[1]
|
|
||||||
.make_volume(bucket)
|
|
||||||
.await
|
|
||||||
.expect("bucket should be created in the selected pool");
|
|
||||||
disks[1]
|
|
||||||
.write_all(bucket, marker, data.clone())
|
|
||||||
.await
|
|
||||||
.expect("unclassified committed file should be written");
|
|
||||||
|
|
||||||
let err = LocalPeerS3Client::new_with_local_disks(None, Some(vec![1]), disks.clone())
|
|
||||||
.delete_bucket(
|
|
||||||
bucket,
|
|
||||||
&DeleteBucketOptions {
|
|
||||||
force_if_empty: true,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("empty-only delete must not recursively remove an unclassified file");
|
|
||||||
|
|
||||||
assert_eq!(err, Error::VolumeNotEmpty);
|
|
||||||
assert_eq!(
|
|
||||||
disks[1]
|
|
||||||
.read_all(bucket, marker)
|
|
||||||
.await
|
|
||||||
.expect("unclassified committed file should be preserved"),
|
|
||||||
data
|
|
||||||
);
|
|
||||||
|
|
||||||
reset_local_disk_test_state().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn heal_bucket_local_recreates_missing_bucket_volumes() {
|
async fn heal_bucket_local_recreates_missing_bucket_volumes() {
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::cluster::rpc::client::{
|
use crate::cluster::rpc::client::{
|
||||||
AuthenticatedChannel, TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error,
|
TonicInterceptor, gen_tonic_signature_interceptor, is_network_like_disk_error, node_service_time_out_client,
|
||||||
node_service_time_out_client, node_service_time_out_client_for_class, node_service_time_out_client_no_auth,
|
node_service_time_out_client_for_class, node_service_time_out_client_no_auth,
|
||||||
};
|
};
|
||||||
use crate::cluster::rpc::http_auth::set_tonic_canonical_body_digest;
|
use crate::cluster::rpc::http_auth::set_tonic_canonical_body_digest;
|
||||||
use crate::cluster::rpc::internode_data_transport::{
|
use crate::cluster::rpc::internode_data_transport::{
|
||||||
@@ -71,7 +71,7 @@ use tokio::{
|
|||||||
time::timeout,
|
time::timeout,
|
||||||
};
|
};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tonic::{Code, Request, service::interceptor::InterceptedService};
|
use tonic::{Code, Request, service::interceptor::InterceptedService, transport::Channel};
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -1083,7 +1083,7 @@ impl RemoteDisk {
|
|||||||
internode_offline_bypass_reason(&self.addr).map(Error::other)
|
internode_offline_bypass_reason(&self.addr).map(Error::other)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>> {
|
async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>> {
|
||||||
if let Some(err) = self.offline_bypass_error() {
|
if let Some(err) = self.offline_bypass_error() {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
@@ -1096,7 +1096,7 @@ impl RemoteDisk {
|
|||||||
/// Routes onto the isolated bulk channel pool so large transfers cannot head-of-line block
|
/// Routes onto the isolated bulk channel pool so large transfers cannot head-of-line block
|
||||||
/// lock/health RPCs (grpc-optimization P1). Falls back to the control channel when isolation
|
/// lock/health RPCs (grpc-optimization P1). Falls back to the control channel when isolation
|
||||||
/// is disabled.
|
/// is disabled.
|
||||||
async fn get_bulk_client(&self) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>> {
|
async fn get_bulk_client(&self) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>> {
|
||||||
if let Some(err) = self.offline_bypass_error() {
|
if let Some(err) = self.offline_bypass_error() {
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
@@ -3005,7 +3005,6 @@ mod tests {
|
|||||||
use crate::cluster::rpc::internode_data_transport::{InternodeDataTransportCapabilities, TcpHttpInternodeDataTransport};
|
use crate::cluster::rpc::internode_data_transport::{InternodeDataTransportCapabilities, TcpHttpInternodeDataTransport};
|
||||||
use crate::runtime::sources as runtime_sources;
|
use crate::runtime::sources as runtime_sources;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serial_test::serial;
|
|
||||||
use std::io::{self as std_io, Write};
|
use std::io::{self as std_io, Write};
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::{Arc, Mutex, Mutex as StdMutex, Once};
|
use std::sync::{Arc, Mutex, Mutex as StdMutex, Once};
|
||||||
@@ -3019,20 +3018,6 @@ mod tests {
|
|||||||
|
|
||||||
static INIT: Once = Once::new();
|
static INIT: Once = Once::new();
|
||||||
|
|
||||||
// `#[serial(internode_metrics)]` marks every test that observes
|
|
||||||
// `global_internode_metrics()`. Those counters are a process-wide singleton:
|
|
||||||
// some of these tests snapshot a counter, run one decode, and assert on the
|
|
||||||
// delta, while others deliberately record decode errors or call
|
|
||||||
// `reset_internode_metrics_for_test()`. Run concurrently in one process they
|
|
||||||
// corrupt each other's deltas — a sibling's error bumps the "no decode error"
|
|
||||||
// assertion off zero, and a sibling's reset can drive an `after > before`
|
|
||||||
// assertion backwards.
|
|
||||||
//
|
|
||||||
// The marker only takes effect under the `cargo test` fallback; nextest
|
|
||||||
// already isolates each test in its own process, so every test there gets its
|
|
||||||
// own copy of the counters (see `docs/testing/README.md`). Any new test that
|
|
||||||
// reads or mutates the global internode metrics belongs in this group.
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_lease_response_requires_current_protocol_and_valid_token() {
|
fn snapshot_lease_response_requires_current_protocol_and_valid_token() {
|
||||||
let token = SnapshotLeaseToken::new();
|
let token = SnapshotLeaseToken::new();
|
||||||
@@ -3321,7 +3306,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
fn read_multiple_response_decode_prefers_msgpack_payloads() {
|
fn read_multiple_response_decode_prefers_msgpack_payloads() {
|
||||||
let endpoint = sample_remote_endpoint();
|
let endpoint = sample_remote_endpoint();
|
||||||
let msgpack_resp = sample_read_multiple_resp("msgpack", b"binary");
|
let msgpack_resp = sample_read_multiple_resp("msgpack", b"binary");
|
||||||
@@ -3344,7 +3328,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
fn read_multiple_response_decode_falls_back_to_json_payloads() {
|
fn read_multiple_response_decode_falls_back_to_json_payloads() {
|
||||||
let endpoint = sample_remote_endpoint();
|
let endpoint = sample_remote_endpoint();
|
||||||
let json_resp = sample_read_multiple_resp("json", b"fallback");
|
let json_resp = sample_read_multiple_resp("json", b"fallback");
|
||||||
@@ -3366,7 +3349,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
fn rename_data_response_accepts_legacy_json_without_decode_error() {
|
fn rename_data_response_accepts_legacy_json_without_decode_error() {
|
||||||
crate::cluster::rpc::runtime_sources::reset_internode_metrics_for_test();
|
crate::cluster::rpc::runtime_sources::reset_internode_metrics_for_test();
|
||||||
let response = RenameDataResp {
|
let response = RenameDataResp {
|
||||||
@@ -3518,7 +3500,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
fn read_multiple_response_decode_reports_corrupt_msgpack_item() {
|
fn read_multiple_response_decode_reports_corrupt_msgpack_item() {
|
||||||
let endpoint = sample_remote_endpoint();
|
let endpoint = sample_remote_endpoint();
|
||||||
let response = ReadMultipleResponse {
|
let response = ReadMultipleResponse {
|
||||||
@@ -3544,7 +3525,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
fn read_multiple_response_decode_reports_corrupt_json_item() {
|
fn read_multiple_response_decode_reports_corrupt_json_item() {
|
||||||
let endpoint = sample_remote_endpoint();
|
let endpoint = sample_remote_endpoint();
|
||||||
let response = ReadMultipleResponse {
|
let response = ReadMultipleResponse {
|
||||||
@@ -3585,7 +3565,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
fn batch_read_version_response_decode_prefers_msgpack_payloads() {
|
fn batch_read_version_response_decode_prefers_msgpack_payloads() {
|
||||||
let endpoint = sample_remote_endpoint();
|
let endpoint = sample_remote_endpoint();
|
||||||
let msgpack_resp = sample_batch_read_version_resp(7, "msgpack-object", true);
|
let msgpack_resp = sample_batch_read_version_resp(7, "msgpack-object", true);
|
||||||
@@ -3606,7 +3585,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
fn batch_read_version_response_rejects_invalid_success_metadata() {
|
fn batch_read_version_response_rejects_invalid_success_metadata() {
|
||||||
let endpoint = sample_remote_endpoint();
|
let endpoint = sample_remote_endpoint();
|
||||||
let mut response_item = sample_batch_read_version_resp(0, "invalid-object", true);
|
let mut response_item = sample_batch_read_version_resp(0, "invalid-object", true);
|
||||||
@@ -3626,7 +3604,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
fn batch_read_version_response_decode_reports_corrupt_msgpack_item() {
|
fn batch_read_version_response_decode_reports_corrupt_msgpack_item() {
|
||||||
let endpoint = sample_remote_endpoint();
|
let endpoint = sample_remote_endpoint();
|
||||||
let response = BatchReadVersionResponse {
|
let response = BatchReadVersionResponse {
|
||||||
@@ -3653,7 +3630,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
fn batch_read_version_response_decode_reports_corrupt_json_item() {
|
fn batch_read_version_response_decode_reports_corrupt_json_item() {
|
||||||
let endpoint = sample_remote_endpoint();
|
let endpoint = sample_remote_endpoint();
|
||||||
let response = BatchReadVersionResponse {
|
let response = BatchReadVersionResponse {
|
||||||
@@ -4443,7 +4419,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
async fn test_remote_disk_create_file_retries_once_on_retryable_open_write_error() {
|
async fn test_remote_disk_create_file_retries_once_on_retryable_open_write_error() {
|
||||||
let transport = RetryingOpenWriteInternodeDataTransport::with_steps(vec![
|
let transport = RetryingOpenWriteInternodeDataTransport::with_steps(vec![
|
||||||
OpenWriteTestStep::Error(DiskError::from(rustfs_rio::new_test_internode_http_io_error(
|
OpenWriteTestStep::Error(DiskError::from(rustfs_rio::new_test_internode_http_io_error(
|
||||||
@@ -4482,7 +4457,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial(internode_metrics)]
|
|
||||||
async fn test_remote_disk_read_file_stream_retries_once_on_retryable_open_read_error() {
|
async fn test_remote_disk_read_file_stream_retries_once_on_retryable_open_read_error() {
|
||||||
// A transient reset-by-peer on a shard read during the read-after-write window must be
|
// A transient reset-by-peer on a shard read during the read-after-write window must be
|
||||||
// absorbed by one re-dial rather than eroding read quorum (issue #2761).
|
// absorbed by one re-dial rather than eroding read quorum (issue #2761).
|
||||||
@@ -4814,11 +4788,9 @@ mod tests {
|
|||||||
async fn test_remote_disk_endpoints_with_different_schemes() {
|
async fn test_remote_disk_endpoints_with_different_schemes() {
|
||||||
let test_cases = vec![
|
let test_cases = vec![
|
||||||
("http://server:9000", "server:9000"),
|
("http://server:9000", "server:9000"),
|
||||||
("http://plain-server:80", "plain-server"),
|
("https://secure-server:443", "secure-server"), // Default HTTPS port is omitted
|
||||||
("http://plain-server", "plain-server"),
|
|
||||||
("https://secure-server:443", "secure-server"),
|
|
||||||
("http://192.168.1.100:8080", "192.168.1.100:8080"),
|
("http://192.168.1.100:8080", "192.168.1.100:8080"),
|
||||||
("https://secure-server", "secure-server"),
|
("https://secure-server", "secure-server"), // No port specified
|
||||||
];
|
];
|
||||||
|
|
||||||
for (url_str, expected_hostname) in test_cases {
|
for (url_str, expected_hostname) in test_cases {
|
||||||
@@ -5334,11 +5306,6 @@ mod tests {
|
|||||||
.with_span_list(true),
|
.with_span_list(true),
|
||||||
);
|
);
|
||||||
let _guard = tracing::subscriber::set_default(subscriber);
|
let _guard = tracing::subscriber::set_default(subscriber);
|
||||||
// The `recovery-monitor` span and the monitor's own log events are
|
|
||||||
// production callsites that sibling tests exercise from subscriber-less
|
|
||||||
// threads; without this they can be cached as `Interest::never()` and go
|
|
||||||
// silently missing here.
|
|
||||||
let _callsite_pin = crate::test_tracing::pin_callsite_interest_for_test();
|
|
||||||
|
|
||||||
let endpoint = Endpoint {
|
let endpoint = Endpoint {
|
||||||
url: url::Url::parse("http://127.0.0.1:59996/data").expect("endpoint URL should parse"),
|
url: url::Url::parse("http://127.0.0.1:59996/data").expect("endpoint URL should parse"),
|
||||||
@@ -5393,11 +5360,6 @@ mod tests {
|
|||||||
.with_span_list(true),
|
.with_span_list(true),
|
||||||
);
|
);
|
||||||
let _guard = tracing::subscriber::set_default(subscriber);
|
let _guard = tracing::subscriber::set_default(subscriber);
|
||||||
// The `recovery-monitor` span and the monitor's own log events are
|
|
||||||
// production callsites that sibling tests exercise from subscriber-less
|
|
||||||
// threads; without this they can be cached as `Interest::never()` and go
|
|
||||||
// silently missing here.
|
|
||||||
let _callsite_pin = crate::test_tracing::pin_callsite_interest_for_test();
|
|
||||||
|
|
||||||
let addr = "http://127.0.0.1:59997".to_string();
|
let addr = "http://127.0.0.1:59997".to_string();
|
||||||
let endpoint = Endpoint {
|
let endpoint = Endpoint {
|
||||||
|
|||||||
@@ -12,9 +12,7 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
use crate::cluster::rpc::client::{
|
use crate::cluster::rpc::client::{TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client};
|
||||||
AuthenticatedChannel, TonicInterceptor, gen_tonic_signature_interceptor, node_service_time_out_client,
|
|
||||||
};
|
|
||||||
use crate::cluster::rpc::set_tonic_mutation_body_digest;
|
use crate::cluster::rpc::set_tonic_mutation_body_digest;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -31,6 +29,7 @@ use std::time::Duration;
|
|||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use tonic::Request;
|
use tonic::Request;
|
||||||
use tonic::service::interceptor::InterceptedService;
|
use tonic::service::interceptor::InterceptedService;
|
||||||
|
use tonic::transport::Channel;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
/// Remote lock client implementation
|
/// Remote lock client implementation
|
||||||
@@ -79,7 +78,7 @@ impl RemoteClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<AuthenticatedChannel, TonicInterceptor>>> {
|
pub async fn get_client(&self) -> Result<NodeServiceClient<InterceptedService<Channel, TonicInterceptor>>> {
|
||||||
// P3-2 offline bypass (now covering the lock path too): fast-fail a peer already marked
|
// P3-2 offline bypass (now covering the lock path too): fast-fail a peer already marked
|
||||||
// offline instead of paying the connect timeout, so dsync reaches quorum sooner. Does not
|
// offline instead of paying the connect timeout, so dsync reaches quorum sooner. Does not
|
||||||
// change quorum; the self-healing re-probe keeps the peer recoverable.
|
// change quorum; the self-healing re-probe keeps the peer recoverable.
|
||||||
|
|||||||
+20
-199
@@ -37,9 +37,7 @@ use crate::{
|
|||||||
runtime::instance::{InstanceContext, bootstrap_ctx},
|
runtime::instance::{InstanceContext, bootstrap_ctx},
|
||||||
runtime::sources as runtime_sources,
|
runtime::sources as runtime_sources,
|
||||||
set_disk::{PreparedGetObjectMetadata, SetDisks},
|
set_disk::{PreparedGetObjectMetadata, SetDisks},
|
||||||
store::init_format::{
|
store::init_format::{check_format_erasure_values, get_format_erasure_in_quorum, load_format_erasure_all, save_format_file},
|
||||||
check_format_erasure_values, load_format_erasure_all, save_format_file, select_format_erasure_in_quorum,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use futures::{
|
use futures::{
|
||||||
future::join_all,
|
future::join_all,
|
||||||
@@ -949,7 +947,7 @@ impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
|||||||
|
|
||||||
#[tracing::instrument(skip(self))]
|
#[tracing::instrument(skip(self))]
|
||||||
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||||
let (disks, init_errs) = init_storage_disks_with_errors(
|
let (disks, _) = init_storage_disks_with_errors(
|
||||||
&self.endpoints.endpoints,
|
&self.endpoints.endpoints,
|
||||||
&DiskOption {
|
&DiskOption {
|
||||||
cleanup: false,
|
cleanup: false,
|
||||||
@@ -957,36 +955,15 @@ impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let (formats, mut errs) = load_format_erasure_all(&disks, true).await;
|
let (formats, errs) = load_format_erasure_all(&disks, true).await;
|
||||||
for (err, init_err) in errs.iter_mut().zip(init_errs) {
|
|
||||||
if init_err.is_some() {
|
|
||||||
*err = init_err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errs.iter().any(|err| {
|
|
||||||
matches!(
|
|
||||||
err,
|
|
||||||
Some(DiskError::InconsistentDisk | DiskError::CorruptedFormat | DiskError::CorruptedBackend)
|
|
||||||
)
|
|
||||||
}) {
|
|
||||||
return Ok((HealResultItem::default(), Some(StorageError::CorruptedFormat)));
|
|
||||||
}
|
|
||||||
if let Err(err) = check_format_erasure_values(&formats, self.set_drive_count) {
|
if let Err(err) = check_format_erasure_values(&formats, self.set_drive_count) {
|
||||||
info!("failed to check formats erasure values: {}", err);
|
info!("failed to check formats erasure values: {}", err);
|
||||||
return Ok((HealResultItem::default(), Some(err)));
|
return Ok((HealResultItem::default(), Some(err)));
|
||||||
}
|
}
|
||||||
let (ref_format, quorum_members) = match select_format_erasure_in_quorum(&formats, 0) {
|
let ref_format = match get_format_erasure_in_quorum(&formats) {
|
||||||
Ok((format, members)) if format.shared_identity() == self.format.shared_identity() => (format, members),
|
Ok(format) => format,
|
||||||
Ok(_) => return Ok((HealResultItem::default(), Some(StorageError::CorruptedFormat))),
|
|
||||||
Err(err) => return Ok((HealResultItem::default(), Some(err))),
|
Err(err) => return Ok((HealResultItem::default(), Some(err))),
|
||||||
};
|
};
|
||||||
if formats
|
|
||||||
.iter()
|
|
||||||
.zip(quorum_members)
|
|
||||||
.any(|(format, member)| format.is_some() && !member)
|
|
||||||
{
|
|
||||||
return Ok((HealResultItem::default(), Some(StorageError::CorruptedFormat)));
|
|
||||||
}
|
|
||||||
let mut res = HealResultItem {
|
let mut res = HealResultItem {
|
||||||
heal_item_type: HealItemType::Metadata.to_string(),
|
heal_item_type: HealItemType::Metadata.to_string(),
|
||||||
detail: "disk-format".to_string(),
|
detail: "disk-format".to_string(),
|
||||||
@@ -1008,6 +985,11 @@ impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
|||||||
return Ok((res, Some(StorageError::NoHealRequired)));
|
return Ok((res, Some(StorageError::NoHealRequired)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if !self.format.eq(&ref_format) {
|
||||||
|
// info!("format ({:?}) not eq ref_format ({:?})", self.format, ref_format);
|
||||||
|
// return Ok((res, Some(Error::new(DiskError::CorruptedFormat))));
|
||||||
|
// }
|
||||||
|
|
||||||
let (new_format_sets, _) = new_heal_format_sets(&ref_format, self.set_count, self.set_drive_count, &formats, &errs);
|
let (new_format_sets, _) = new_heal_format_sets(&ref_format, self.set_count, self.set_drive_count, &formats, &errs);
|
||||||
if !dry_run {
|
if !dry_run {
|
||||||
let mut tmp_new_formats = vec![None; self.set_count * self.set_drive_count];
|
let mut tmp_new_formats = vec![None; self.set_count * self.set_drive_count];
|
||||||
@@ -1316,7 +1298,7 @@ mod tests {
|
|||||||
assert_eq!(result, (Some(3), Some(1), Some(0)));
|
assert_eq!(result, (Some(3), Some(1), Some(0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn two_set_test_sets() -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
async fn multipart_listing_test_sets() -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
||||||
let format = FormatV3::new(2, 2);
|
let format = FormatV3::new(2, 2);
|
||||||
let mut temp_dirs = Vec::new();
|
let mut temp_dirs = Vec::new();
|
||||||
let mut all_endpoints = Vec::new();
|
let mut all_endpoints = Vec::new();
|
||||||
@@ -1357,8 +1339,8 @@ mod tests {
|
|||||||
Arc::new(RwLock::new(disks)),
|
Arc::new(RwLock::new(disks)),
|
||||||
2,
|
2,
|
||||||
1,
|
1,
|
||||||
set_index,
|
|
||||||
0,
|
0,
|
||||||
|
set_index,
|
||||||
endpoints,
|
endpoints,
|
||||||
format.clone(),
|
format.clone(),
|
||||||
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
|
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
|
||||||
@@ -1391,114 +1373,11 @@ mod tests {
|
|||||||
(temp_dirs, sets)
|
(temp_dirs, sets)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn set_format_heal_accepts_quorum_from_a_nonzero_set() {
|
|
||||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
|
||||||
|
|
||||||
let (result, err) = sets.disk_set[1]
|
|
||||||
.heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect("the second erasure set should load its own format quorum");
|
|
||||||
|
|
||||||
assert!(matches!(err, Some(StorageError::NoHealRequired)), "unexpected heal result: {err:?}");
|
|
||||||
assert_eq!(result.disk_count, 2);
|
|
||||||
assert_eq!(result.set_count, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn format_heal_rejects_foreign_majorities_at_set_and_pool_scopes() {
|
|
||||||
let (_temp_dirs, _canonical_format, sets) = setup_heal_format_sets(2, true).await;
|
|
||||||
let set_disks = set_level_heal_view(&sets).await;
|
|
||||||
|
|
||||||
let (_, set_err) = set_disks
|
|
||||||
.heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect("set format heal should report a typed mismatch");
|
|
||||||
assert!(
|
|
||||||
matches!(set_err, Some(StorageError::CorruptedFormat)),
|
|
||||||
"foreign set majority must not replace the cached format: {set_err:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let (_, pool_err) = sets
|
|
||||||
.heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect("pool format heal should report a typed mismatch");
|
|
||||||
assert!(
|
|
||||||
matches!(pool_err, Some(StorageError::CorruptedFormat)),
|
|
||||||
"foreign pool majority must not replace the cached format: {pool_err:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn pool_format_heal_rejects_a_wrong_slot_minority() {
|
|
||||||
let (_temp_dirs, canonical_format, sets) = setup_heal_format_sets(3, false).await;
|
|
||||||
let mut poisoned_format = canonical_format.clone();
|
|
||||||
poisoned_format.erasure.this = canonical_format.erasure.sets[0][0];
|
|
||||||
replace_heal_test_format(&sets, 2, &poisoned_format).await;
|
|
||||||
let probe_err = new_disk(
|
|
||||||
&sets.endpoints.endpoints.as_ref()[2],
|
|
||||||
&DiskOption {
|
|
||||||
cleanup: false,
|
|
||||||
health_check: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("a wrong-slot local format must fail disk initialization");
|
|
||||||
assert_eq!(probe_err, DiskError::InconsistentDisk);
|
|
||||||
|
|
||||||
let (_, pool_err) = sets
|
|
||||||
.heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect("pool format heal should report a typed slot mismatch");
|
|
||||||
assert!(
|
|
||||||
matches!(pool_err, Some(StorageError::CorruptedFormat)),
|
|
||||||
"a wrong-slot minority must not be reported as no-heal-required: {pool_err:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
read_heal_test_format(&sets, 2).await,
|
|
||||||
poisoned_format,
|
|
||||||
"format heal must not overwrite a wrong-slot disk"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn format_heal_rejects_a_foreign_minority_at_set_and_pool_scopes() {
|
|
||||||
let (_temp_dirs, canonical_format, sets) = setup_heal_format_sets(3, false).await;
|
|
||||||
let mut poisoned_format = canonical_format.clone();
|
|
||||||
poisoned_format.id = Uuid::new_v4();
|
|
||||||
poisoned_format.erasure.this = poisoned_format.erasure.sets[0][2];
|
|
||||||
replace_heal_test_format(&sets, 2, &poisoned_format).await;
|
|
||||||
let set_disks = set_level_heal_view(&sets).await;
|
|
||||||
|
|
||||||
let (_, set_err) = set_disks
|
|
||||||
.heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect("set format heal should report a typed identity mismatch");
|
|
||||||
assert!(
|
|
||||||
matches!(set_err, Some(StorageError::CorruptedFormat)),
|
|
||||||
"a foreign minority must not be reported as no-heal-required: {set_err:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let (_, pool_err) = sets
|
|
||||||
.heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect("pool format heal should report a typed identity mismatch");
|
|
||||||
assert!(
|
|
||||||
matches!(pool_err, Some(StorageError::CorruptedFormat)),
|
|
||||||
"a foreign minority must not be reported as no-heal-required: {pool_err:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
read_heal_test_format(&sets, 2).await,
|
|
||||||
poisoned_format,
|
|
||||||
"format heal must not overwrite a foreign disk"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn list_multipart_uploads_merges_all_sets_without_pagination_loss() {
|
async fn list_multipart_uploads_merges_all_sets_without_pagination_loss() {
|
||||||
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
|
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
|
||||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
let (_temp_dirs, sets) = multipart_listing_test_sets().await;
|
||||||
let bucket = format!("multipart-list-{}", Uuid::new_v4().simple());
|
let bucket = format!("multipart-list-{}", Uuid::new_v4().simple());
|
||||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||||
.await
|
.await
|
||||||
@@ -1737,15 +1616,11 @@ mod tests {
|
|||||||
// formatting the first `num_formatted` of them against a shared reference
|
// formatting the first `num_formatted` of them against a shared reference
|
||||||
// format and leaving the rest unformatted. Returns the live TempDir handles
|
// format and leaving the rest unformatted. Returns the live TempDir handles
|
||||||
// (must be kept alive), the reference format, and the assembled `Sets`.
|
// (must be kept alive), the reference format, and the assembled `Sets`.
|
||||||
// `disk_set` is intentionally empty: these tests only exercise paths that
|
// `disk_set` is intentionally empty: these tests only drive `heal_format`
|
||||||
// return before pool-level healing delegates into a set.
|
// with `dry_run == true`, which never touches `disk_set`.
|
||||||
async fn setup_heal_format_sets(num_formatted: usize, foreign_identity: bool) -> (Vec<tempfile::TempDir>, FormatV3, Sets) {
|
async fn setup_heal_format_sets(num_formatted: usize) -> (Vec<tempfile::TempDir>, FormatV3, Sets) {
|
||||||
const SET_DRIVE_COUNT: usize = 3;
|
const SET_DRIVE_COUNT: usize = 3;
|
||||||
let ref_format = FormatV3::new(1, SET_DRIVE_COUNT);
|
let ref_format = FormatV3::new(1, SET_DRIVE_COUNT);
|
||||||
let mut stored_format = ref_format.clone();
|
|
||||||
if foreign_identity {
|
|
||||||
stored_format.id = Uuid::new_v4();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut dirs = Vec::with_capacity(SET_DRIVE_COUNT);
|
let mut dirs = Vec::with_capacity(SET_DRIVE_COUNT);
|
||||||
let mut endpoints = Vec::with_capacity(SET_DRIVE_COUNT);
|
let mut endpoints = Vec::with_capacity(SET_DRIVE_COUNT);
|
||||||
@@ -1770,8 +1645,8 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("disk should be created");
|
.expect("disk should be created");
|
||||||
let mut disk_format = stored_format.clone();
|
let mut disk_format = ref_format.clone();
|
||||||
disk_format.erasure.this = stored_format.erasure.sets[0][i];
|
disk_format.erasure.this = ref_format.erasure.sets[0][i];
|
||||||
save_format_file(&Some(disk), &Some(disk_format))
|
save_format_file(&Some(disk), &Some(disk_format))
|
||||||
.await
|
.await
|
||||||
.expect("format should be saved");
|
.expect("format should be saved");
|
||||||
@@ -1802,60 +1677,6 @@ mod tests {
|
|||||||
(dirs, ref_format, sets)
|
(dirs, ref_format, sets)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_level_heal_view(sets: &Sets) -> Arc<SetDisks> {
|
|
||||||
let endpoints = sets.endpoints.endpoints.as_ref().clone();
|
|
||||||
let mut disks = Vec::with_capacity(endpoints.len());
|
|
||||||
for endpoint in &endpoints {
|
|
||||||
disks.push(Some(
|
|
||||||
new_disk(
|
|
||||||
endpoint,
|
|
||||||
&DiskOption {
|
|
||||||
cleanup: false,
|
|
||||||
health_check: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("fresh set-level disk handle should open"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
SetDisks::new(
|
|
||||||
"test-owner".to_string(),
|
|
||||||
Arc::new(RwLock::new(disks)),
|
|
||||||
endpoints.len(),
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
endpoints,
|
|
||||||
sets.format.clone(),
|
|
||||||
Vec::new(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn replace_heal_test_format(sets: &Sets, disk_index: usize, format: &FormatV3) {
|
|
||||||
let disk = new_disk(
|
|
||||||
&sets.endpoints.endpoints.as_ref()[disk_index],
|
|
||||||
&DiskOption {
|
|
||||||
cleanup: false,
|
|
||||||
health_check: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("heal test disk should open");
|
|
||||||
save_format_file(&Some(disk.clone()), &Some(format.clone()))
|
|
||||||
.await
|
|
||||||
.expect("poisoned test format should be written");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn read_heal_test_format(sets: &Sets, disk_index: usize) -> FormatV3 {
|
|
||||||
let path = std::path::Path::new(&sets.endpoints.endpoints.as_ref()[disk_index].get_file_path())
|
|
||||||
.join(crate::disk::RUSTFS_META_BUCKET)
|
|
||||||
.join(crate::disk::FORMAT_CONFIG_FILE);
|
|
||||||
let data = tokio::fs::read(path).await.expect("test format should be readable");
|
|
||||||
FormatV3::try_from(data.as_slice()).expect("test format should parse")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression for #956 (NoHealRequired path): with every disk already
|
// Regression for #956 (NoHealRequired path): with every disk already
|
||||||
// formatted, `heal_format` reports exactly one drive record per disk
|
// formatted, `heal_format` reports exactly one drive record per disk
|
||||||
// (N = set_count * set_drive_count), each carrying a real endpoint. Before
|
// (N = set_count * set_drive_count), each carrying a real endpoint. Before
|
||||||
@@ -1864,7 +1685,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn heal_format_no_heal_required_reports_one_record_per_disk() {
|
async fn heal_format_no_heal_required_reports_one_record_per_disk() {
|
||||||
let (_dirs, _ref_format, sets) = setup_heal_format_sets(3, false).await;
|
let (_dirs, _ref_format, sets) = setup_heal_format_sets(3).await;
|
||||||
|
|
||||||
let (res, err) = sets.heal_format(true).await.expect("heal_format should succeed");
|
let (res, err) = sets.heal_format(true).await.expect("heal_format should succeed");
|
||||||
// All disks formatted -> NoHealRequired early return, still returns `res`.
|
// All disks formatted -> NoHealRequired early return, still returns `res`.
|
||||||
@@ -1894,7 +1715,7 @@ mod tests {
|
|||||||
#[serial]
|
#[serial]
|
||||||
async fn heal_format_heal_path_reports_one_record_per_disk_aligned() {
|
async fn heal_format_heal_path_reports_one_record_per_disk_aligned() {
|
||||||
// Disks 0 and 1 formatted (quorum), disk 2 unformatted.
|
// Disks 0 and 1 formatted (quorum), disk 2 unformatted.
|
||||||
let (_dirs, _ref_format, sets) = setup_heal_format_sets(2, false).await;
|
let (_dirs, _ref_format, sets) = setup_heal_format_sets(2).await;
|
||||||
|
|
||||||
let (res, err) = sets.heal_format(true).await.expect("heal_format should succeed");
|
let (res, err) = sets.heal_format(true).await.expect("heal_format should succeed");
|
||||||
// Unformatted disk present -> heal path, not NoHealRequired.
|
// Unformatted disk present -> heal path, not NoHealRequired.
|
||||||
|
|||||||
@@ -1049,10 +1049,6 @@ impl LocalDiskWrapper {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn set_disk_id_state(&self, id: Option<Uuid>) {
|
|
||||||
*self.disk_id.write().await = id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current disk ID
|
/// Get the current disk ID
|
||||||
pub async fn get_current_disk_id(&self) -> Option<Uuid> {
|
pub async fn get_current_disk_id(&self) -> Option<Uuid> {
|
||||||
*self.disk_id.read().await
|
*self.disk_id.read().await
|
||||||
|
|||||||
@@ -381,291 +381,6 @@ fn classify_delete_volume_error(err: std::io::Error) -> DiskError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
struct EmptyDirectoryFrame {
|
|
||||||
path: PathBuf,
|
|
||||||
name_in_parent: std::ffi::CString,
|
|
||||||
entries: rustix::fs::Dir,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn empty_tree_io_error(err: rustix::io::Errno) -> std::io::Error {
|
|
||||||
match err {
|
|
||||||
rustix::io::Errno::NOTDIR | rustix::io::Errno::LOOP => std::io::Error::from(ErrorKind::DirectoryNotEmpty),
|
|
||||||
_ => err.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn remove_empty_directory_tree_unix_with(
|
|
||||||
root: &Path,
|
|
||||||
mut before_descend: impl FnMut(&Path) -> std::io::Result<()>,
|
|
||||||
mut before_remove: impl FnMut(&Path) -> std::io::Result<()>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
use rustix::{
|
|
||||||
fs::{AtFlags, Dir, Mode, OFlags, fstat, open, openat, statat, unlinkat},
|
|
||||||
io::Errno,
|
|
||||||
};
|
|
||||||
use std::os::fd::AsFd;
|
|
||||||
use std::os::unix::ffi::OsStrExt;
|
|
||||||
|
|
||||||
let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
|
|
||||||
let root_parent_path = root
|
|
||||||
.parent()
|
|
||||||
.ok_or_else(|| std::io::Error::from(ErrorKind::DirectoryNotEmpty))?;
|
|
||||||
let root_name = root
|
|
||||||
.file_name()
|
|
||||||
.ok_or_else(|| std::io::Error::from(ErrorKind::DirectoryNotEmpty))?
|
|
||||||
.as_bytes();
|
|
||||||
let root_name = std::ffi::CString::new(root_name).map_err(|_| std::io::Error::from(ErrorKind::DirectoryNotEmpty))?;
|
|
||||||
let root_parent = open(root_parent_path, flags, Mode::empty()).map_err(empty_tree_io_error)?;
|
|
||||||
let root_fd = openat(&root_parent, root_name.as_c_str(), flags, Mode::empty()).map_err(empty_tree_io_error)?;
|
|
||||||
// Each frame owns one directory iterator/FD, so memory and descriptors are
|
|
||||||
// bounded by path depth rather than by the number of empty remnants.
|
|
||||||
let mut stack = vec![EmptyDirectoryFrame {
|
|
||||||
path: root.to_path_buf(),
|
|
||||||
name_in_parent: root_name,
|
|
||||||
entries: Dir::new(root_fd).map_err(empty_tree_io_error)?,
|
|
||||||
}];
|
|
||||||
|
|
||||||
while let Some(mut frame) = stack.pop() {
|
|
||||||
let next_child = loop {
|
|
||||||
let Some(entry) = frame.entries.next() else {
|
|
||||||
break None;
|
|
||||||
};
|
|
||||||
let entry = entry.map_err(std::io::Error::from)?;
|
|
||||||
let name = entry.file_name();
|
|
||||||
if name.to_bytes() == b"." || name.to_bytes() == b".." {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = name.to_owned();
|
|
||||||
let child_path = frame.path.join(std::ffi::OsStr::from_bytes(name.as_bytes()));
|
|
||||||
before_descend(&child_path)?;
|
|
||||||
break Some((child_path, name));
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some((child_path, name)) = next_child {
|
|
||||||
let parent = frame.entries.fd().map_err(std::io::Error::from)?;
|
|
||||||
let child = match openat(parent, name.as_c_str(), flags, Mode::empty()) {
|
|
||||||
Ok(child) => child,
|
|
||||||
// A concurrent cleanup may remove an empty child after readdir
|
|
||||||
// returns it. Resume the parent instead of treating the whole
|
|
||||||
// bucket as missing and leaving its root behind.
|
|
||||||
Err(Errno::NOENT) => {
|
|
||||||
stack.push(frame);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => return Err(empty_tree_io_error(err)),
|
|
||||||
};
|
|
||||||
stack.push(frame);
|
|
||||||
stack.push(EmptyDirectoryFrame {
|
|
||||||
path: child_path,
|
|
||||||
name_in_parent: name,
|
|
||||||
entries: Dir::new(child).map_err(empty_tree_io_error)?,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
before_remove(&frame.path)?;
|
|
||||||
let parent = if let Some(parent) = stack.last() {
|
|
||||||
parent.entries.fd().map_err(std::io::Error::from)?
|
|
||||||
} else {
|
|
||||||
root_parent.as_fd()
|
|
||||||
};
|
|
||||||
let expected = fstat(frame.entries.fd().map_err(std::io::Error::from)?).map_err(empty_tree_io_error)?;
|
|
||||||
let current = statat(parent, frame.name_in_parent.as_c_str(), AtFlags::SYMLINK_NOFOLLOW).map_err(empty_tree_io_error)?;
|
|
||||||
if current.st_dev != expected.st_dev || current.st_ino != expected.st_ino {
|
|
||||||
return Err(std::io::Error::from(ErrorKind::DirectoryNotEmpty));
|
|
||||||
}
|
|
||||||
match unlinkat(parent, frame.name_in_parent.as_c_str(), AtFlags::REMOVEDIR).map_err(empty_tree_io_error) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
async fn remove_empty_directory_tree_with(
|
|
||||||
root: &Path,
|
|
||||||
before_descend: impl FnMut(&Path) -> std::io::Result<()>,
|
|
||||||
before_remove: impl FnMut(&Path) -> std::io::Result<()>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
remove_empty_directory_tree_unix_with(root, before_descend, before_remove)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
async fn remove_empty_directory_tree(root: &Path) -> std::io::Result<()> {
|
|
||||||
let root = root.to_path_buf();
|
|
||||||
tokio::task::spawn_blocking(move || remove_empty_directory_tree_unix_with(&root, |_| Ok(()), |_| Ok(()))).await?
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
#[derive(Debug)]
|
|
||||||
struct LockedEmptyDirectory {
|
|
||||||
handle: winapi_util::Handle,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn validate_windows_empty_directory(file_attributes: u64) -> std::io::Result<()> {
|
|
||||||
const FILE_ATTRIBUTE_DIRECTORY: u64 = 0x10;
|
|
||||||
const FILE_ATTRIBUTE_REPARSE_POINT: u64 = 0x400;
|
|
||||||
|
|
||||||
if file_attributes & FILE_ATTRIBUTE_DIRECTORY == 0 || file_attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
|
||||||
return Err(std::io::Error::from(ErrorKind::DirectoryNotEmpty));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
async fn lock_windows_empty_directory(path: &Path, canonical_root: Option<&Path>) -> std::io::Result<LockedEmptyDirectory> {
|
|
||||||
use std::os::windows::fs::OpenOptionsExt;
|
|
||||||
use windows_sys::Win32::{
|
|
||||||
Foundation::GENERIC_READ,
|
|
||||||
Storage::FileSystem::{DELETE, FILE_SHARE_READ},
|
|
||||||
};
|
|
||||||
|
|
||||||
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
|
|
||||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
|
||||||
|
|
||||||
let path = path.to_path_buf();
|
|
||||||
let canonical_root = canonical_root.map(Path::to_path_buf);
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
let file = std::fs::OpenOptions::new()
|
|
||||||
.access_mode(GENERIC_READ | DELETE)
|
|
||||||
.share_mode(FILE_SHARE_READ)
|
|
||||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
|
||||||
.open(&path)?;
|
|
||||||
let handle = winapi_util::Handle::from_file(file);
|
|
||||||
let info = winapi_util::file::information(&handle)?;
|
|
||||||
validate_windows_empty_directory(info.file_attributes())?;
|
|
||||||
if let Some(canonical_root) = canonical_root {
|
|
||||||
let canonical_path = std::fs::canonicalize(path)?;
|
|
||||||
if !canonical_path.starts_with(canonical_root) {
|
|
||||||
return Err(std::io::Error::from(ErrorKind::DirectoryNotEmpty));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok::<_, std::io::Error>(LockedEmptyDirectory { handle })
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
// SAFETY: This helper only passes an owned live handle and one initialized
|
|
||||||
// FILE_DISPOSITION_INFO to the synchronous Windows deletion API.
|
|
||||||
#[allow(unsafe_code)]
|
|
||||||
async fn remove_windows_empty_directory(directory: LockedEmptyDirectory) -> std::io::Result<()> {
|
|
||||||
tokio::task::spawn_blocking(move || {
|
|
||||||
use std::os::windows::io::AsRawHandle;
|
|
||||||
use windows_sys::Win32::Storage::FileSystem::{FILE_DISPOSITION_INFO, FileDispositionInfo, SetFileInformationByHandle};
|
|
||||||
|
|
||||||
let disposition = FILE_DISPOSITION_INFO { DeleteFile: true };
|
|
||||||
let disposition_size = u32::try_from(std::mem::size_of_val(&disposition))
|
|
||||||
.map_err(|_| std::io::Error::other("FILE_DISPOSITION_INFO size exceeds the Win32 API limit"))?;
|
|
||||||
let handle = directory.handle.as_raw_handle();
|
|
||||||
// SAFETY: `handle` is owned by `directory` and stays live for this synchronous
|
|
||||||
// call. `disposition` is initialized with the exact structure and byte size
|
|
||||||
// required by `FileDispositionInfo`; Windows does not retain the pointer.
|
|
||||||
let deleted = unsafe {
|
|
||||||
SetFileInformationByHandle(handle, FileDispositionInfo, std::ptr::from_ref(&disposition).cast(), disposition_size)
|
|
||||||
};
|
|
||||||
if deleted == 0 {
|
|
||||||
Err(std::io::Error::last_os_error())
|
|
||||||
} else {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
struct WindowsEmptyDirectoryFrame {
|
|
||||||
path: PathBuf,
|
|
||||||
directory: LockedEmptyDirectory,
|
|
||||||
entries: fs::ReadDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
async fn remove_empty_directory_tree_with(
|
|
||||||
root: &Path,
|
|
||||||
mut before_descend: impl FnMut(&Path) -> std::io::Result<()>,
|
|
||||||
mut before_remove: impl FnMut(&Path) -> std::io::Result<()>,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
let root_directory = lock_windows_empty_directory(root, None).await?;
|
|
||||||
let canonical_root = fs::canonicalize(root).await?;
|
|
||||||
let root_entries = fs::read_dir(root).await?;
|
|
||||||
|
|
||||||
// Holding each validated directory without delete sharing keeps its path
|
|
||||||
// generation stable until handle-relative deletion. State is O(depth).
|
|
||||||
let mut stack = vec![WindowsEmptyDirectoryFrame {
|
|
||||||
path: root.to_path_buf(),
|
|
||||||
directory: root_directory,
|
|
||||||
entries: root_entries,
|
|
||||||
}];
|
|
||||||
|
|
||||||
while let Some(mut frame) = stack.pop() {
|
|
||||||
match frame.entries.next_entry().await {
|
|
||||||
Ok(Some(entry)) => {
|
|
||||||
let child = entry.path();
|
|
||||||
before_descend(&child)?;
|
|
||||||
let child_directory = match lock_windows_empty_directory(&child, Some(&canonical_root)).await {
|
|
||||||
Ok(directory) => directory,
|
|
||||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
|
||||||
stack.push(frame);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
};
|
|
||||||
let child_entries = match fs::read_dir(&child).await {
|
|
||||||
Ok(entries) => entries,
|
|
||||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
|
||||||
stack.push(frame);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(err) if err.kind() == ErrorKind::NotADirectory => {
|
|
||||||
return Err(std::io::Error::from(ErrorKind::DirectoryNotEmpty));
|
|
||||||
}
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
};
|
|
||||||
stack.push(frame);
|
|
||||||
stack.push(WindowsEmptyDirectoryFrame {
|
|
||||||
path: child,
|
|
||||||
directory: child_directory,
|
|
||||||
entries: child_entries,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
before_remove(&frame.path)?;
|
|
||||||
drop(frame.entries);
|
|
||||||
match remove_windows_empty_directory(frame.directory).await {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
|
||||||
Err(err) if err.kind() == ErrorKind::NotADirectory => {
|
|
||||||
return Err(std::io::Error::from(ErrorKind::DirectoryNotEmpty));
|
|
||||||
}
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
async fn remove_empty_directory_tree(root: &Path) -> std::io::Result<()> {
|
|
||||||
remove_empty_directory_tree_with(root, |_| Ok(()), |_| Ok(())).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(not(unix), not(windows)))]
|
|
||||||
async fn remove_empty_directory_tree(root: &Path) -> std::io::Result<()> {
|
|
||||||
fs::remove_dir(root).await
|
|
||||||
}
|
|
||||||
|
|
||||||
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
const LOG_COMPONENT_ECSTORE: &str = "ecstore";
|
||||||
const LOG_SUBSYSTEM_DISK_LOCAL: &str = "disk_local";
|
const LOG_SUBSYSTEM_DISK_LOCAL: &str = "disk_local";
|
||||||
const EVENT_DISK_LOCAL_STARTUP_CLEANUP: &str = "disk_local_startup_cleanup";
|
const EVENT_DISK_LOCAL_STARTUP_CLEANUP: &str = "disk_local_startup_cleanup";
|
||||||
@@ -1841,8 +1556,6 @@ static RENAME_DATA_FAIL_COMMIT_RENAME: std::sync::Mutex<Option<String>> = std::s
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static LOCAL_INLINE_ROLLBACK_HARDLINK_FAILURE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
|
static LOCAL_INLINE_ROLLBACK_HARDLINK_FAILURE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static RENAME_DATA_REMOVE_DST_BASE_BEFORE_COMMIT: std::sync::Mutex<Option<(String, PathBuf)>> = std::sync::Mutex::new(None);
|
|
||||||
#[cfg(test)]
|
|
||||||
static DELETE_VERSION_FAIL_AFTER_DATA_STAGED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
static DELETE_VERSION_FAIL_AFTER_DATA_STAGED: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
static DELETE_VERSION_FAIL_AFTER_COMMIT: std::sync::Mutex<Vec<(PathBuf, String)>> = std::sync::Mutex::new(Vec::new());
|
static DELETE_VERSION_FAIL_AFTER_COMMIT: std::sync::Mutex<Vec<(PathBuf, String)>> = std::sync::Mutex::new(Vec::new());
|
||||||
@@ -1875,13 +1588,6 @@ fn set_local_inline_rollback_hardlink_failure(dst_path: &Path) {
|
|||||||
.expect("test failpoint lock should not be poisoned") = Some(dst_path.to_path_buf());
|
.expect("test failpoint lock should not be poisoned") = Some(dst_path.to_path_buf());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn set_rename_data_remove_dst_base_before_commit(dst_path: &str, dst_base: &Path) {
|
|
||||||
*RENAME_DATA_REMOVE_DST_BASE_BEFORE_COMMIT
|
|
||||||
.lock()
|
|
||||||
.expect("test failpoint lock should not be poisoned") = Some((dst_path.to_string(), dst_base.to_path_buf()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn set_delete_version_fail_after_data_staged(path: &str) {
|
fn set_delete_version_fail_after_data_staged(path: &str) {
|
||||||
DELETE_VERSION_FAIL_AFTER_DATA_STAGED
|
DELETE_VERSION_FAIL_AFTER_DATA_STAGED
|
||||||
@@ -1950,19 +1656,6 @@ fn should_fail_local_inline_rollback_hardlink(dst_path: &Path) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn remove_dst_base_before_commit(dst_path: &str) -> std::io::Result<()> {
|
|
||||||
let mut target = RENAME_DATA_REMOVE_DST_BASE_BEFORE_COMMIT
|
|
||||||
.lock()
|
|
||||||
.expect("test failpoint lock should not be poisoned");
|
|
||||||
let Some((_, base)) = target.as_ref().filter(|(target_path, _)| target_path == dst_path) else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
std::fs::remove_dir_all(base)?;
|
|
||||||
target.take();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn should_fail_after_delete_data_staged(path: &str) -> bool {
|
fn should_fail_after_delete_data_staged(path: &str) -> bool {
|
||||||
let mut targets = DELETE_VERSION_FAIL_AFTER_DATA_STAGED
|
let mut targets = DELETE_VERSION_FAIL_AFTER_DATA_STAGED
|
||||||
@@ -2012,11 +1705,6 @@ fn should_fail_local_inline_rollback_hardlink(_dst_path: &Path) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(test))]
|
|
||||||
fn remove_dst_base_before_commit(_dst_path: &str) -> std::io::Result<()> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(test))]
|
#[cfg(not(test))]
|
||||||
fn should_fail_after_delete_data_staged(_path: &str) -> bool {
|
fn should_fail_after_delete_data_staged(_path: &str) -> bool {
|
||||||
false
|
false
|
||||||
@@ -4555,11 +4243,9 @@ impl LocalDisk {
|
|||||||
let tmp_path = Self::meta_path(root, RUSTFS_META_TMP_BUCKET);
|
let tmp_path = Self::meta_path(root, RUSTFS_META_TMP_BUCKET);
|
||||||
let tmp_old_path = Self::meta_path(root, RUSTFS_META_TMP_OLD_BUCKET).join(Uuid::new_v4().to_string());
|
let tmp_old_path = Self::meta_path(root, RUSTFS_META_TMP_OLD_BUCKET).join(Uuid::new_v4().to_string());
|
||||||
|
|
||||||
rename_all_ignore_missing_source(&tmp_path, &tmp_old_path, root)
|
rename_all(&tmp_path, &tmp_old_path, root).await.inspect_err(|err| {
|
||||||
.await
|
log_startup_disk_error("cleanup_tmp_rename_all", &tmp_path, err);
|
||||||
.inspect_err(|err| {
|
})?;
|
||||||
log_startup_disk_error("cleanup_tmp_rename_all", &tmp_path, err);
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let tmp_deleted_path = Self::meta_path(root, RUSTFS_META_TMP_DELETED_BUCKET);
|
let tmp_deleted_path = Self::meta_path(root, RUSTFS_META_TMP_DELETED_BUCKET);
|
||||||
tokio::fs::create_dir_all(&tmp_deleted_path).await.inspect_err(|err| {
|
tokio::fs::create_dir_all(&tmp_deleted_path).await.inspect_err(|err| {
|
||||||
@@ -5078,7 +4764,7 @@ impl LocalDisk {
|
|||||||
Ok((buf, mtime))
|
Ok((buf, mtime))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
async fn read_metadata_with_dmtime(&self, file_path: impl AsRef<Path>) -> Result<(Vec<u8>, Option<OffsetDateTime>)> {
|
async fn read_metadata_with_dmtime(&self, file_path: impl AsRef<Path>) -> Result<(Vec<u8>, Option<OffsetDateTime>)> {
|
||||||
check_path_length(file_path.as_ref().to_string_lossy().as_ref())?;
|
check_path_length(file_path.as_ref().to_string_lossy().as_ref())?;
|
||||||
|
|
||||||
@@ -5121,7 +4807,7 @@ impl LocalDisk {
|
|||||||
Ok((data, modtime))
|
Ok((data, modtime))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
async fn read_all_data(&self, volume: &str, volume_dir: impl AsRef<Path>, file_path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
async fn read_all_data(&self, volume: &str, volume_dir: impl AsRef<Path>, file_path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||||
// TODO: timeout support
|
// TODO: timeout support
|
||||||
let (data, _) = self.read_all_data_with_dmtime(volume, volume_dir, file_path).await?;
|
let (data, _) = self.read_all_data_with_dmtime(volume, volume_dir, file_path).await?;
|
||||||
@@ -7673,10 +7359,6 @@ impl DiskAPI for LocalDisk {
|
|||||||
dst_path: &str,
|
dst_path: &str,
|
||||||
) -> Result<RenameDataResp> {
|
) -> Result<RenameDataResp> {
|
||||||
crate::hp_guard!("LocalDisk::rename_data");
|
crate::hp_guard!("LocalDisk::rename_data");
|
||||||
// A non-force DeleteBucket must not remove a directory while a local
|
|
||||||
// object commit is publishing into it. The peer's empty scan remains
|
|
||||||
// optimistic; this guard establishes the local commit/delete order.
|
|
||||||
let _volume_mutation_guard = os::disk_volume_mutation_lock(&self.root, dst_volume).read_owned().await;
|
|
||||||
if fi.is_legacy_indexed_delete_marker() {
|
if fi.is_legacy_indexed_delete_marker() {
|
||||||
fi.erasure.index = 0;
|
fi.erasure.index = 0;
|
||||||
}
|
}
|
||||||
@@ -7873,7 +7555,6 @@ impl DiskAPI for LocalDisk {
|
|||||||
// sequential version did.
|
// sequential version did.
|
||||||
tmp_meta_res?;
|
tmp_meta_res?;
|
||||||
shard_sync_res?;
|
shard_sync_res?;
|
||||||
remove_dst_base_before_commit(dst_path).map_err(to_file_error)?;
|
|
||||||
|
|
||||||
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref()
|
if let Some((src_data_path, dst_data_path)) = has_data_dir_path.as_ref()
|
||||||
&& let Err(err) = rename_all(src_data_path, dst_data_path, &skip_parent).await
|
&& let Err(err) = rename_all(src_data_path, dst_data_path, &skip_parent).await
|
||||||
@@ -8133,7 +7814,6 @@ impl DiskAPI for LocalDisk {
|
|||||||
xlmeta.add_version(fi)?;
|
xlmeta.add_version(fi)?;
|
||||||
let version_signature = rename_data_versions_signature(&xlmeta);
|
let version_signature = rename_data_versions_signature(&xlmeta);
|
||||||
let new_buf = xlmeta.marshal_msg()?;
|
let new_buf = xlmeta.marshal_msg()?;
|
||||||
remove_dst_base_before_commit(&dst_path_for_failpoint).map_err(to_file_error)?;
|
|
||||||
|
|
||||||
// Write new xl.meta + rename. Inline objects carry their data
|
// Write new xl.meta + rename. Inline objects carry their data
|
||||||
// inside xl.meta, so this whole sequence is a metadata commit:
|
// inside xl.meta, so this whole sequence is a metadata commit:
|
||||||
@@ -8158,11 +7838,9 @@ impl DiskAPI for LocalDisk {
|
|||||||
{
|
{
|
||||||
let old_path = dst_parent.join(old_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
let old_path = dst_parent.join(old_dir.to_string()).join(STORAGE_FORMAT_FILE_BACKUP);
|
||||||
let old_parent = old_path.parent().map(|p| p.to_path_buf());
|
let old_parent = old_path.parent().map(|p| p.to_path_buf());
|
||||||
let _old_parent_guard = old_parent
|
if let Some(ref old_parent) = old_parent {
|
||||||
.as_deref()
|
std::fs::create_dir_all(old_parent)?;
|
||||||
.map(|parent| os::mkdir_all_below_existing_base_std(parent, &bucket_dir))
|
}
|
||||||
.transpose()
|
|
||||||
.map_err(to_file_error)?;
|
|
||||||
// This rollback backup is the sole restore source for a later
|
// This rollback backup is the sole restore source for a later
|
||||||
// undo_write when the set-level write quorum fails. Persist it as
|
// undo_write when the set-level write quorum fails. Persist it as
|
||||||
// durably as the new xl.meta written above (and as the non-inline
|
// durably as the new xl.meta written above (and as the non-inline
|
||||||
@@ -8188,12 +7866,6 @@ impl DiskAPI for LocalDisk {
|
|||||||
local_rollback_path = Some(create_local_inline_rollback_backup(&dst, &src, old_metadata)?);
|
local_rollback_path = Some(create_local_inline_rollback_backup(&dst, &src, old_metadata)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
let _commit_parent_guard = if let Some(parent) = dst.parent() {
|
|
||||||
Some(os::mkdir_all_below_existing_base_std(parent, &bucket_dir).map_err(to_file_error)?)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let commit_result = if should_fail_commit_rename(&dst_path_for_failpoint) {
|
let commit_result = if should_fail_commit_rename(&dst_path_for_failpoint) {
|
||||||
Err(std::io::Error::other("test fail during metadata commit rename"))
|
Err(std::io::Error::other("test fail during metadata commit rename"))
|
||||||
} else {
|
} else {
|
||||||
@@ -8202,8 +7874,7 @@ impl DiskAPI for LocalDisk {
|
|||||||
Err(err) if err.kind() == ErrorKind::NotFound && !src.exists() => Ok(()),
|
Err(err) if err.kind() == ErrorKind::NotFound && !src.exists() => Ok(()),
|
||||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||||
if let Some(parent) = dst.parent() {
|
if let Some(parent) = dst.parent() {
|
||||||
let _parent_guard =
|
std::fs::create_dir_all(parent)?;
|
||||||
os::mkdir_all_below_existing_base_std(parent, &bucket_dir).map_err(to_file_error)?;
|
|
||||||
}
|
}
|
||||||
std::fs::rename(&src, &dst).map_err(to_file_error)?;
|
std::fs::rename(&src, &dst).map_err(to_file_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -9120,16 +8791,18 @@ impl DiskAPI for LocalDisk {
|
|||||||
#[tracing::instrument(level = "trace", skip_all)]
|
#[tracing::instrument(level = "trace", skip_all)]
|
||||||
async fn delete_volume(&self, volume: &str, force_delete: bool) -> Result<()> {
|
async fn delete_volume(&self, volume: &str, force_delete: bool) -> Result<()> {
|
||||||
let p = self.get_bucket_path(volume)?;
|
let p = self.get_bucket_path(volume)?;
|
||||||
let _volume_mutation_guard = os::disk_volume_mutation_lock(&self.root, volume).write_owned().await;
|
|
||||||
|
|
||||||
// Non-force removes empty directory remnants children-first with
|
// Non-force is non-recursive: `remove_dir` (rmdir) fails atomically with
|
||||||
// non-recursive rmdir calls. A file that exists during the scan, or
|
// `DirectoryNotEmpty` -> VolumeNotEmpty if the bucket still holds any
|
||||||
// appears before its parent is removed, fails closed with
|
// object data, so a misclassified "dangling" bucket on the heal path
|
||||||
// VolumeNotEmpty. Only an explicit force delete removes recursively.
|
// (or a non-force S3 DeleteBucket on a populated bucket) can never be
|
||||||
|
// recursively wiped. Only an explicit `force_delete` (e.g. S3 force
|
||||||
|
// bucket delete) removes recursively. Mirrors MinIO's
|
||||||
|
// xlStorage.DeleteVol (Remove vs RemoveAll). (backlog#799 B1)
|
||||||
let res = if force_delete {
|
let res = if force_delete {
|
||||||
fs::remove_dir_all(&p).await
|
fs::remove_dir_all(&p).await
|
||||||
} else {
|
} else {
|
||||||
remove_empty_directory_tree(&p).await
|
fs::remove_dir(&p).await
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(err) = res {
|
if let Err(err) = res {
|
||||||
@@ -9388,35 +9061,6 @@ mod test {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
#[test]
|
|
||||||
fn windows_empty_tree_requires_non_reparse_directory() {
|
|
||||||
validate_windows_empty_directory(0x10).expect("ordinary directories should be accepted");
|
|
||||||
assert!(validate_windows_empty_directory(0).is_err());
|
|
||||||
assert!(validate_windows_empty_directory(0x10 | 0x400).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn windows_empty_tree_blocks_replacement_at_final_delete_boundary() {
|
|
||||||
let root = tempfile::tempdir().expect("temporary root should be created");
|
|
||||||
let bucket_path = root.path().join("bucket");
|
|
||||||
let child_path = bucket_path.join("child");
|
|
||||||
fs::create_dir_all(&child_path).await.expect("bucket child should be created");
|
|
||||||
let canonical_root = fs::canonicalize(&bucket_path).await.expect("bucket path should canonicalize");
|
|
||||||
let directory = lock_windows_empty_directory(&child_path, Some(&canonical_root))
|
|
||||||
.await
|
|
||||||
.expect("child directory should be locked");
|
|
||||||
|
|
||||||
std::fs::rename(&child_path, bucket_path.join("replacement"))
|
|
||||||
.expect_err("the locked directory must not be replaceable at the final deletion boundary");
|
|
||||||
remove_windows_empty_directory(directory)
|
|
||||||
.await
|
|
||||||
.expect("handle-relative deletion should remove the locked directory");
|
|
||||||
|
|
||||||
assert!(!child_path.exists(), "the exact locked directory should be removed");
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn ensure_test_volume(disk: &LocalDisk, volume: &str) {
|
async fn ensure_test_volume(disk: &LocalDisk, volume: &str) {
|
||||||
match disk.make_volume(volume).await {
|
match disk.make_volume(volume).await {
|
||||||
Ok(()) | Err(DiskError::VolumeExists) => {}
|
Ok(()) | Err(DiskError::VolumeExists) => {}
|
||||||
@@ -11143,72 +10787,6 @@ mod test {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial(rename_data_deleted_bucket)]
|
|
||||||
async fn rename_data_non_inline_does_not_recreate_bucket_deleted_before_commit() {
|
|
||||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
|
||||||
let endpoint = Endpoint::try_from(dir.path().to_string_lossy().as_ref()).expect("endpoint should parse");
|
|
||||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
|
||||||
let bucket = "deleted-before-non-inline-commit";
|
|
||||||
let object = "prefix/object";
|
|
||||||
let tmp_object = "tmp-non-inline-delete-race";
|
|
||||||
let data_dir = Uuid::parse_str("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").expect("data dir should parse");
|
|
||||||
ensure_test_volume(&disk, bucket).await;
|
|
||||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
|
||||||
|
|
||||||
let staged_data = disk
|
|
||||||
.get_object_path(RUSTFS_META_TMP_BUCKET, &format!("{tmp_object}/{data_dir}/part.1"))
|
|
||||||
.expect("staged data path should resolve");
|
|
||||||
fs::create_dir_all(staged_data.parent().expect("staged data should have a parent"))
|
|
||||||
.await
|
|
||||||
.expect("staged data parent should be created");
|
|
||||||
fs::write(&staged_data, b"payload")
|
|
||||||
.await
|
|
||||||
.expect("staged shard should be written");
|
|
||||||
|
|
||||||
let bucket_path = disk.get_bucket_path(bucket).expect("bucket path should resolve");
|
|
||||||
set_rename_data_remove_dst_base_before_commit(object, &bucket_path);
|
|
||||||
let fi = test_file_info(object, Uuid::new_v4(), Some(data_dir), None);
|
|
||||||
let err = disk
|
|
||||||
.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object)
|
|
||||||
.await
|
|
||||||
.expect_err("commit must fail after the destination bucket is deleted");
|
|
||||||
|
|
||||||
assert_eq!(err, DiskError::FileNotFound);
|
|
||||||
assert!(!bucket_path.exists(), "rename_data must not recreate the deleted bucket");
|
|
||||||
assert!(staged_data.exists(), "failed commit must preserve the staged shard");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial(rename_data_deleted_bucket)]
|
|
||||||
async fn rename_data_inline_does_not_recreate_bucket_deleted_before_commit() {
|
|
||||||
let dir = tempfile::tempdir().expect("temp dir should be created");
|
|
||||||
let endpoint = Endpoint::try_from(dir.path().to_string_lossy().as_ref()).expect("endpoint should parse");
|
|
||||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
|
||||||
let bucket = "deleted-before-inline-commit";
|
|
||||||
let object = "prefix/object";
|
|
||||||
let tmp_object = "tmp-inline-delete-race";
|
|
||||||
ensure_test_volume(&disk, bucket).await;
|
|
||||||
ensure_test_volume(&disk, RUSTFS_META_TMP_BUCKET).await;
|
|
||||||
|
|
||||||
let bucket_path = disk.get_bucket_path(bucket).expect("bucket path should resolve");
|
|
||||||
set_rename_data_remove_dst_base_before_commit(object, &bucket_path);
|
|
||||||
let fi = test_file_info(object, Uuid::new_v4(), None, Some(Bytes::from_static(b"inline payload")));
|
|
||||||
let err = disk
|
|
||||||
.rename_data(RUSTFS_META_TMP_BUCKET, tmp_object, fi, bucket, object)
|
|
||||||
.await
|
|
||||||
.expect_err("inline commit must fail after the destination bucket is deleted");
|
|
||||||
|
|
||||||
assert_eq!(err, DiskError::FileNotFound);
|
|
||||||
assert!(!bucket_path.exists(), "inline rename_data must not recreate the deleted bucket");
|
|
||||||
assert!(
|
|
||||||
disk.get_object_path(RUSTFS_META_TMP_BUCKET, &format!("{tmp_object}/{STORAGE_FORMAT_FILE}"))
|
|
||||||
.expect("staged metadata path should resolve")
|
|
||||||
.exists(),
|
|
||||||
"failed inline commit must preserve staged metadata"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_resolve_durability_mode_mapping() {
|
fn test_resolve_durability_mode_mapping() {
|
||||||
// Default: nothing set -> strict (current main behavior).
|
// Default: nothing set -> strict (current main behavior).
|
||||||
@@ -13067,19 +12645,6 @@ mod test {
|
|||||||
assert!(format_info.last_check.is_none(), "cached format timestamp should be cleared");
|
assert!(format_info.last_check.is_none(), "cached format timestamp should be cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn cleanup_tmp_on_startup_allows_missing_tmp_directory() {
|
|
||||||
use tempfile::tempdir;
|
|
||||||
|
|
||||||
let dir = tempdir().expect("operation should succeed");
|
|
||||||
|
|
||||||
LocalDisk::cleanup_tmp_on_startup(dir.path(), Arc::new(AtomicU32::new(0)), Arc::new(Notify::new()))
|
|
||||||
.await
|
|
||||||
.expect("missing temporary directory should already be clean");
|
|
||||||
|
|
||||||
assert!(LocalDisk::meta_path(dir.path(), RUSTFS_META_TMP_DELETED_BUCKET).exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cleanup_tmp_on_startup_moves_existing_tmp_and_recreates_trash() {
|
async fn cleanup_tmp_on_startup_moves_existing_tmp_and_recreates_trash() {
|
||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
@@ -14952,154 +14517,6 @@ mod test {
|
|||||||
let _ = fs::remove_dir_all(&test_dir).await;
|
let _ = fs::remove_dir_all(&test_dir).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn delete_volume_non_force_removes_nested_empty_directories() {
|
|
||||||
let root = tempfile::tempdir().expect("temporary disk root should be created");
|
|
||||||
let endpoint = Endpoint::try_from(root.path().to_string_lossy().as_ref()).expect("endpoint should parse");
|
|
||||||
let disk = LocalDisk::new(&endpoint, false).await.expect("local disk should be created");
|
|
||||||
let bucket = "nested-empty-bucket";
|
|
||||||
|
|
||||||
disk.make_volume(bucket).await.expect("bucket should be created");
|
|
||||||
fs::create_dir_all(disk.path().join(bucket).join("a/b/c"))
|
|
||||||
.await
|
|
||||||
.expect("nested empty directories should be created");
|
|
||||||
|
|
||||||
disk.delete_volume(bucket, false)
|
|
||||||
.await
|
|
||||||
.expect("non-force delete should remove an empty directory tree");
|
|
||||||
|
|
||||||
assert!(matches!(disk.stat_volume(bucket).await, Err(DiskError::VolumeNotFound)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_tree_delete_preserves_xlmeta_published_after_scan() {
|
|
||||||
let root = tempfile::tempdir().expect("temporary disk root should be created");
|
|
||||||
let bucket_path = root.path().join("bucket");
|
|
||||||
let object_path = bucket_path.join("object").join(STORAGE_FORMAT_FILE);
|
|
||||||
fs::create_dir_all(object_path.parent().expect("object path should have a parent"))
|
|
||||||
.await
|
|
||||||
.expect("empty object directory should be created");
|
|
||||||
|
|
||||||
let data = b"committed object metadata";
|
|
||||||
let mut published = false;
|
|
||||||
let err = remove_empty_directory_tree_with(
|
|
||||||
&bucket_path,
|
|
||||||
|_| Ok(()),
|
|
||||||
|directory| {
|
|
||||||
if !published && directory == object_path.parent().expect("object path should have a parent") {
|
|
||||||
std::fs::write(&object_path, data)?;
|
|
||||||
published = true;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("rmdir should refuse metadata published after the directory scan");
|
|
||||||
|
|
||||||
assert!(published, "test barrier should publish metadata before rmdir");
|
|
||||||
assert!(matches!(classify_delete_volume_error(err), DiskError::VolumeNotEmpty));
|
|
||||||
assert_eq!(std::fs::read(&object_path).expect("object metadata should be preserved"), data);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_tree_delete_rejects_child_replaced_with_external_symlink() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let root = tempfile::tempdir().expect("temporary root should be created");
|
|
||||||
let bucket_path = root.path().join("bucket");
|
|
||||||
let child_path = bucket_path.join("child");
|
|
||||||
let outside_path = root.path().join("outside");
|
|
||||||
let outside_empty = outside_path.join("must-remain");
|
|
||||||
fs::create_dir_all(&child_path).await.expect("bucket child should be created");
|
|
||||||
fs::create_dir_all(&outside_empty)
|
|
||||||
.await
|
|
||||||
.expect("outside directory should be created");
|
|
||||||
|
|
||||||
let mut replaced = false;
|
|
||||||
let err = remove_empty_directory_tree_with(
|
|
||||||
&bucket_path,
|
|
||||||
|child| {
|
|
||||||
if !replaced && child == child_path {
|
|
||||||
std::fs::remove_dir(&child_path)?;
|
|
||||||
symlink(&outside_path, &child_path)?;
|
|
||||||
replaced = true;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
|_| Ok(()),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("a replaced child must fail closed");
|
|
||||||
|
|
||||||
assert!(replaced, "test barrier should replace the child before it is opened");
|
|
||||||
assert!(matches!(classify_delete_volume_error(err), DiskError::VolumeNotEmpty));
|
|
||||||
assert!(outside_empty.exists(), "bucket deletion must not remove directories outside the bucket");
|
|
||||||
assert!(
|
|
||||||
std::fs::symlink_metadata(&child_path)
|
|
||||||
.expect("replacement symlink should remain")
|
|
||||||
.file_type()
|
|
||||||
.is_symlink()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_tree_delete_rechecks_parent_after_child_disappears() {
|
|
||||||
let root = tempfile::tempdir().expect("temporary root should be created");
|
|
||||||
let bucket_path = root.path().join("bucket");
|
|
||||||
let child_path = bucket_path.join("child");
|
|
||||||
fs::create_dir_all(&child_path).await.expect("bucket child should be created");
|
|
||||||
|
|
||||||
let mut removed = false;
|
|
||||||
remove_empty_directory_tree_with(
|
|
||||||
&bucket_path,
|
|
||||||
|child| {
|
|
||||||
if !removed && child == child_path {
|
|
||||||
std::fs::remove_dir(&child_path)?;
|
|
||||||
removed = true;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
|_| Ok(()),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("a vanished empty child should not leave the bucket root behind");
|
|
||||||
|
|
||||||
assert!(removed, "test hook should remove the child before openat");
|
|
||||||
assert!(!bucket_path.exists(), "parent should be rechecked and removed after the child disappears");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn empty_tree_delete_rejects_root_generation_replacement() {
|
|
||||||
let root = tempfile::tempdir().expect("temporary root should be created");
|
|
||||||
let bucket_path = root.path().join("bucket");
|
|
||||||
let moved_path = root.path().join("bucket-before-replacement");
|
|
||||||
fs::create_dir(&bucket_path).await.expect("bucket should be created");
|
|
||||||
|
|
||||||
let mut replaced = false;
|
|
||||||
let err = remove_empty_directory_tree_with(
|
|
||||||
&bucket_path,
|
|
||||||
|_| Ok(()),
|
|
||||||
|directory| {
|
|
||||||
if !replaced && directory == bucket_path {
|
|
||||||
std::fs::rename(&bucket_path, &moved_path)?;
|
|
||||||
std::fs::create_dir(&bucket_path)?;
|
|
||||||
replaced = true;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("a replacement root directory must fail closed");
|
|
||||||
|
|
||||||
assert!(replaced, "test hook should replace the root generation before rmdir");
|
|
||||||
assert!(matches!(classify_delete_volume_error(err), DiskError::VolumeNotEmpty));
|
|
||||||
assert!(moved_path.exists(), "the originally scanned root should not be removed by its old name");
|
|
||||||
assert!(bucket_path.exists(), "the replacement root should remain after identity validation fails");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_local_disk_volume_operations() {
|
async fn test_local_disk_volume_operations() {
|
||||||
let test_dir = "./test_local_disk_volumes";
|
let test_dir = "./test_local_disk_volumes";
|
||||||
|
|||||||
@@ -132,18 +132,6 @@ pub enum Disk {
|
|||||||
Remote(Box<RemoteDisk>),
|
Remote(Box<RemoteDisk>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Disk {
|
|
||||||
pub(crate) async fn set_disk_id_state(&self, id: Option<Uuid>) -> Result<()> {
|
|
||||||
match self {
|
|
||||||
Disk::Local(local_disk) => {
|
|
||||||
local_disk.set_disk_id_state(id).await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Disk::Remote(remote_disk) => remote_disk.set_disk_id(id).await,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl DiskAPI for Disk {
|
impl DiskAPI for Disk {
|
||||||
fn to_string(&self) -> String {
|
fn to_string(&self) -> String {
|
||||||
@@ -1564,72 +1552,6 @@ mod tests {
|
|||||||
let _ = fs::remove_dir_all(&test_dir).await;
|
let _ = fs::remove_dir_all(&test_dir).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
async fn local_disk_id_state_does_not_publish_to_the_process_registry() {
|
|
||||||
let local_dir = tempfile::tempdir().expect("local disk tempdir should be created");
|
|
||||||
let mut endpoint =
|
|
||||||
Endpoint::try_from(local_dir.path().to_str().expect("tempdir path should be utf8")).expect("endpoint should parse");
|
|
||||||
endpoint.set_pool_index(0);
|
|
||||||
endpoint.set_set_index(0);
|
|
||||||
endpoint.set_disk_index(0);
|
|
||||||
let local_disk = LocalDisk::new(&endpoint, false).await.expect("local disk should initialize");
|
|
||||||
let disk = Disk::Local(Box::new(LocalDiskWrapper::new(Arc::new(local_disk), false)));
|
|
||||||
let disk_id = Uuid::new_v4();
|
|
||||||
|
|
||||||
disk.set_disk_id_state(Some(disk_id))
|
|
||||||
.await
|
|
||||||
.expect("local wrapper state should accept a disk ID");
|
|
||||||
|
|
||||||
let Disk::Local(local_disk) = &disk else {
|
|
||||||
panic!("test disk should remain local");
|
|
||||||
};
|
|
||||||
assert_eq!(local_disk.get_current_disk_id().await, Some(disk_id));
|
|
||||||
assert!(
|
|
||||||
!crate::runtime::global::current_ctx()
|
|
||||||
.local_disk_id_map()
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.contains_key(&disk_id),
|
|
||||||
"state-only startup publication must not update the process disk-ID registry"
|
|
||||||
);
|
|
||||||
|
|
||||||
disk.set_disk_id_state(None)
|
|
||||||
.await
|
|
||||||
.expect("local wrapper state should clear a disk ID");
|
|
||||||
assert_eq!(local_disk.get_current_disk_id().await, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remote_disk_id_state_delegates_some_and_none() {
|
|
||||||
let mut endpoint = Endpoint::try_from("http://remote-server:9000/data").expect("remote endpoint should parse");
|
|
||||||
endpoint.set_pool_index(0);
|
|
||||||
endpoint.set_set_index(0);
|
|
||||||
endpoint.set_disk_index(0);
|
|
||||||
let remote_disk = RemoteDisk::new(
|
|
||||||
&endpoint,
|
|
||||||
&DiskOption {
|
|
||||||
cleanup: false,
|
|
||||||
health_check: false,
|
|
||||||
},
|
|
||||||
Arc::new(crate::cluster::rpc::TcpHttpInternodeDataTransport),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("remote disk should initialize");
|
|
||||||
let disk = Disk::Remote(Box::new(remote_disk));
|
|
||||||
let disk_id = Uuid::new_v4();
|
|
||||||
|
|
||||||
disk.set_disk_id_state(Some(disk_id))
|
|
||||||
.await
|
|
||||||
.expect("remote state should accept a disk ID");
|
|
||||||
assert_eq!(disk.get_disk_id().await.expect("remote disk ID should be readable"), Some(disk_id));
|
|
||||||
|
|
||||||
disk.set_disk_id_state(None)
|
|
||||||
.await
|
|
||||||
.expect("remote state should clear a disk ID");
|
|
||||||
assert_eq!(disk.get_disk_id().await.expect("remote disk ID should be readable"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reset_health_for_store_init_retry_delegates_to_disk_variants() {
|
async fn reset_health_for_store_init_retry_delegates_to_disk_variants() {
|
||||||
let local_dir = tempfile::tempdir().unwrap();
|
let local_dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use std::{
|
|||||||
sync::{Arc, LazyLock, Weak},
|
sync::{Arc, LazyLock, Weak},
|
||||||
};
|
};
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::sync::{OwnedSemaphorePermit, RwLock, Semaphore, SemaphorePermit};
|
use tokio::sync::{OwnedSemaphorePermit, Semaphore, SemaphorePermit};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
/// Check path length according to OS limits.
|
/// Check path length according to OS limits.
|
||||||
@@ -123,8 +123,6 @@ const TEST_GLOBAL_FILE_SYNCS: usize = 64;
|
|||||||
|
|
||||||
static FILE_SYNC_PERMITS: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(global_file_sync_limit()));
|
static FILE_SYNC_PERMITS: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(global_file_sync_limit()));
|
||||||
static DISK_FILE_SYNC_LIMITERS: LazyLock<Mutex<HashMap<PathBuf, Weak<Semaphore>>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
static DISK_FILE_SYNC_LIMITERS: LazyLock<Mutex<HashMap<PathBuf, Weak<Semaphore>>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||||
static DISK_VOLUME_MUTATION_LOCKS: LazyLock<Mutex<HashMap<PathBuf, Weak<RwLock<()>>>>> =
|
|
||||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
|
||||||
|
|
||||||
fn default_global_file_sync_limit(cpu_count: usize, max_blocking_threads: usize) -> usize {
|
fn default_global_file_sync_limit(cpu_count: usize, max_blocking_threads: usize) -> usize {
|
||||||
let cpu_scaled = cpu_count
|
let cpu_scaled = cpu_count
|
||||||
@@ -159,24 +157,6 @@ pub(crate) fn disk_file_sync_limiter(root: &Path) -> Arc<Semaphore> {
|
|||||||
limiter
|
limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize a bucket's local metadata commits with physical bucket removal.
|
|
||||||
///
|
|
||||||
/// The key includes the canonical disk root, so independently reconnected
|
|
||||||
/// [`LocalDisk`](super::local::LocalDisk) instances share the same lock while
|
|
||||||
/// disconnected disks do not keep the registry alive.
|
|
||||||
pub(crate) fn disk_volume_mutation_lock(root: &Path, volume: &str) -> Arc<RwLock<()>> {
|
|
||||||
let key = root.join(volume);
|
|
||||||
let mut locks = DISK_VOLUME_MUTATION_LOCKS.lock();
|
|
||||||
locks.retain(|_, lock| lock.strong_count() > 0);
|
|
||||||
if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
|
|
||||||
return lock;
|
|
||||||
}
|
|
||||||
|
|
||||||
let lock = Arc::new(RwLock::new(()));
|
|
||||||
locks.insert(key, Arc::downgrade(&lock));
|
|
||||||
lock
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Always acquire the per-disk permit before the process-wide permit. Keeping
|
/// Always acquire the per-disk permit before the process-wide permit. Keeping
|
||||||
/// this order uniform prevents one slow disk from reserving global capacity
|
/// this order uniform prevents one slow disk from reserving global capacity
|
||||||
/// while it waits for its own concurrency slot.
|
/// while it waits for its own concurrency slot.
|
||||||
@@ -592,14 +572,15 @@ async fn reliable_rename_inner(
|
|||||||
base_dir: impl AsRef<Path>,
|
base_dir: impl AsRef<Path>,
|
||||||
warn_on_missing_source: bool,
|
warn_on_missing_source: bool,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let parent_guard = match dst_file_path.as_ref().parent() {
|
if let Some(parent) = dst_file_path.as_ref().parent()
|
||||||
Some(parent) => Some(mkdir_all_below_existing_base(parent, base_dir.as_ref()).await?),
|
&& !file_exists(parent)
|
||||||
None => None,
|
{
|
||||||
};
|
reliable_mkdir_all(parent, base_dir.as_ref()).await?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
loop {
|
loop {
|
||||||
if let Err(e) = rename_into_existing_parent(src_file_path.as_ref(), dst_file_path.as_ref(), parent_guard.as_ref()) {
|
if let Err(e) = super::fs::rename_std(src_file_path.as_ref(), dst_file_path.as_ref()) {
|
||||||
if should_retry_rename(&e, i) {
|
if should_retry_rename(&e, i) {
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -616,159 +597,6 @@ async fn reliable_rename_inner(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn rename_into_existing_parent(
|
|
||||||
src_file_path: &Path,
|
|
||||||
dst_file_path: &Path,
|
|
||||||
parent_guard: Option<&ExistingBaseDirectoryGuard>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
use rustix::fs::{Mode, OFlags, open, renameat};
|
|
||||||
|
|
||||||
let Some(parent_guard) = parent_guard else {
|
|
||||||
return super::fs::rename_std(src_file_path, dst_file_path);
|
|
||||||
};
|
|
||||||
let src_parent = src_file_path
|
|
||||||
.parent()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename source must have a parent directory"))?;
|
|
||||||
let src_name = src_file_path
|
|
||||||
.file_name()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename source must have a file name"))?;
|
|
||||||
let dst_name = dst_file_path
|
|
||||||
.file_name()
|
|
||||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "rename destination must have a file name"))?;
|
|
||||||
let src_parent = open(
|
|
||||||
src_parent,
|
|
||||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC,
|
|
||||||
Mode::empty(),
|
|
||||||
)
|
|
||||||
.map_err(io::Error::from)?;
|
|
||||||
let dst_parent = parent_guard
|
|
||||||
.last()
|
|
||||||
.ok_or_else(|| io::Error::other("rename destination parent guard is empty"))?;
|
|
||||||
|
|
||||||
renameat(&src_parent, src_name, dst_parent, dst_name).map_err(io::Error::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn rename_into_existing_parent(
|
|
||||||
src_file_path: &Path,
|
|
||||||
dst_file_path: &Path,
|
|
||||||
_parent_guard: Option<&ExistingBaseDirectoryGuard>,
|
|
||||||
) -> io::Result<()> {
|
|
||||||
super::fs::rename_std(src_file_path, dst_file_path)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn mkdir_all_below_existing_base(dir_path: &Path, base_dir: &Path) -> io::Result<ExistingBaseDirectoryGuard> {
|
|
||||||
let dir_path = dir_path.to_path_buf();
|
|
||||||
let base_dir = base_dir.to_path_buf();
|
|
||||||
|
|
||||||
tokio::task::spawn_blocking(move || mkdir_all_below_existing_base_std(&dir_path, &base_dir)).await?
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
pub(crate) type ExistingBaseDirectoryGuard = Vec<winapi_util::Handle>;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
pub(crate) type ExistingBaseDirectoryGuard = Vec<std::os::fd::OwnedFd>;
|
|
||||||
|
|
||||||
#[cfg(all(not(unix), not(windows)))]
|
|
||||||
pub(crate) type ExistingBaseDirectoryGuard = ();
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn lock_windows_directory(path: &Path) -> io::Result<winapi_util::Handle> {
|
|
||||||
use std::os::windows::fs::OpenOptionsExt;
|
|
||||||
|
|
||||||
const FILE_ATTRIBUTE_DIRECTORY: u64 = 0x10;
|
|
||||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
|
||||||
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
|
|
||||||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
|
||||||
const FILE_SHARE_READ: u32 = 0x1;
|
|
||||||
|
|
||||||
let file = std::fs::OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.share_mode(FILE_SHARE_READ)
|
|
||||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
|
||||||
.open(path)?;
|
|
||||||
let handle = winapi_util::Handle::from_file(file);
|
|
||||||
let info = winapi_util::file::information(&handle)?;
|
|
||||||
if info.file_attributes() & FILE_ATTRIBUTE_DIRECTORY == 0
|
|
||||||
|| info.file_attributes() & u64::from(FILE_ATTRIBUTE_REPARSE_POINT) != 0
|
|
||||||
{
|
|
||||||
return Err(io::Error::from(io::ErrorKind::NotADirectory));
|
|
||||||
}
|
|
||||||
Ok(handle)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn mkdir_all_below_existing_base_std(dir_path: &Path, base_dir: &Path) -> io::Result<ExistingBaseDirectoryGuard> {
|
|
||||||
let relative = dir_path
|
|
||||||
.strip_prefix(base_dir)
|
|
||||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "rename destination must remain below its base directory"))?;
|
|
||||||
for component in relative.components() {
|
|
||||||
if !matches!(component, Component::Normal(_) | Component::CurDir) {
|
|
||||||
return Err(io::Error::new(
|
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
"rename destination contains an invalid path component",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use rustix::fs::{Mode, OFlags, mkdirat, open, openat};
|
|
||||||
use rustix::io::Errno;
|
|
||||||
|
|
||||||
let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
|
|
||||||
let mode = Mode::RWXU | Mode::RWXG | Mode::RWXO;
|
|
||||||
let mut parents = vec![open(base_dir, flags, Mode::empty()).map_err(io::Error::from)?];
|
|
||||||
|
|
||||||
for component in relative.components() {
|
|
||||||
let Component::Normal(component) = component else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let parent = parents
|
|
||||||
.last()
|
|
||||||
.expect("base directory guard should contain the base directory");
|
|
||||||
match mkdirat(parent, component, mode) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(Errno::EXIST) => {}
|
|
||||||
Err(err) => return Err(err.into()),
|
|
||||||
}
|
|
||||||
parents.push(openat(parent, component, flags, Mode::empty()).map_err(io::Error::from)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(parents)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
let mut handles = vec![lock_windows_directory(base_dir)?];
|
|
||||||
let mut current = base_dir.to_path_buf();
|
|
||||||
for component in relative.components() {
|
|
||||||
let Component::Normal(component) = component else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
current.push(component);
|
|
||||||
match std::fs::create_dir(¤t) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
|
|
||||||
Err(err) => return Err(err),
|
|
||||||
}
|
|
||||||
handles.push(lock_windows_directory(¤t)?);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(handles)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(all(not(unix), not(windows)))]
|
|
||||||
{
|
|
||||||
let _ = relative;
|
|
||||||
Err(io::Error::new(
|
|
||||||
io::ErrorKind::Unsupported,
|
|
||||||
"safe recursive directory creation is unavailable on this platform",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn warn_reliable_rename_failure(src_file_path: &Path, dst_file_path: &Path, base_dir: &Path, err: &io::Error) {
|
fn warn_reliable_rename_failure(src_file_path: &Path, dst_file_path: &Path, base_dir: &Path, err: &io::Error) {
|
||||||
warn!(
|
warn!(
|
||||||
"reliable_rename failed. src_file_path: {:?}, dst_file_path: {:?}, base_dir: {:?}, err: {:?}",
|
"reliable_rename failed. src_file_path: {:?}, dst_file_path: {:?}, base_dir: {:?}, err: {:?}",
|
||||||
@@ -899,20 +727,6 @@ mod tests {
|
|||||||
Arc::new(Semaphore::new(MAX_PARALLEL_FILE_SYNCS))
|
Arc::new(Semaphore::new(MAX_PARALLEL_FILE_SYNCS))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn disk_volume_mutation_lock_is_shared_per_root_and_volume() {
|
|
||||||
let temp_dir = tempdir().expect("create temp dir");
|
|
||||||
let first = disk_volume_mutation_lock(temp_dir.path(), "bucket");
|
|
||||||
let second = disk_volume_mutation_lock(temp_dir.path(), "bucket");
|
|
||||||
let other = disk_volume_mutation_lock(temp_dir.path(), "other-bucket");
|
|
||||||
|
|
||||||
assert!(Arc::ptr_eq(&first, &second), "reconnected disks must share a bucket mutation lock");
|
|
||||||
assert!(!Arc::ptr_eq(&first, &other), "different buckets must not serialize each other");
|
|
||||||
|
|
||||||
let _write_guard = first.write().await;
|
|
||||||
assert!(second.try_read().is_err(), "a bucket delete lock must exclude local commits");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct CapturedLogs {
|
struct CapturedLogs {
|
||||||
buffer: Arc<Mutex<Vec<u8>>>,
|
buffer: Arc<Mutex<Vec<u8>>>,
|
||||||
@@ -957,26 +771,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Holds a `warn_capture()` capture alive: the thread-local subscriber, plus
|
|
||||||
/// the pin that keeps tracing's process-global callsite-interest cache from
|
|
||||||
/// being decided by some other test's thread.
|
|
||||||
struct WarnCaptureGuard {
|
|
||||||
_subscriber: tracing::subscriber::DefaultGuard,
|
|
||||||
_callsite_pin: tracing::Dispatch,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capture WARN-level output on the current thread; tokio tests here run on
|
/// Capture WARN-level output on the current thread; tokio tests here run on
|
||||||
/// the current-thread runtime, so the guard covers the whole test body.
|
/// the current-thread runtime, so the guard covers the whole test body.
|
||||||
///
|
fn warn_capture() -> (CapturedLogs, tracing::subscriber::DefaultGuard) {
|
||||||
/// The callsite pin matters because `warn_reliable_rename_failure` is a
|
|
||||||
/// single production callsite shared with tests that call `rename_all`
|
|
||||||
/// *without* installing a subscriber — `rename_all_missing_source_returns_file_not_found`
|
|
||||||
/// is one. Whichever thread reaches it first fixes its `Interest`
|
|
||||||
/// process-wide, so without the pin that sibling can cache
|
|
||||||
/// `Interest::never()` and the WARN never fires here at all, leaving the
|
|
||||||
/// "must keep the WARN" assertions staring at empty output. See
|
|
||||||
/// [`crate::test_tracing::pin_callsite_interest_for_test`].
|
|
||||||
fn warn_capture() -> (CapturedLogs, WarnCaptureGuard) {
|
|
||||||
let logs = CapturedLogs::default();
|
let logs = CapturedLogs::default();
|
||||||
let subscriber = tracing_subscriber::fmt()
|
let subscriber = tracing_subscriber::fmt()
|
||||||
.with_max_level(tracing::Level::WARN)
|
.with_max_level(tracing::Level::WARN)
|
||||||
@@ -984,10 +781,7 @@ mod tests {
|
|||||||
.with_ansi(false)
|
.with_ansi(false)
|
||||||
.without_time()
|
.without_time()
|
||||||
.finish();
|
.finish();
|
||||||
let guard = WarnCaptureGuard {
|
let guard = tracing::subscriber::set_default(subscriber);
|
||||||
_subscriber: tracing::subscriber::set_default(subscriber),
|
|
||||||
_callsite_pin: crate::test_tracing::pin_callsite_interest_for_test(),
|
|
||||||
};
|
|
||||||
(logs, guard)
|
(logs, guard)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1174,112 +968,6 @@ mod tests {
|
|||||||
assert_eq!(std::fs::read(dst.join("nested").join("part.1")).expect("read moved part"), b"payload");
|
assert_eq!(std::fs::read(dst.join("nested").join("part.1")).expect("read moved part"), b"payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rename_all_does_not_recreate_missing_base_directory() {
|
|
||||||
let temp_dir = tempdir().expect("create temp dir");
|
|
||||||
let base = temp_dir.path().join("bucket");
|
|
||||||
std::fs::create_dir(&base).expect("create destination base");
|
|
||||||
let src = temp_dir.path().join("staged-object");
|
|
||||||
std::fs::write(&src, b"payload").expect("write staged object");
|
|
||||||
let dst = base.join("object").join("xl.meta");
|
|
||||||
std::fs::remove_dir(&base).expect("delete destination base before commit");
|
|
||||||
|
|
||||||
let err = rename_all(&src, &dst, &base)
|
|
||||||
.await
|
|
||||||
.expect_err("rename must not recreate a deleted destination base");
|
|
||||||
|
|
||||||
assert!(matches!(err, DiskError::FileNotFound));
|
|
||||||
assert!(src.exists(), "failed commit must preserve the staged source");
|
|
||||||
assert!(!base.exists(), "failed commit must not recreate the deleted bucket");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rename_all_rejects_a_replaced_base_with_an_existing_parent() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let temp_dir = tempdir().expect("create temp dir");
|
|
||||||
let base = temp_dir.path().join("bucket");
|
|
||||||
let outside = temp_dir.path().join("outside");
|
|
||||||
std::fs::create_dir(&base).expect("create destination base");
|
|
||||||
std::fs::create_dir_all(outside.join("object")).expect("create outside destination parent");
|
|
||||||
let src = temp_dir.path().join("staged-object");
|
|
||||||
std::fs::write(&src, b"payload").expect("write staged object");
|
|
||||||
let dst = base.join("object").join("xl.meta");
|
|
||||||
|
|
||||||
std::fs::remove_dir(&base).expect("remove destination base before replacement");
|
|
||||||
symlink(&outside, &base).expect("replace destination base with a symlink");
|
|
||||||
|
|
||||||
rename_all(&src, &dst, &base)
|
|
||||||
.await
|
|
||||||
.expect_err("rename must reject an existing destination parent below a replaced base");
|
|
||||||
|
|
||||||
assert!(src.exists(), "rejected rename must preserve the staged source");
|
|
||||||
assert!(
|
|
||||||
!outside.join("object/xl.meta").exists(),
|
|
||||||
"rename must not publish through the replacement symlink"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
#[test]
|
|
||||||
fn windows_parent_guard_blocks_base_and_intermediate_replacement() {
|
|
||||||
let temp_dir = tempdir().expect("create temp dir");
|
|
||||||
let base = temp_dir.path().join("bucket");
|
|
||||||
std::fs::create_dir(&base).expect("create destination base");
|
|
||||||
let parent = base.join("object").join("nested");
|
|
||||||
let guard = mkdir_all_below_existing_base_std(&parent, &base).expect("create and lock destination parents");
|
|
||||||
|
|
||||||
std::fs::rename(&base, temp_dir.path().join("replacement-base"))
|
|
||||||
.expect_err("the locked base must not be replaceable before commit");
|
|
||||||
std::fs::rename(base.join("object"), base.join("replacement-object"))
|
|
||||||
.expect_err("a locked intermediate directory must not be replaceable before commit");
|
|
||||||
|
|
||||||
drop(guard);
|
|
||||||
std::fs::rename(base.join("object"), base.join("replacement-object"))
|
|
||||||
.expect("replacement should succeed after the commit guard is released");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rename_parent_creation_rejects_symlinked_base() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let temp_dir = tempdir().expect("create temp dir");
|
|
||||||
let outside = temp_dir.path().join("outside");
|
|
||||||
std::fs::create_dir(&outside).expect("create outside directory");
|
|
||||||
let base = temp_dir.path().join("bucket");
|
|
||||||
symlink(&outside, &base).expect("create symlinked base");
|
|
||||||
|
|
||||||
mkdir_all_below_existing_base(&base.join("object"), &base)
|
|
||||||
.await
|
|
||||||
.expect_err("symlinked base must be rejected");
|
|
||||||
|
|
||||||
assert!(!outside.join("object").exists(), "parent creation must remain confined to the base");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rename_parent_creation_rejects_symlink_below_base() {
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
|
|
||||||
let temp_dir = tempdir().expect("create temp dir");
|
|
||||||
let base = temp_dir.path().join("bucket");
|
|
||||||
let outside = temp_dir.path().join("outside");
|
|
||||||
std::fs::create_dir(&base).expect("create destination base");
|
|
||||||
std::fs::create_dir(&outside).expect("create outside directory");
|
|
||||||
symlink(&outside, base.join("linked")).expect("create symlink below base");
|
|
||||||
|
|
||||||
mkdir_all_below_existing_base(&base.join("linked/object"), &base)
|
|
||||||
.await
|
|
||||||
.expect_err("symlink below base must be rejected");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!outside.join("object").exists(),
|
|
||||||
"parent creation must not follow a symlink outside the base"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fsync_dir_succeeds_on_directory() {
|
async fn fsync_dir_succeeds_on_directory() {
|
||||||
let temp_dir = tempdir().expect("create temp dir");
|
let temp_dir = tempdir().expect("create temp dir");
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ where
|
|||||||
/// or `out` is larger than one shard. On error `out`'s contents are
|
/// or `out` is larger than one shard. On error `out`'s contents are
|
||||||
/// unspecified but never contain bytes that failed the hash check — the copy
|
/// unspecified but never contain bytes that failed the hash check — the copy
|
||||||
/// happens only after verification.
|
/// happens only after verification.
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub async fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
|
pub async fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
|
||||||
let want = out.len();
|
let want = out.len();
|
||||||
self.begin_read(want)?;
|
self.begin_read(want)?;
|
||||||
@@ -303,7 +303,7 @@ where
|
|||||||
|
|
||||||
/// Write a (hash+data) block. Returns the number of data bytes written.
|
/// Write a (hash+data) block. Returns the number of data bytes written.
|
||||||
/// Returns an error if called after a short write or if data exceeds shard_size.
|
/// Returns an error if called after a short write or if data exceeds shard_size.
|
||||||
#[hotpath::measure(label = "BitrotWriter::write")]
|
#[cfg_attr(feature = "hotpath", hotpath::measure(label = "BitrotWriter::write"))]
|
||||||
pub async fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
pub async fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||||
if buf.is_empty() {
|
if buf.is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
@@ -455,7 +455,7 @@ pub fn bitrot_shard_file_size(size: usize, shard_size: usize, algo: HashAlgorith
|
|||||||
/// stores those as whole-file bitrot with no interleaved hash, so the size guard
|
/// stores those as whole-file bitrot with no interleaved hash, so the size guard
|
||||||
/// on the next line would reject a genuinely healthy part. Reading legacy V1
|
/// on the next line would reject a genuinely healthy part. Reading legacy V1
|
||||||
/// whole-file-bitrot objects would need a separate verification path.
|
/// whole-file-bitrot objects would need a separate verification path.
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub async fn bitrot_verify<R: AsyncRead + Unpin + Send>(
|
pub async fn bitrot_verify<R: AsyncRead + Unpin + Send>(
|
||||||
mut r: R,
|
mut r: R,
|
||||||
want_size: usize,
|
want_size: usize,
|
||||||
|
|||||||
@@ -691,7 +691,7 @@ impl<R> ParallelReader<R>
|
|||||||
where
|
where
|
||||||
R: crate::erasure::coding::ShardSource,
|
R: crate::erasure::coding::ShardSource,
|
||||||
{
|
{
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub async fn read(&mut self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
|
pub async fn read(&mut self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
|
||||||
// On the reconstruction-verifying GET path, read every live shard reader
|
// On the reconstruction-verifying GET path, read every live shard reader
|
||||||
// in lockstep so all readers advance one block per stripe and stay
|
// in lockstep so all readers advance one block per stripe and stay
|
||||||
@@ -1505,7 +1505,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Erasure {
|
impl Erasure {
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub async fn decode<W, R>(
|
pub async fn decode<W, R>(
|
||||||
&self,
|
&self,
|
||||||
writer: &mut W,
|
writer: &mut W,
|
||||||
|
|||||||
@@ -504,7 +504,7 @@ impl Erasure {
|
|||||||
Ok((reader, total))
|
Ok((reader, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub async fn encode<R>(
|
pub async fn encode<R>(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
reader: R,
|
reader: R,
|
||||||
@@ -670,7 +670,7 @@ impl Erasure {
|
|||||||
Ok((reader, total))
|
Ok((reader, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub async fn encode_batched<R>(
|
pub async fn encode_batched<R>(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
mut reader: R,
|
mut reader: R,
|
||||||
@@ -798,7 +798,7 @@ impl Erasure {
|
|||||||
|
|
||||||
/// Fast path for small inline objects: skip tokio::spawn + mpsc channel.
|
/// Fast path for small inline objects: skip tokio::spawn + mpsc channel.
|
||||||
/// Reads all data, encodes directly, writes shards sequentially.
|
/// Reads all data, encodes directly, writes shards sequentially.
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub async fn encode_inline_small<R>(
|
pub async fn encode_inline_small<R>(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
reader: R,
|
reader: R,
|
||||||
@@ -813,7 +813,7 @@ impl Erasure {
|
|||||||
|
|
||||||
/// Fast path for single-block non-inline objects: avoids the producer/consumer
|
/// Fast path for single-block non-inline objects: avoids the producer/consumer
|
||||||
/// pipeline in `encode()` while keeping the same writer/quorum/shutdown semantics.
|
/// pipeline in `encode()` while keeping the same writer/quorum/shutdown semantics.
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub async fn encode_single_block_non_inline<R>(
|
pub async fn encode_single_block_non_inline<R>(
|
||||||
self: Arc<Self>,
|
self: Arc<Self>,
|
||||||
reader: R,
|
reader: R,
|
||||||
|
|||||||
@@ -640,7 +640,7 @@ impl Erasure {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
/// A vector of encoded shards as `Bytes`.
|
/// A vector of encoded shards as `Bytes`.
|
||||||
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
|
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub fn encode_data(&self, data: &[u8]) -> io::Result<Vec<Bytes>> {
|
pub fn encode_data(&self, data: &[u8]) -> io::Result<Vec<Bytes>> {
|
||||||
let shard_size_fn = if self.uses_legacy {
|
let shard_size_fn = if self.uses_legacy {
|
||||||
calc_shard_size_legacy
|
calc_shard_size_legacy
|
||||||
@@ -688,7 +688,7 @@ impl Erasure {
|
|||||||
|
|
||||||
/// Encode owned data, avoiding a copy when the caller already has a heap buffer.
|
/// Encode owned data, avoiding a copy when the caller already has a heap buffer.
|
||||||
/// Falls back to copying into a new buffer if zero-copy conversion fails.
|
/// Falls back to copying into a new buffer if zero-copy conversion fails.
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub fn encode_data_owned(&self, data: Vec<u8>) -> io::Result<Vec<Bytes>> {
|
pub fn encode_data_owned(&self, data: Vec<u8>) -> io::Result<Vec<Bytes>> {
|
||||||
let shard_size_fn = if self.uses_legacy {
|
let shard_size_fn = if self.uses_legacy {
|
||||||
calc_shard_size_legacy
|
calc_shard_size_legacy
|
||||||
@@ -752,7 +752,7 @@ impl Erasure {
|
|||||||
/// block), the `resize(need_total_size)` below stays within capacity for every
|
/// block), the `resize(need_total_size)` below stays within capacity for every
|
||||||
/// `data_len <= block_size` — both shard-size formulas are monotone in
|
/// `data_len <= block_size` — both shard-size formulas are monotone in
|
||||||
/// `data_len` — so this function never reallocates the buffer.
|
/// `data_len` — so this function never reallocates the buffer.
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub fn encode_data_bytes_mut(&self, mut data_buffer: BytesMut, data_len: usize) -> io::Result<Vec<Bytes>> {
|
pub fn encode_data_bytes_mut(&self, mut data_buffer: BytesMut, data_len: usize) -> io::Result<Vec<Bytes>> {
|
||||||
let shard_size_fn = if self.uses_legacy {
|
let shard_size_fn = if self.uses_legacy {
|
||||||
calc_shard_size_legacy
|
calc_shard_size_legacy
|
||||||
@@ -805,7 +805,7 @@ impl Erasure {
|
|||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
/// Ok if reconstruction succeeds, error otherwise.
|
/// Ok if reconstruction succeeds, error otherwise.
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub fn decode_data(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
pub fn decode_data(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
||||||
if self.parity_shards > 0 {
|
if self.parity_shards > 0 {
|
||||||
if self.uses_legacy {
|
if self.uses_legacy {
|
||||||
@@ -825,7 +825,7 @@ impl Erasure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decode and reconstruct missing data shards, then regenerate parity shards.
|
/// Decode and reconstruct missing data shards, then regenerate parity shards.
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub fn decode_data_and_parity(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
pub fn decode_data_and_parity(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
||||||
if self.parity_shards > 0 {
|
if self.parity_shards > 0 {
|
||||||
if self.uses_legacy {
|
if self.uses_legacy {
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ fn get_all_sets<T: AsRef<str>>(set_drive_count: usize, is_ellipses: bool, args:
|
|||||||
for args in set_args.iter() {
|
for args in set_args.iter() {
|
||||||
for arg in args {
|
for arg in args {
|
||||||
if unique_args.contains(arg) {
|
if unique_args.contains(arg) {
|
||||||
return Err(Error::other("input arguments contain a duplicate endpoint after ellipsis expansion"));
|
return Err(Error::other(format!("Input args {arg} has duplicate ellipses")));
|
||||||
}
|
}
|
||||||
unique_args.insert(arg);
|
unique_args.insert(arg);
|
||||||
}
|
}
|
||||||
@@ -924,15 +924,4 @@ mod test {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn layout_errors_do_not_echo_url_credentials() {
|
|
||||||
for volumes in [
|
|
||||||
vec!["http://:duplicate-secret@server/path", "http://:duplicate-secret@server/path"],
|
|
||||||
vec!["http://:ellipsis...secret@server/path"],
|
|
||||||
] {
|
|
||||||
let err = DisksLayout::from_volumes(&volumes).unwrap_err();
|
|
||||||
assert!(!err.to_string().contains("secret"), "layout error leaked endpoint credentials: {err}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ impl TryFrom<&str> for Endpoint {
|
|||||||
// - All field should be empty except Host and Path.
|
// - All field should be empty except Host and Path.
|
||||||
if !((url.scheme() == "http" || url.scheme() == "https")
|
if !((url.scheme() == "http" || url.scheme() == "https")
|
||||||
&& url.username().is_empty()
|
&& url.username().is_empty()
|
||||||
&& url.password().is_none()
|
|
||||||
&& url.fragment().is_none()
|
&& url.fragment().is_none()
|
||||||
&& url.query().is_none())
|
&& url.query().is_none())
|
||||||
{
|
{
|
||||||
@@ -367,12 +366,6 @@ mod test {
|
|||||||
expected_type: None,
|
expected_type: None,
|
||||||
expected_err: Some(Error::other("invalid URL endpoint format")),
|
expected_err: Some(Error::other("invalid URL endpoint format")),
|
||||||
},
|
},
|
||||||
TestCase {
|
|
||||||
arg: "http://:topsecret@server/path",
|
|
||||||
expected_endpoint: None,
|
|
||||||
expected_type: None,
|
|
||||||
expected_err: Some(Error::other("invalid URL endpoint format")),
|
|
||||||
},
|
|
||||||
TestCase {
|
TestCase {
|
||||||
arg: "http://:/path",
|
arg: "http://:/path",
|
||||||
expected_endpoint: None,
|
expected_endpoint: None,
|
||||||
@@ -512,18 +505,8 @@ mod test {
|
|||||||
let endpoint = Endpoint::try_from("http://example.com:9000/path").unwrap();
|
let endpoint = Endpoint::try_from("http://example.com:9000/path").unwrap();
|
||||||
assert_eq!(endpoint.host_port(), "example.com:9000");
|
assert_eq!(endpoint.host_port(), "example.com:9000");
|
||||||
|
|
||||||
for endpoint in [
|
let endpoint_no_port = Endpoint::try_from("https://example.com/path").unwrap();
|
||||||
Endpoint::try_from("http://example.com/path").unwrap(),
|
assert_eq!(endpoint_no_port.host_port(), "example.com");
|
||||||
Endpoint::try_from("http://example.com:80/path").unwrap(),
|
|
||||||
] {
|
|
||||||
assert_eq!(endpoint.host_port(), "example.com");
|
|
||||||
}
|
|
||||||
for endpoint in [
|
|
||||||
Endpoint::try_from("https://example.com/path").unwrap(),
|
|
||||||
Endpoint::try_from("https://example.com:443/path").unwrap(),
|
|
||||||
] {
|
|
||||||
assert_eq!(endpoint.host_port(), "example.com");
|
|
||||||
}
|
|
||||||
|
|
||||||
let file_endpoint = Endpoint::try_from("/tmp/data").unwrap();
|
let file_endpoint = Endpoint::try_from("/tmp/data").unwrap();
|
||||||
assert_eq!(file_endpoint.host_port(), "");
|
assert_eq!(file_endpoint.host_port(), "");
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Error as JsonError;
|
use serde_json::Error as JsonError;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
pub enum FormatMetaVersion {
|
pub enum FormatMetaVersion {
|
||||||
#[serde(rename = "1")]
|
#[serde(rename = "1")]
|
||||||
V1,
|
V1,
|
||||||
@@ -27,7 +27,7 @@ pub enum FormatMetaVersion {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
pub enum FormatBackend {
|
pub enum FormatBackend {
|
||||||
#[serde(rename = "xl")]
|
#[serde(rename = "xl")]
|
||||||
Erasure,
|
Erasure,
|
||||||
@@ -64,7 +64,7 @@ pub struct FormatErasureV3 {
|
|||||||
pub distribution_algo: DistributionAlgoVersion,
|
pub distribution_algo: DistributionAlgoVersion,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
pub enum FormatErasureVersion {
|
pub enum FormatErasureVersion {
|
||||||
#[serde(rename = "1")]
|
#[serde(rename = "1")]
|
||||||
V1,
|
V1,
|
||||||
@@ -77,7 +77,7 @@ pub enum FormatErasureVersion {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||||
pub enum DistributionAlgoVersion {
|
pub enum DistributionAlgoVersion {
|
||||||
#[serde(rename = "CRCMOD")]
|
#[serde(rename = "CRCMOD")]
|
||||||
V1,
|
V1,
|
||||||
@@ -121,15 +121,6 @@ pub struct FormatV3 {
|
|||||||
pub disk_info: Option<DiskInfo>,
|
pub disk_info: Option<DiskInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) type SharedFormatIdentity<'a> = (
|
|
||||||
&'a FormatMetaVersion,
|
|
||||||
&'a FormatBackend,
|
|
||||||
&'a Uuid,
|
|
||||||
&'a FormatErasureVersion,
|
|
||||||
&'a [Vec<Uuid>],
|
|
||||||
&'a DistributionAlgoVersion,
|
|
||||||
);
|
|
||||||
|
|
||||||
impl TryFrom<&[u8]> for FormatV3 {
|
impl TryFrom<&[u8]> for FormatV3 {
|
||||||
type Error = JsonError;
|
type Error = JsonError;
|
||||||
|
|
||||||
@@ -207,24 +198,52 @@ impl FormatV3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn check_other(&self, other: &FormatV3) -> Result<()> {
|
pub fn check_other(&self, other: &FormatV3) -> Result<()> {
|
||||||
if self.shared_identity() != other.shared_identity() {
|
let mut tmp = other.clone();
|
||||||
return Err(Error::other("storage formats do not match"));
|
let this = tmp.erasure.this;
|
||||||
|
tmp.erasure.this = Uuid::nil();
|
||||||
|
|
||||||
|
if self.erasure.sets.len() != other.erasure.sets.len() {
|
||||||
|
return Err(Error::other(format!(
|
||||||
|
"Expected number of sets {}, got {}",
|
||||||
|
self.erasure.sets.len(),
|
||||||
|
other.erasure.sets.len()
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
self.find_disk_index_by_disk_id(other.erasure.this).map(|_| ())
|
for i in 0..self.erasure.sets.len() {
|
||||||
}
|
if self.erasure.sets[i].len() != other.erasure.sets[i].len() {
|
||||||
|
return Err(Error::other(format!(
|
||||||
|
"Each set should be of same size, expected {}, got {}",
|
||||||
|
self.erasure.sets[i].len(),
|
||||||
|
other.erasure.sets[i].len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
/// Fields that must agree across every disk in one erasure format,
|
for j in 0..self.erasure.sets[i].len() {
|
||||||
/// excluding the disk-specific `this` UUID and runtime-only `disk_info`.
|
if self.erasure.sets[i][j] != other.erasure.sets[i][j] {
|
||||||
pub(crate) fn shared_identity(&self) -> SharedFormatIdentity<'_> {
|
return Err(Error::other(format!(
|
||||||
(
|
"UUID on positions {}:{} do not match with, expected {:?} got {:?}: (%w)",
|
||||||
&self.version,
|
i,
|
||||||
&self.format,
|
j,
|
||||||
&self.id,
|
self.erasure.sets[i][j].to_string(),
|
||||||
&self.erasure.version,
|
other.erasure.sets[i][j].to_string(),
|
||||||
&self.erasure.sets,
|
)));
|
||||||
&self.erasure.distribution_algo,
|
}
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..tmp.erasure.sets.len() {
|
||||||
|
for j in 0..tmp.erasure.sets[i].len() {
|
||||||
|
if this == tmp.erasure.sets[i][j] {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(Error::other(format!(
|
||||||
|
"DriveID {:?} not found in any drive sets {:?}",
|
||||||
|
this, other.erasure.sets
|
||||||
|
)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,30 +437,6 @@ mod test {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_check_other_rejects_shared_identity_mismatches() {
|
|
||||||
type FormatMutation = (&'static str, fn(&mut FormatV3));
|
|
||||||
|
|
||||||
let format = FormatV3::new(1, 2);
|
|
||||||
let mutations: [FormatMutation; 5] = [
|
|
||||||
("meta version", |other| other.version = FormatMetaVersion::Unknown),
|
|
||||||
("backend", |other| other.format = FormatBackend::ErasureSingle),
|
|
||||||
("deployment id", |other| other.id = Uuid::new_v4()),
|
|
||||||
("erasure version", |other| other.erasure.version = FormatErasureVersion::V2),
|
|
||||||
("distribution algorithm", |other| {
|
|
||||||
other.erasure.distribution_algo = DistributionAlgoVersion::V2
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (field, mutate) in mutations {
|
|
||||||
let mut other = format.clone();
|
|
||||||
other.erasure.this = format.erasure.sets[0][0];
|
|
||||||
mutate(&mut other);
|
|
||||||
|
|
||||||
assert!(format.check_other(&other).is_err(), "{field} mismatch must be rejected");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_check_other_different_set_count() {
|
fn test_check_other_different_set_count() {
|
||||||
let format1 = FormatV3::new(2, 4);
|
let format1 = FormatV3::new(2, 4);
|
||||||
|
|||||||
@@ -12,10 +12,8 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
#![recursion_limit = "256"]
|
|
||||||
|
|
||||||
/// Scope-based hotpath measurement for `#[async_trait]` methods, where
|
/// Scope-based hotpath measurement for `#[async_trait]` methods, where
|
||||||
/// `#[hotpath::measure]` would only time the boxed-future construction.
|
/// `#[cfg_attr(feature = "hotpath", hotpath::measure)]` would only time the boxed-future construction.
|
||||||
/// The guard records wall time from this statement until the enclosing
|
/// The guard records wall time from this statement until the enclosing
|
||||||
/// (desugared) async block completes, including early returns via `?`.
|
/// (desugared) async block completes, including early returns via `?`.
|
||||||
#[cfg(feature = "hotpath")]
|
#[cfg(feature = "hotpath")]
|
||||||
@@ -95,6 +93,3 @@ pub(crate) mod ecstore_validation_blackbox;
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod test_metrics;
|
pub(crate) mod test_metrics;
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) mod test_tracing;
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
// Copyright 2024 RustFS Team
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use http::{HeaderMap, HeaderValue};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::error::Error;
|
|
||||||
use std::fmt::{Display, Formatter};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ReadEncryptionMode {
|
|
||||||
Direct { base_nonce: [u8; 12] },
|
|
||||||
Object,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ReadEncryptionMaterial {
|
|
||||||
pub key_bytes: [u8; 32],
|
|
||||||
pub mode: ReadEncryptionMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum EncryptionResolutionErrorKind {
|
|
||||||
InvalidRequest,
|
|
||||||
InvalidMetadata,
|
|
||||||
ServiceUnavailable,
|
|
||||||
DecryptionFailed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct EncryptionResolutionError {
|
|
||||||
kind: EncryptionResolutionErrorKind,
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl EncryptionResolutionError {
|
|
||||||
pub fn new(kind: EncryptionResolutionErrorKind, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
kind,
|
|
||||||
message: message.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn kind(&self) -> EncryptionResolutionErrorKind {
|
|
||||||
self.kind
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Display for EncryptionResolutionError {
|
|
||||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
formatter.write_str(&self.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Error for EncryptionResolutionError {}
|
|
||||||
|
|
||||||
pub struct ReadEncryptionRequest<'a> {
|
|
||||||
pub bucket: &'a str,
|
|
||||||
pub object: &'a str,
|
|
||||||
pub metadata: &'a HashMap<String, String>,
|
|
||||||
pub headers: &'a HeaderMap<HeaderValue>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ObjectEncryptionResolver: Send + Sync {
|
|
||||||
async fn resolve_read_material(
|
|
||||||
&self,
|
|
||||||
request: ReadEncryptionRequest<'_>,
|
|
||||||
) -> Result<Option<ReadEncryptionMaterial>, EncryptionResolutionError>;
|
|
||||||
}
|
|
||||||
@@ -84,7 +84,6 @@ pub(crate) fn legacy_encrypted_range_seek_enabled() -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mod body_cache_hook;
|
mod body_cache_hook;
|
||||||
mod encryption;
|
|
||||||
mod hook_slot;
|
mod hook_slot;
|
||||||
mod object_mutation_hook;
|
mod object_mutation_hook;
|
||||||
mod readers;
|
mod readers;
|
||||||
@@ -99,10 +98,6 @@ pub use body_cache_hook::{
|
|||||||
pub(crate) use body_cache_hook::{
|
pub(crate) use body_cache_hook::{
|
||||||
get_object_body_cache_hook, get_object_body_cache_hook_suppressed, without_get_object_body_cache_hook,
|
get_object_body_cache_hook, get_object_body_cache_hook_suppressed, without_get_object_body_cache_hook,
|
||||||
};
|
};
|
||||||
pub use encryption::{
|
|
||||||
EncryptionResolutionError, EncryptionResolutionErrorKind, ObjectEncryptionResolver, ReadEncryptionMaterial,
|
|
||||||
ReadEncryptionMode, ReadEncryptionRequest,
|
|
||||||
};
|
|
||||||
pub(crate) use object_mutation_hook::notify_object_mutation;
|
pub(crate) use object_mutation_hook::notify_object_mutation;
|
||||||
pub use object_mutation_hook::{ObjectMutationHook, register_object_mutation_hook, unregister_object_mutation_hook};
|
pub use object_mutation_hook::{ObjectMutationHook, register_object_mutation_hook, unregister_object_mutation_hook};
|
||||||
pub use readers::*;
|
pub use readers::*;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -273,9 +273,29 @@ impl ObjectInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_encrypted(&self) -> bool {
|
pub fn is_encrypted(&self) -> bool {
|
||||||
self.user_defined
|
// Corresponding to the logic in rustfs/src/sse.rs/encryption_material_to_metadata function
|
||||||
.keys()
|
use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
|
||||||
.any(|key| rustfs_utils::http::is_object_encryption_marker(key))
|
|
||||||
|
self.user_defined.keys().any(|key| {
|
||||||
|
let lower = key.to_ascii_lowercase();
|
||||||
|
lower.starts_with("x-minio-encryption-")
|
||||||
|
|| lower.starts_with("x-minio-internal-server-side-encryption-")
|
||||||
|
|| matches!(
|
||||||
|
lower.as_str(),
|
||||||
|
"x-minio-internal-encrypted-multipart"
|
||||||
|
| "x-rustfs-encryption-key"
|
||||||
|
| "x-rustfs-encryption-algorithm"
|
||||||
|
| "x-rustfs-encryption-iv"
|
||||||
|
| "x-rustfs-encryption-key-id"
|
||||||
|
| "x-rustfs-encryption-context"
|
||||||
|
| "x-rustfs-encryption-tag"
|
||||||
|
| "x-amz-server-side-encryption-aws-kms-key-id"
|
||||||
|
| SSEC_ALGORITHM_HEADER
|
||||||
|
| SSEC_KEY_HEADER
|
||||||
|
| SSEC_KEY_MD5_HEADER
|
||||||
|
| "x-amz-server-side-encryption"
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum inline size for non-versioned objects (128 KiB).
|
/// Maximum inline size for non-versioned objects (128 KiB).
|
||||||
@@ -319,7 +339,26 @@ impl ObjectInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn encryption_original_size(&self) -> std::io::Result<Option<i64>> {
|
pub fn encryption_original_size(&self) -> std::io::Result<Option<i64>> {
|
||||||
rustfs_utils::http::get_object_encryption_original_size(&self.user_defined)
|
let actual_size = rustfs_utils::http::get_str(&self.user_defined, rustfs_utils::http::SUFFIX_ACTUAL_SIZE);
|
||||||
|
if let Some(size_str) = self
|
||||||
|
.user_defined
|
||||||
|
.get("x-rustfs-encryption-original-size")
|
||||||
|
.map(String::as_str)
|
||||||
|
.or_else(|| {
|
||||||
|
self.user_defined
|
||||||
|
.get("x-amz-server-side-encryption-customer-original-size")
|
||||||
|
.map(String::as_str)
|
||||||
|
})
|
||||||
|
.or(actual_size.as_deref())
|
||||||
|
&& !size_str.is_empty()
|
||||||
|
{
|
||||||
|
let size = size_str
|
||||||
|
.parse::<i64>()
|
||||||
|
.map_err(|e| std::io::Error::other(format!("Failed to parse encryption original size: {e}")))?;
|
||||||
|
return Ok(Some(size));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decrypted_size(&self) -> std::io::Result<i64> {
|
pub fn decrypted_size(&self) -> std::io::Result<i64> {
|
||||||
@@ -349,6 +388,9 @@ impl ObjectInfo {
|
|||||||
return Ok(actual_size);
|
return Ok(actual_size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if object is encrypted
|
||||||
|
// Managed SSE stores original size in x-rustfs-encryption-original-size metadata
|
||||||
|
// SSE-C stores original size in x-amz-server-side-encryption-customer-original-size
|
||||||
if let Some(size) = self.encryption_original_size()? {
|
if let Some(size) = self.encryption_original_size()? {
|
||||||
return Ok(size);
|
return Ok(size);
|
||||||
}
|
}
|
||||||
@@ -839,19 +881,6 @@ mod tests {
|
|||||||
assert!(!object.is_inline_fast_path_eligible(), "transitioned objects must fall back");
|
assert!(!object.is_inline_fast_path_eligible(), "transitioned objects must fall back");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn minio_internal_encryption_metadata_is_not_treated_as_plaintext() {
|
|
||||||
let object = ObjectInfo {
|
|
||||||
user_defined: Arc::new(HashMap::from([(
|
|
||||||
"X-Minio-Internal-Server-Side-Encryption-Sealed-Key".to_string(),
|
|
||||||
"sealed".to_string(),
|
|
||||||
)])),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(object.is_encrypted());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn versions_after_marker_handles_null_version_marker() {
|
fn versions_after_marker_handles_null_version_marker() {
|
||||||
let first_version = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
|
let first_version = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ use crate::bucket::metadata_sys::BucketMetadataSys;
|
|||||||
use crate::bucket::replication::{DynReplicationPool, ReplicationStats};
|
use crate::bucket::replication::{DynReplicationPool, ReplicationStats};
|
||||||
use crate::disk::DiskStore;
|
use crate::disk::DiskStore;
|
||||||
use crate::layout::endpoints::{EndpointServerPools, SetupType};
|
use crate::layout::endpoints::{EndpointServerPools, SetupType};
|
||||||
use crate::object_api::ObjectEncryptionResolver;
|
|
||||||
use crate::services::event_notification::EventNotifier;
|
use crate::services::event_notification::EventNotifier;
|
||||||
use crate::services::tier::tier::TierConfigMgr;
|
use crate::services::tier::tier::TierConfigMgr;
|
||||||
use rustfs_lock::{GlobalLockManager, get_global_lock_manager};
|
use rustfs_lock::{GlobalLockManager, get_global_lock_manager};
|
||||||
@@ -160,8 +159,6 @@ pub struct InstanceContext {
|
|||||||
/// workers (scanner/heal/tier/lifecycle) without touching another instance.
|
/// workers (scanner/heal/tier/lifecycle) without touching another instance.
|
||||||
/// Replaces the process-global cancel-token static.
|
/// Replaces the process-global cancel-token static.
|
||||||
background_cancel_token: OnceLock<CancellationToken>,
|
background_cancel_token: OnceLock<CancellationToken>,
|
||||||
/// Resolves object-encryption material at the application boundary.
|
|
||||||
object_encryption_resolver: OnceLock<Arc<dyn ObjectEncryptionResolver>>,
|
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
tier_delete_journal_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -200,7 +197,6 @@ impl InstanceContext {
|
|||||||
local_disk_set_drives: Arc::new(RwLock::new(Vec::new())),
|
local_disk_set_drives: Arc::new(RwLock::new(Vec::new())),
|
||||||
bucket_metadata_sys: std::sync::Mutex::new(None),
|
bucket_metadata_sys: std::sync::Mutex::new(None),
|
||||||
background_cancel_token: OnceLock::new(),
|
background_cancel_token: OnceLock::new(),
|
||||||
object_encryption_resolver: OnceLock::new(),
|
|
||||||
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -213,19 +209,6 @@ impl InstanceContext {
|
|||||||
self.lock_manager.clone()
|
self.lock_manager.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Install the application-owned object-encryption resolver once.
|
|
||||||
pub fn set_object_encryption_resolver(
|
|
||||||
&self,
|
|
||||||
resolver: Arc<dyn ObjectEncryptionResolver>,
|
|
||||||
) -> Result<(), Arc<dyn ObjectEncryptionResolver>> {
|
|
||||||
self.object_encryption_resolver.set(resolver)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Return the configured object-encryption resolver, if startup installed one.
|
|
||||||
pub fn object_encryption_resolver(&self) -> Option<&dyn ObjectEncryptionResolver> {
|
|
||||||
self.object_encryption_resolver.get().map(Arc::as_ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set this instance's S3 region.
|
/// Set this instance's S3 region.
|
||||||
///
|
///
|
||||||
/// Write-once: panics on a second write, preserving the startup fail-fast
|
/// Write-once: panics on a second write, preserving the startup fail-fast
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use crate::{
|
|||||||
bucket::replication::{DynReplicationPool, ReplicationStats},
|
bucket::replication::{DynReplicationPool, ReplicationStats},
|
||||||
config::{get_global_storage_class, get_global_storage_class_snapshot, set_global_storage_class, storageclass},
|
config::{get_global_storage_class, get_global_storage_class_snapshot, set_global_storage_class, storageclass},
|
||||||
disk::{DiskAPI, DiskOption, DiskStore, new_disk},
|
disk::{DiskAPI, DiskOption, DiskStore, new_disk},
|
||||||
error::{Error, Result},
|
error::Result,
|
||||||
layout::endpoints::{EndpointServerPools, SetupType},
|
layout::endpoints::{EndpointServerPools, SetupType},
|
||||||
runtime::global::{
|
runtime::global::{
|
||||||
GLOBAL_BOOT_TIME, GLOBAL_LIFECYCLE_SYS, GLOBAL_LOCAL_NODE_NAME_FALLBACK, GLOBAL_ROOT_DISK_THRESHOLD,
|
GLOBAL_BOOT_TIME, GLOBAL_LIFECYCLE_SYS, GLOBAL_LOCAL_NODE_NAME_FALLBACK, GLOBAL_ROOT_DISK_THRESHOLD,
|
||||||
@@ -46,6 +46,7 @@ use crate::{
|
|||||||
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
|
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
|
||||||
use rustfs_config::server_config::{Config, get_global_server_config, set_global_server_config};
|
use rustfs_config::server_config::{Config, get_global_server_config, set_global_server_config};
|
||||||
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
use rustfs_io_metrics::internode_metrics::global_internode_metrics;
|
||||||
|
use rustfs_kms::{ObjectEncryptionService, get_global_encryption_service};
|
||||||
use rustfs_lock::client::LockClient;
|
use rustfs_lock::client::LockClient;
|
||||||
use s3s::dto::BucketLifecycleConfiguration;
|
use s3s::dto::BucketLifecycleConfiguration;
|
||||||
use s3s::region::Region;
|
use s3s::region::Region;
|
||||||
@@ -104,6 +105,10 @@ pub(crate) fn record_erasure_write_quorum_failure(stage: &'static str, dominant_
|
|||||||
global_internode_metrics().record_erasure_write_quorum_failure(stage, dominant_error);
|
global_internode_metrics().record_erasure_write_quorum_failure(stage, dominant_error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn object_encryption_service() -> Option<Arc<ObjectEncryptionService>> {
|
||||||
|
get_global_encryption_service().await
|
||||||
|
}
|
||||||
|
|
||||||
pub fn object_store_handle() -> Option<Arc<ECStore>> {
|
pub fn object_store_handle() -> Option<Arc<ECStore>> {
|
||||||
resolve_object_store_handle()
|
resolve_object_store_handle()
|
||||||
}
|
}
|
||||||
@@ -412,14 +417,14 @@ pub(crate) async fn clear_local_disk_id_map_for_test() {
|
|||||||
local_disk_id_map_handle().write().await.clear();
|
local_disk_id_map_handle().write().await.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn record_local_disk_id(instance_ctx: &Arc<InstanceContext>, disk_id: Uuid, endpoint: String) {
|
||||||
|
instance_ctx.local_disk_id_map().write().await.insert(disk_id, endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn replace_local_disk_id(previous: Option<Uuid>, current: Option<Uuid>, endpoint: String) {
|
pub(crate) async fn replace_local_disk_id(previous: Option<Uuid>, current: Option<Uuid>, endpoint: String) {
|
||||||
let id_map = local_disk_id_map_handle();
|
let id_map = local_disk_id_map_handle();
|
||||||
let mut disk_id_map = id_map.write().await;
|
let mut disk_id_map = id_map.write().await;
|
||||||
if let Some(previous_id) = previous
|
if let Some(previous_id) = previous {
|
||||||
&& disk_id_map
|
|
||||||
.get(&previous_id)
|
|
||||||
.is_some_and(|registered_endpoint| registered_endpoint == &endpoint)
|
|
||||||
{
|
|
||||||
disk_id_map.remove(&previous_id);
|
disk_id_map.remove(&previous_id);
|
||||||
}
|
}
|
||||||
if let Some(current_id) = current {
|
if let Some(current_id) = current {
|
||||||
@@ -427,53 +432,6 @@ pub(crate) async fn replace_local_disk_id(previous: Option<Uuid>, current: Optio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn reconcile_local_disk_ids(
|
|
||||||
instance_ctx: &InstanceContext,
|
|
||||||
pool_endpoints: &[String],
|
|
||||||
selected: &[(Uuid, String)],
|
|
||||||
) {
|
|
||||||
let pool_endpoints = pool_endpoints.iter().map(String::as_str).collect::<HashSet<_>>();
|
|
||||||
let disk_id_map = instance_ctx.local_disk_id_map();
|
|
||||||
let mut disk_ids = disk_id_map.write().await;
|
|
||||||
disk_ids.retain(|_, registered_endpoint| !pool_endpoints.contains(registered_endpoint.as_str()));
|
|
||||||
disk_ids.extend(selected.iter().cloned());
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn quarantine_local_disks(instance_ctx: &InstanceContext, endpoints: &[Endpoint]) -> Result<()> {
|
|
||||||
let slots = endpoints
|
|
||||||
.iter()
|
|
||||||
.map(|endpoint| {
|
|
||||||
Ok((
|
|
||||||
usize::try_from(endpoint.pool_idx).map_err(|_| Error::CorruptedFormat)?,
|
|
||||||
usize::try_from(endpoint.set_idx).map_err(|_| Error::CorruptedFormat)?,
|
|
||||||
usize::try_from(endpoint.disk_idx).map_err(|_| Error::CorruptedFormat)?,
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>>>()?;
|
|
||||||
|
|
||||||
let local_disk_map = instance_ctx.local_disk_map();
|
|
||||||
let mut local_disks = local_disk_map.write().await;
|
|
||||||
for endpoint in endpoints {
|
|
||||||
local_disks.insert(endpoint.to_string(), None);
|
|
||||||
}
|
|
||||||
drop(local_disks);
|
|
||||||
|
|
||||||
let set_drives = instance_ctx.local_disk_set_drives();
|
|
||||||
let mut local_set_drives = set_drives.write().await;
|
|
||||||
if local_set_drives.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
for (pool_idx, set_idx, disk_idx) in slots {
|
|
||||||
let disk = local_set_drives
|
|
||||||
.get_mut(pool_idx)
|
|
||||||
.and_then(|sets| sets.get_mut(set_idx))
|
|
||||||
.and_then(|disks| disks.get_mut(disk_idx))
|
|
||||||
.ok_or(Error::CorruptedFormat)?;
|
|
||||||
*disk = None;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn record_local_disks(instance_ctx: &Arc<InstanceContext>, disks: Vec<DiskStore>) {
|
pub(crate) async fn record_local_disks(instance_ctx: &Arc<InstanceContext>, disks: Vec<DiskStore>) {
|
||||||
let map = instance_ctx.local_disk_map();
|
let map = instance_ctx.local_disk_map();
|
||||||
let mut global_local_disk_map = map.write().await;
|
let mut global_local_disk_map = map.write().await;
|
||||||
@@ -600,14 +558,10 @@ pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{LockRegistry, local_node_name, set_local_node_name};
|
||||||
LockRegistry, clear_local_disk_id_map_for_test, local_disk_path_by_id, local_node_name, reconcile_local_disk_ids,
|
|
||||||
replace_local_disk_id, set_local_node_name,
|
|
||||||
};
|
|
||||||
use crate::disk::endpoint::Endpoint;
|
use crate::disk::endpoint::Endpoint;
|
||||||
use rustfs_lock::{LocalClient, LockClient};
|
use rustfs_lock::{LocalClient, LockClient};
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
fn url_endpoint(raw: &str) -> Endpoint {
|
fn url_endpoint(raw: &str) -> Endpoint {
|
||||||
Endpoint {
|
Endpoint {
|
||||||
@@ -653,78 +607,4 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(observed, next);
|
assert_eq!(observed, next);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
async fn clearing_a_stale_disk_id_does_not_remove_another_endpoint() {
|
|
||||||
clear_local_disk_id_map_for_test().await;
|
|
||||||
let disk_id = Uuid::new_v4();
|
|
||||||
replace_local_disk_id(None, Some(disk_id), "endpoint-a".to_string()).await;
|
|
||||||
|
|
||||||
replace_local_disk_id(Some(disk_id), None, "endpoint-b".to_string()).await;
|
|
||||||
|
|
||||||
assert_eq!(local_disk_path_by_id(&disk_id).await, Some("endpoint-a".to_string()));
|
|
||||||
clear_local_disk_id_map_for_test().await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial]
|
|
||||||
async fn reconciling_pool_disk_ids_preserves_other_endpoints() {
|
|
||||||
let instance_ctx = Arc::new(crate::runtime::instance::InstanceContext::new());
|
|
||||||
let process_ctx = crate::runtime::global::current_ctx();
|
|
||||||
let bootstrap_ctx = crate::runtime::instance::bootstrap_ctx();
|
|
||||||
let retained_id = Uuid::new_v4();
|
|
||||||
let removed_id = Uuid::new_v4();
|
|
||||||
let selected_id = Uuid::new_v4();
|
|
||||||
let process_sentinel = Uuid::new_v4();
|
|
||||||
let bootstrap_sentinel = Uuid::new_v4();
|
|
||||||
instance_ctx.local_disk_id_map().write().await.extend([
|
|
||||||
(retained_id, "endpoint-a".to_string()),
|
|
||||||
(removed_id, "endpoint-b".to_string()),
|
|
||||||
]);
|
|
||||||
process_ctx
|
|
||||||
.local_disk_id_map()
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(process_sentinel, "endpoint-b".to_string());
|
|
||||||
bootstrap_ctx
|
|
||||||
.local_disk_id_map()
|
|
||||||
.write()
|
|
||||||
.await
|
|
||||||
.insert(bootstrap_sentinel, "endpoint-b".to_string());
|
|
||||||
|
|
||||||
reconcile_local_disk_ids(
|
|
||||||
&instance_ctx,
|
|
||||||
&["endpoint-b".to_string(), "endpoint-c".to_string()],
|
|
||||||
&[(selected_id, "endpoint-c".to_string())],
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let disk_ids = instance_ctx.local_disk_id_map();
|
|
||||||
let disk_ids = disk_ids.read().await;
|
|
||||||
assert_eq!(disk_ids.get(&retained_id).map(String::as_str), Some("endpoint-a"));
|
|
||||||
assert_eq!(disk_ids.get(&removed_id), None);
|
|
||||||
assert_eq!(disk_ids.get(&selected_id).map(String::as_str), Some("endpoint-c"));
|
|
||||||
drop(disk_ids);
|
|
||||||
assert_eq!(
|
|
||||||
process_ctx
|
|
||||||
.local_disk_id_map()
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get(&process_sentinel)
|
|
||||||
.map(String::as_str),
|
|
||||||
Some("endpoint-b")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
bootstrap_ctx
|
|
||||||
.local_disk_id_map()
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get(&bootstrap_sentinel)
|
|
||||||
.map(String::as_str),
|
|
||||||
Some("endpoint-b")
|
|
||||||
);
|
|
||||||
process_ctx.local_disk_id_map().write().await.remove(&process_sentinel);
|
|
||||||
bootstrap_ctx.local_disk_id_map().write().await.remove(&bootstrap_sentinel);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ use rustfs_madmin::metrics::RealtimeMetrics;
|
|||||||
use rustfs_madmin::net::NetInfo;
|
use rustfs_madmin::net::NetInfo;
|
||||||
use rustfs_madmin::{ItemState, ServerProperties, StorageInfo};
|
use rustfs_madmin::{ItemState, ServerProperties, StorageInfo};
|
||||||
use rustfs_utils::XHost;
|
use rustfs_utils::XHost;
|
||||||
use std::collections::{BTreeMap, HashMap, hash_map::DefaultHasher};
|
use std::collections::{HashMap, hash_map::DefaultHasher};
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::sync::{Arc, Mutex, OnceLock};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::{Duration, Instant, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
use tokio::time::{sleep, timeout};
|
use tokio::time::{sleep, timeout};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
@@ -47,9 +47,6 @@ const EVENT_NOTIFICATION_PEER_PROPAGATION: &str = "notification_peer_propagation
|
|||||||
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
const SCANNER_ACTIVITY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100);
|
const TIER_CONFIG_RELOAD_RETRY_BASE: Duration = Duration::from_millis(100);
|
||||||
const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
|
const TIER_CONFIG_RELOAD_RETRY_CAP: Duration = Duration::from_secs(5);
|
||||||
const REMOTE_VERSION_STATE_PROBE_INTERVAL: Duration = Duration::from_secs(10);
|
|
||||||
const REMOTE_VERSION_STATE_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
|
||||||
const REMOTE_VERSION_STATE_PROOF_TTL: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// Cached result from the last successful admin call to a peer.
|
/// Cached result from the last successful admin call to a peer.
|
||||||
struct PeerAdminCache {
|
struct PeerAdminCache {
|
||||||
@@ -94,180 +91,6 @@ lazy_static! {
|
|||||||
pub static ref GLOBAL_NOTIFICATION_SYS: OnceLock<Arc<NotificationSys>> = OnceLock::new();
|
pub static ref GLOBAL_NOTIFICATION_SYS: OnceLock<Arc<NotificationSys>> = OnceLock::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct RemoteVersionStateFleetProof {
|
|
||||||
topology_fingerprint: String,
|
|
||||||
peer_epochs: Arc<BTreeMap<String, Uuid>>,
|
|
||||||
expires_at: Instant,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RemoteVersionStateFleetProof {
|
|
||||||
fn token(&self) -> RemoteVersionStateFleetProofToken {
|
|
||||||
RemoteVersionStateFleetProofToken {
|
|
||||||
topology_fingerprint: self.topology_fingerprint.clone(),
|
|
||||||
peer_epochs: self.peer_epochs.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq, Eq)]
|
|
||||||
pub(crate) struct RemoteVersionStateFleetProofToken {
|
|
||||||
topology_fingerprint: String,
|
|
||||||
peer_epochs: Arc<BTreeMap<String, Uuid>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct RemoteVersionStateFleetProofState {
|
|
||||||
proof: Option<RemoteVersionStateFleetProof>,
|
|
||||||
topology_conflict: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
static REMOTE_VERSION_STATE_FLEET_PROOF: OnceLock<std::sync::RwLock<RemoteVersionStateFleetProofState>> = OnceLock::new();
|
|
||||||
static REMOTE_VERSION_STATE_PROBE_TOPOLOGY: OnceLock<String> = OnceLock::new();
|
|
||||||
|
|
||||||
fn remote_version_state_fleet_proof_slot() -> &'static std::sync::RwLock<RemoteVersionStateFleetProofState> {
|
|
||||||
REMOTE_VERSION_STATE_FLEET_PROOF.get_or_init(|| std::sync::RwLock::new(RemoteVersionStateFleetProofState::default()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn replace_remote_version_state_fleet_proof(proof: Option<RemoteVersionStateFleetProof>) {
|
|
||||||
replace_remote_version_state_fleet_proof_in(remote_version_state_fleet_proof_slot(), proof);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn replace_remote_version_state_fleet_proof_in(
|
|
||||||
slot: &std::sync::RwLock<RemoteVersionStateFleetProofState>,
|
|
||||||
proof: Option<RemoteVersionStateFleetProof>,
|
|
||||||
) {
|
|
||||||
slot.write().unwrap_or_else(std::sync::PoisonError::into_inner).proof = proof;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn publish_remote_version_state_probe_result(
|
|
||||||
slot: &std::sync::RwLock<RemoteVersionStateFleetProofState>,
|
|
||||||
topology_fingerprint: &str,
|
|
||||||
result: Result<BTreeMap<String, Uuid>>,
|
|
||||||
observed_at: Instant,
|
|
||||||
) -> Option<Error> {
|
|
||||||
match result {
|
|
||||||
Ok(peer_epochs) => {
|
|
||||||
let mut state = slot.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
let peer_epochs = state
|
|
||||||
.proof
|
|
||||||
.as_ref()
|
|
||||||
.filter(|proof| proof.topology_fingerprint == topology_fingerprint && proof.peer_epochs.as_ref() == &peer_epochs)
|
|
||||||
.map(|proof| Arc::clone(&proof.peer_epochs))
|
|
||||||
.unwrap_or_else(|| Arc::new(peer_epochs));
|
|
||||||
state.proof = Some(RemoteVersionStateFleetProof {
|
|
||||||
topology_fingerprint: topology_fingerprint.to_string(),
|
|
||||||
peer_epochs,
|
|
||||||
expires_at: observed_at + REMOTE_VERSION_STATE_PROOF_TTL,
|
|
||||||
});
|
|
||||||
None
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
replace_remote_version_state_fleet_proof_in(slot, None);
|
|
||||||
Some(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn acquire_remote_version_state_fleet_proof() -> Option<RemoteVersionStateFleetProofToken> {
|
|
||||||
let expected_topology = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get()?;
|
|
||||||
let state = remote_version_state_fleet_proof_slot()
|
|
||||||
.read()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
acquire_remote_version_state_fleet_proof_from(&state, expected_topology, Instant::now())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn acquire_remote_version_state_fleet_proof_from(
|
|
||||||
state: &RemoteVersionStateFleetProofState,
|
|
||||||
expected_topology: &str,
|
|
||||||
now: Instant,
|
|
||||||
) -> Option<RemoteVersionStateFleetProofToken> {
|
|
||||||
if state.topology_conflict || !remote_version_state_fleet_proof_valid_at(state.proof.as_ref(), expected_topology, now) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
state.proof.as_ref().map(RemoteVersionStateFleetProof::token)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn remote_version_state_fleet_proof_matches(proof: &RemoteVersionStateFleetProofToken) -> bool {
|
|
||||||
let Some(expected_topology) = REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let state = remote_version_state_fleet_proof_slot()
|
|
||||||
.read()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
if state.topology_conflict {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
state.proof.as_ref().is_some_and(|current| {
|
|
||||||
current.topology_fingerprint == *expected_topology
|
|
||||||
&& current.topology_fingerprint == proof.topology_fingerprint
|
|
||||||
&& Arc::ptr_eq(¤t.peer_epochs, &proof.peer_epochs)
|
|
||||||
&& Instant::now() < current.expires_at
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remote_version_state_fleet_proof_valid_at(
|
|
||||||
proof: Option<&RemoteVersionStateFleetProof>,
|
|
||||||
expected_topology: &str,
|
|
||||||
now: Instant,
|
|
||||||
) -> bool {
|
|
||||||
proof.is_some_and(|proof| proof.topology_fingerprint == expected_topology && now < proof.expires_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn insert_remote_version_state_peer(peer_epochs: &mut BTreeMap<String, Uuid>, peer: String, epoch: Uuid) -> Result<()> {
|
|
||||||
if epoch.is_nil() || peer_epochs.values().any(|existing| *existing == epoch) || peer_epochs.insert(peer, epoch).is_some() {
|
|
||||||
return Err(Error::other("remote version state capability peer identity is invalid"));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start_remote_version_state_fleet_probe(topology_fingerprint: String) {
|
|
||||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.set(topology_fingerprint.clone()).is_err() {
|
|
||||||
if REMOTE_VERSION_STATE_PROBE_TOPOLOGY.get() != Some(&topology_fingerprint) {
|
|
||||||
let mut state = remote_version_state_fleet_proof_slot()
|
|
||||||
.write()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
||||||
state.topology_conflict = true;
|
|
||||||
state.proof = None;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
let result = match get_global_notification_sys() {
|
|
||||||
Some(notification_sys) => {
|
|
||||||
match timeout(
|
|
||||||
REMOTE_VERSION_STATE_PROBE_TIMEOUT,
|
|
||||||
notification_sys.probe_remote_version_state_fleet(&topology_fingerprint),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(result) => result,
|
|
||||||
Err(_) => Err(Error::other("remote version state fleet capability probe timed out")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => Err(Error::other("remote version state fleet capability notification system is unavailable")),
|
|
||||||
};
|
|
||||||
let topology_conflict = remote_version_state_fleet_proof_slot()
|
|
||||||
.read()
|
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
||||||
.topology_conflict;
|
|
||||||
if topology_conflict {
|
|
||||||
replace_remote_version_state_fleet_proof(None);
|
|
||||||
} else if let Some(err) = publish_remote_version_state_probe_result(
|
|
||||||
remote_version_state_fleet_proof_slot(),
|
|
||||||
&topology_fingerprint,
|
|
||||||
result,
|
|
||||||
Instant::now(),
|
|
||||||
) {
|
|
||||||
debug!(error = %err, "remote version state fleet capability probe failed closed");
|
|
||||||
}
|
|
||||||
sleep(REMOTE_VERSION_STATE_PROBE_INTERVAL).await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn new_global_notification_sys(eps: EndpointServerPools) -> Result<()> {
|
pub async fn new_global_notification_sys(eps: EndpointServerPools) -> Result<()> {
|
||||||
let _ = GLOBAL_NOTIFICATION_SYS
|
let _ = GLOBAL_NOTIFICATION_SYS
|
||||||
.set(Arc::new(NotificationSys::new(eps).await))
|
.set(Arc::new(NotificationSys::new(eps).await))
|
||||||
@@ -292,17 +115,7 @@ pub struct NotificationSys {
|
|||||||
|
|
||||||
impl NotificationSys {
|
impl NotificationSys {
|
||||||
pub async fn new(eps: EndpointServerPools) -> Self {
|
pub async fn new(eps: EndpointServerPools) -> Self {
|
||||||
let expected_remote_hosts = eps
|
|
||||||
.peer_grid_host_slots_sorted()
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(peer, _, is_local)| (!is_local).then_some(peer))
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
let (peer_clients, all_peer_clients, peer_topology_hosts) = PeerRestClient::new_clients_with_topology(eps).await;
|
let (peer_clients, all_peer_clients, peer_topology_hosts) = PeerRestClient::new_clients_with_topology(eps).await;
|
||||||
let peer_topology_hosts = if peer_topology_hosts.is_empty() {
|
|
||||||
expected_remote_hosts
|
|
||||||
} else {
|
|
||||||
peer_topology_hosts
|
|
||||||
};
|
|
||||||
let peer_admin_caches = (0..peer_clients.len()).map(|_| Mutex::new(PeerAdminCache::new())).collect();
|
let peer_admin_caches = (0..peer_clients.len()).map(|_| Mutex::new(PeerAdminCache::new())).collect();
|
||||||
Self {
|
Self {
|
||||||
peer_clients,
|
peer_clients,
|
||||||
@@ -312,24 +125,6 @@ impl NotificationSys {
|
|||||||
tier_config_reload_workers: Default::default(),
|
tier_config_reload_workers: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn probe_remote_version_state_fleet(&self, topology_fingerprint: &str) -> Result<BTreeMap<String, Uuid>> {
|
|
||||||
if self.peer_clients.len() != self.peer_topology_hosts.len() {
|
|
||||||
return Err(Error::other("remote version state capability fleet membership is incomplete"));
|
|
||||||
}
|
|
||||||
let probes = self.peer_clients.iter().map(|client| async {
|
|
||||||
let client = client
|
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| Error::other("remote version state capability peer is unreachable"))?;
|
|
||||||
client.probe_remote_version_state(topology_fingerprint.to_string()).await
|
|
||||||
});
|
|
||||||
let mut peer_epochs = BTreeMap::new();
|
|
||||||
for result in join_all(probes).await {
|
|
||||||
let (peer, epoch) = result?;
|
|
||||||
insert_remote_version_state_peer(&mut peer_epochs, peer, epoch)?;
|
|
||||||
}
|
|
||||||
Ok(peer_epochs)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct NotificationPeerErr {
|
pub struct NotificationPeerErr {
|
||||||
@@ -2044,10 +1839,15 @@ fn synthesized_disks(host: &str, endpoints: &EndpointServerPools, state: ItemSta
|
|||||||
/// Whether `peer_host` refers to the same node as an endpoint whose
|
/// Whether `peer_host` refers to the same node as an endpoint whose
|
||||||
/// `host_port()` is `ep_host_port`.
|
/// `host_port()` is `ep_host_port`.
|
||||||
///
|
///
|
||||||
/// Current topology clients preserve the endpoint `hostname:port`, so the
|
/// `PeerRestClient::host` is an `XHost`, which resolves names to an address on
|
||||||
/// direct comparison is the normal path. The resolution fallback keeps
|
/// construction (`hosts_sorted` -> `XHost::try_from` -> `to_socket_addrs`), so
|
||||||
/// compatibility with older or manually constructed clients whose `XHost`
|
/// `peer_host` is the resolved `IP:port`. An endpoint's `host_port()`, however,
|
||||||
/// contains a resolved `IP:port` (rustfs/rustfs#4607 follow-up).
|
/// is `url.host():port` — still the raw `hostname:port` on hostname-based
|
||||||
|
/// deployments. A plain string compare therefore misses on hostname clusters,
|
||||||
|
/// leaving the synthesized/degraded drive list empty and `unknownDisks` at 0
|
||||||
|
/// (rustfs/rustfs#4607 follow-up). Compare directly first (fast path / IP
|
||||||
|
/// deployments), then canonicalize the endpoint side through the same `XHost`
|
||||||
|
/// resolution and compare again.
|
||||||
fn endpoint_host_matches(peer_host: &str, ep_host_port: &str) -> bool {
|
fn endpoint_host_matches(peer_host: &str, ep_host_port: &str) -> bool {
|
||||||
if peer_host == ep_host_port {
|
if peer_host == ep_host_port {
|
||||||
return true;
|
return true;
|
||||||
@@ -2090,183 +1890,6 @@ fn aggregate_scanner_dirty_usage_acknowledgement_results(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_fleet_proof_rejects_stale_or_mismatched_membership() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut peer_epochs = BTreeMap::new();
|
|
||||||
peer_epochs.insert("peer-a".to_string(), Uuid::new_v4());
|
|
||||||
let proof = RemoteVersionStateFleetProof {
|
|
||||||
topology_fingerprint: "topology-a".to_string(),
|
|
||||||
peer_epochs: Arc::new(peer_epochs),
|
|
||||||
expires_at: now + Duration::from_secs(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now));
|
|
||||||
assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-b", now));
|
|
||||||
assert!(!remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", proof.expires_at));
|
|
||||||
assert!(!remote_version_state_fleet_proof_valid_at(None, "topology-a", now));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_fleet_proof_rejects_nil_process_epoch() {
|
|
||||||
let mut peer_epochs = BTreeMap::new();
|
|
||||||
|
|
||||||
assert!(insert_remote_version_state_peer(&mut peer_epochs, "peer-a".to_string(), Uuid::nil()).is_err());
|
|
||||||
assert!(peer_epochs.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_fleet_proof_accepts_single_node_membership() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let proof = RemoteVersionStateFleetProof {
|
|
||||||
topology_fingerprint: "topology-a".to_string(),
|
|
||||||
peer_epochs: Arc::new(BTreeMap::new()),
|
|
||||||
expires_at: now + Duration::from_secs(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(remote_version_state_fleet_proof_valid_at(Some(&proof), "topology-a", now));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_fleet_proof_token_changes_with_process_epoch() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let proof = RemoteVersionStateFleetProof {
|
|
||||||
topology_fingerprint: "topology-a".to_string(),
|
|
||||||
peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])),
|
|
||||||
expires_at: now + Duration::from_secs(1),
|
|
||||||
};
|
|
||||||
let captured = proof.token();
|
|
||||||
let restarted = RemoteVersionStateFleetProof {
|
|
||||||
topology_fingerprint: proof.topology_fingerprint.clone(),
|
|
||||||
peer_epochs: Arc::new(BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())])),
|
|
||||||
expires_at: proof.expires_at,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(captured != restarted.token());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_fleet_proof_renewal_preserves_only_same_epoch_token() {
|
|
||||||
let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default());
|
|
||||||
let now = Instant::now();
|
|
||||||
let epoch = Uuid::new_v4();
|
|
||||||
let peers = BTreeMap::from([("peer-a".to_string(), epoch)]);
|
|
||||||
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers.clone()), now).is_none());
|
|
||||||
let original = slot
|
|
||||||
.read()
|
|
||||||
.expect("proof slot should not poison")
|
|
||||||
.proof
|
|
||||||
.as_ref()
|
|
||||||
.expect("successful probe should publish proof")
|
|
||||||
.token();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peers), now + Duration::from_millis(1)).is_none()
|
|
||||||
);
|
|
||||||
let renewed = slot
|
|
||||||
.read()
|
|
||||||
.expect("proof slot should not poison")
|
|
||||||
.proof
|
|
||||||
.as_ref()
|
|
||||||
.expect("renewal should retain proof")
|
|
||||||
.token();
|
|
||||||
assert!(Arc::ptr_eq(&original.peer_epochs, &renewed.peer_epochs));
|
|
||||||
|
|
||||||
let restarted = BTreeMap::from([("peer-a".to_string(), Uuid::new_v4())]);
|
|
||||||
assert!(
|
|
||||||
publish_remote_version_state_probe_result(&slot, "topology-a", Ok(restarted), now + Duration::from_millis(2))
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
let replaced = slot
|
|
||||||
.read()
|
|
||||||
.expect("proof slot should not poison")
|
|
||||||
.proof
|
|
||||||
.as_ref()
|
|
||||||
.expect("restarted peer should publish a new proof")
|
|
||||||
.token();
|
|
||||||
assert!(!Arc::ptr_eq(&original.peer_epochs, &replaced.peer_epochs));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_fleet_proof_conflict_revokes_atomic_snapshot() {
|
|
||||||
let now = Instant::now();
|
|
||||||
let mut state = RemoteVersionStateFleetProofState {
|
|
||||||
proof: Some(RemoteVersionStateFleetProof {
|
|
||||||
topology_fingerprint: "topology-a".to_string(),
|
|
||||||
peer_epochs: Arc::new(BTreeMap::new()),
|
|
||||||
expires_at: now + Duration::from_secs(1),
|
|
||||||
}),
|
|
||||||
topology_conflict: false,
|
|
||||||
};
|
|
||||||
assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_some());
|
|
||||||
|
|
||||||
state.topology_conflict = true;
|
|
||||||
assert!(acquire_remote_version_state_fleet_proof_from(&state, "topology-a", now).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_fleet_probe_rejects_duplicate_member_or_process_epoch() {
|
|
||||||
let epoch = Uuid::new_v4();
|
|
||||||
let mut peer_epochs = BTreeMap::new();
|
|
||||||
insert_remote_version_state_peer(&mut peer_epochs, "node-a:9000".to_string(), epoch)
|
|
||||||
.expect("first member should be admitted");
|
|
||||||
assert!(insert_remote_version_state_peer(&mut peer_epochs, "node-b:9000".to_string(), epoch).is_err());
|
|
||||||
assert!(insert_remote_version_state_peer(&mut peer_epochs, "node-a:9000".to_string(), Uuid::new_v4()).is_err());
|
|
||||||
assert!(insert_remote_version_state_peer(&mut peer_epochs, "node-c:9000".to_string(), Uuid::nil()).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_fleet_probe_failure_revokes_previous_proof() {
|
|
||||||
let slot = std::sync::RwLock::new(RemoteVersionStateFleetProofState::default());
|
|
||||||
let now = Instant::now();
|
|
||||||
let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]);
|
|
||||||
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
|
||||||
assert!(slot.read().expect("proof slot should not poison").proof.is_some());
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
publish_remote_version_state_probe_result(&slot, "topology-a", Err(Error::other("peer unavailable")), now,).is_some()
|
|
||||||
);
|
|
||||||
assert!(slot.read().expect("proof slot should not poison").proof.is_none());
|
|
||||||
|
|
||||||
let peer_epochs = BTreeMap::from([("node-a:9000".to_string(), Uuid::new_v4())]);
|
|
||||||
assert!(publish_remote_version_state_probe_result(&slot, "topology-a", Ok(peer_epochs), now).is_none());
|
|
||||||
assert!(slot.read().expect("proof slot should not poison").proof.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remote_version_state_fleet_probe_rejects_unreachable_member() {
|
|
||||||
let notification_sys = NotificationSys {
|
|
||||||
peer_clients: vec![None],
|
|
||||||
all_peer_clients: vec![None, None],
|
|
||||||
peer_topology_hosts: vec!["peer-a".to_string()],
|
|
||||||
peer_admin_caches: vec![Mutex::new(PeerAdminCache::new())],
|
|
||||||
tier_config_reload_workers: Default::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let err = notification_sys
|
|
||||||
.probe_remote_version_state_fleet("topology-a")
|
|
||||||
.await
|
|
||||||
.expect_err("an unreachable configured member must fail the fleet proof");
|
|
||||||
assert!(err.to_string().contains("unreachable"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn remote_version_state_fleet_probe_rejects_missing_member_slot() {
|
|
||||||
let notification_sys = NotificationSys {
|
|
||||||
peer_clients: Vec::new(),
|
|
||||||
all_peer_clients: vec![None],
|
|
||||||
peer_topology_hosts: vec!["peer-a".to_string()],
|
|
||||||
peer_admin_caches: Vec::new(),
|
|
||||||
tier_config_reload_workers: Default::default(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let err = notification_sys
|
|
||||||
.probe_remote_version_state_fleet("topology-a")
|
|
||||||
.await
|
|
||||||
.expect_err("a missing configured member slot must fail the fleet proof");
|
|
||||||
assert!(err.to_string().contains("incomplete"));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_props(endpoint: &str) -> ServerProperties {
|
fn build_props(endpoint: &str) -> ServerProperties {
|
||||||
ServerProperties {
|
ServerProperties {
|
||||||
endpoint: endpoint.to_string(),
|
endpoint: endpoint.to_string(),
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ use crate::{
|
|||||||
cluster::rpc::peer_rest_client::{PeerRestClient, PeerTierMutationState},
|
cluster::rpc::peer_rest_client::{PeerRestClient, PeerTierMutationState},
|
||||||
config::com::{CONFIG_PREFIX, read_config, read_config_with_metadata},
|
config::com::{CONFIG_PREFIX, read_config, read_config_with_metadata},
|
||||||
disk::{MIGRATING_META_BUCKET, RUSTFS_META_BUCKET},
|
disk::{MIGRATING_META_BUCKET, RUSTFS_META_BUCKET},
|
||||||
layout::endpoints::EndpointServerPools,
|
|
||||||
object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader},
|
object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader},
|
||||||
runtime::sources as runtime_sources,
|
runtime::sources as runtime_sources,
|
||||||
set_disk::get_lock_acquire_timeout,
|
set_disk::get_lock_acquire_timeout,
|
||||||
@@ -237,7 +236,6 @@ struct TierPublishTransition {
|
|||||||
|
|
||||||
struct PreparedTierDriver {
|
struct PreparedTierDriver {
|
||||||
tier_name: String,
|
tier_name: String,
|
||||||
tier_config: TierConfig,
|
|
||||||
config_fingerprint: TierDriverFingerprint,
|
config_fingerprint: TierDriverFingerprint,
|
||||||
backend_identity: TierDestinationId,
|
backend_identity: TierDestinationId,
|
||||||
exact_get_delete: bool,
|
exact_get_delete: bool,
|
||||||
@@ -905,17 +903,14 @@ async fn remote_tier_mutation_peers() -> io::Result<Vec<Arc<dyn TierMutationPeer
|
|||||||
let Some(endpoints) = runtime_sources::endpoint_pools() else {
|
let Some(endpoints) = runtime_sources::endpoint_pools() else {
|
||||||
return Err(tier_mutation_replay_error("cluster endpoint topology is not initialized"));
|
return Err(tier_mutation_replay_error("cluster endpoint topology is not initialized"));
|
||||||
};
|
};
|
||||||
remote_tier_mutation_peers_from_topology(endpoints).await
|
let remote_host_count = endpoints.hosts_sorted().iter().flatten().count();
|
||||||
}
|
let (peers, _) = PeerRestClient::new_clients(endpoints).await;
|
||||||
|
|
||||||
async fn remote_tier_mutation_peers_from_topology(endpoints: EndpointServerPools) -> io::Result<Vec<Arc<dyn TierMutationPeer>>> {
|
|
||||||
let (peers, _, remote_topology_hosts) = PeerRestClient::new_clients_with_topology(endpoints).await;
|
|
||||||
let peers = peers
|
let peers = peers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|peer| Arc::new(peer) as Arc<dyn TierMutationPeer>)
|
.map(|peer| Arc::new(peer) as Arc<dyn TierMutationPeer>)
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
ensure_complete_tier_mutation_commit_peer_set(peers.len(), remote_topology_hosts.len())?;
|
ensure_complete_tier_mutation_commit_peer_set(peers.len(), remote_host_count)?;
|
||||||
Ok(peers)
|
Ok(peers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,7 +1342,6 @@ fn tier_exact_get_delete(config: &TierConfig) -> bool {
|
|||||||
|
|
||||||
struct TierDriverGeneration {
|
struct TierDriverGeneration {
|
||||||
tier_name: Arc<str>,
|
tier_name: Arc<str>,
|
||||||
tier_config: TierConfig,
|
|
||||||
generation: DriverRevision,
|
generation: DriverRevision,
|
||||||
// Process-local only: this may reflect credential changes and must never be persisted or logged.
|
// Process-local only: this may reflect credential changes and must never be persisted or logged.
|
||||||
config_fingerprint: TierDriverFingerprint,
|
config_fingerprint: TierDriverFingerprint,
|
||||||
@@ -1355,9 +1349,6 @@ struct TierDriverGeneration {
|
|||||||
backend_identity: TierDestinationId,
|
backend_identity: TierDestinationId,
|
||||||
exact_get_delete: bool,
|
exact_get_delete: bool,
|
||||||
driver: SharedWarmBackend,
|
driver: SharedWarmBackend,
|
||||||
reconciler: tokio::sync::OnceCell<
|
|
||||||
Option<Arc<dyn crate::services::tier::warm_backend::TransitionCandidateReconciler + Send + Sync + 'static>>,
|
|
||||||
>,
|
|
||||||
accepting: AtomicBool,
|
accepting: AtomicBool,
|
||||||
active_leases: AtomicUsize,
|
active_leases: AtomicUsize,
|
||||||
drained: tokio::sync::Notify,
|
drained: tokio::sync::Notify,
|
||||||
@@ -1482,35 +1473,6 @@ impl TierOperationLease {
|
|||||||
self.inner.backend_identity
|
self.inner.backend_identity
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn probe_transition_candidate_for(
|
|
||||||
&self,
|
|
||||||
object: &str,
|
|
||||||
transaction_id: uuid::Uuid,
|
|
||||||
) -> io::Result<TransitionCandidateProbe> {
|
|
||||||
let Some(reconciler) = self
|
|
||||||
.inner
|
|
||||||
.reconciler
|
|
||||||
.get_or_try_init(|| async {
|
|
||||||
crate::services::tier::warm_backend::new_transition_candidate_reconciler(&self.inner.tier_config)
|
|
||||||
.await
|
|
||||||
.map(|reconciler| reconciler.map(Arc::from))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.map_err(|err| io::Error::other(err.message))?
|
|
||||||
else {
|
|
||||||
return self.inner.driver.probe_transition_candidate(object).await;
|
|
||||||
};
|
|
||||||
reconciler
|
|
||||||
.probe_transition_candidate_for(
|
|
||||||
object,
|
|
||||||
crate::services::tier::warm_backend::TransitionCandidateIdentity {
|
|
||||||
transaction_id,
|
|
||||||
destination_id: self.backend_identity(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn validate_remote_version_id(&self, remote_version_id: &str) -> io::Result<()> {
|
pub(crate) fn validate_remote_version_id(&self, remote_version_id: &str) -> io::Result<()> {
|
||||||
self.inner.driver.validate_remote_version_id(remote_version_id)?;
|
self.inner.driver.validate_remote_version_id(remote_version_id)?;
|
||||||
if !remote_version_id.is_empty() && !self.inner.exact_get_delete {
|
if !remote_version_id.is_empty() && !self.inner.exact_get_delete {
|
||||||
@@ -2871,7 +2833,6 @@ impl TierConfigMgr {
|
|||||||
let exact_get_delete = tier_exact_get_delete(config);
|
let exact_get_delete = tier_exact_get_delete(config);
|
||||||
Some(PreparedTierDriver {
|
Some(PreparedTierDriver {
|
||||||
tier_name: tier_name.to_string(),
|
tier_name: tier_name.to_string(),
|
||||||
tier_config: config.clone(),
|
|
||||||
config_fingerprint,
|
config_fingerprint,
|
||||||
backend_identity,
|
backend_identity,
|
||||||
exact_get_delete,
|
exact_get_delete,
|
||||||
@@ -2900,13 +2861,11 @@ impl TierConfigMgr {
|
|||||||
})?;
|
})?;
|
||||||
let entry = Arc::new(TierDriverGeneration {
|
let entry = Arc::new(TierDriverGeneration {
|
||||||
tier_name: Arc::from(prepared.tier_name.as_str()),
|
tier_name: Arc::from(prepared.tier_name.as_str()),
|
||||||
tier_config: prepared.tier_config.clone(),
|
|
||||||
generation,
|
generation,
|
||||||
config_fingerprint: prepared.config_fingerprint,
|
config_fingerprint: prepared.config_fingerprint,
|
||||||
backend_identity: prepared.backend_identity,
|
backend_identity: prepared.backend_identity,
|
||||||
exact_get_delete: prepared.exact_get_delete,
|
exact_get_delete: prepared.exact_get_delete,
|
||||||
driver: prepared.driver.clone(),
|
driver: prepared.driver.clone(),
|
||||||
reconciler: tokio::sync::OnceCell::new(),
|
|
||||||
accepting: AtomicBool::new(true),
|
accepting: AtomicBool::new(true),
|
||||||
active_leases: AtomicUsize::new(0),
|
active_leases: AtomicUsize::new(0),
|
||||||
drained: tokio::sync::Notify::new(),
|
drained: tokio::sync::Notify::new(),
|
||||||
@@ -3650,13 +3609,11 @@ impl TierConfigMgr {
|
|||||||
let driver: SharedWarmBackend = Arc::from(driver);
|
let driver: SharedWarmBackend = Arc::from(driver);
|
||||||
let entry = Arc::new(TierDriverGeneration {
|
let entry = Arc::new(TierDriverGeneration {
|
||||||
tier_name: Arc::from(tier_name),
|
tier_name: Arc::from(tier_name),
|
||||||
tier_config: config.clone(),
|
|
||||||
generation,
|
generation,
|
||||||
config_fingerprint,
|
config_fingerprint,
|
||||||
backend_identity,
|
backend_identity,
|
||||||
exact_get_delete,
|
exact_get_delete,
|
||||||
driver: driver.clone(),
|
driver: driver.clone(),
|
||||||
reconciler: tokio::sync::OnceCell::new(),
|
|
||||||
accepting: AtomicBool::new(true),
|
accepting: AtomicBool::new(true),
|
||||||
active_leases: AtomicUsize::new(0),
|
active_leases: AtomicUsize::new(0),
|
||||||
drained: tokio::sync::Notify::new(),
|
drained: tokio::sync::Notify::new(),
|
||||||
@@ -4311,34 +4268,6 @@ fn tier_config_not_initialized_error(operation: &str) -> std::io::Error {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::layout::{
|
|
||||||
endpoint::Endpoint,
|
|
||||||
endpoints::{Endpoints, PoolEndpoints, SetupType},
|
|
||||||
};
|
|
||||||
|
|
||||||
struct SetupTypeGuard {
|
|
||||||
previous: SetupType,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SetupTypeGuard {
|
|
||||||
async fn switch_to(next: SetupType) -> Self {
|
|
||||||
let previous = runtime_sources::current_setup_type().await;
|
|
||||||
runtime_sources::set_setup_type(next).await;
|
|
||||||
Self { previous }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for SetupTypeGuard {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let previous = self.previous.clone();
|
|
||||||
let handle = tokio::runtime::Handle::current();
|
|
||||||
tokio::task::block_in_place(|| {
|
|
||||||
handle.block_on(async move {
|
|
||||||
runtime_sources::set_setup_type(previous).await;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_s3_tier(name: &str) -> TierConfig {
|
fn build_s3_tier(name: &str) -> TierConfig {
|
||||||
TierConfig {
|
TierConfig {
|
||||||
@@ -6386,42 +6315,6 @@ mod tests {
|
|||||||
assert!(err.to_string().contains("without peer commit clients"), "{err}");
|
assert!(err.to_string().contains("without peer commit clients"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "multi_thread")]
|
|
||||||
#[serial_test::serial]
|
|
||||||
async fn tier_mutation_peer_composition_preserves_unresolved_topology_slots() {
|
|
||||||
let mut endpoints = Vec::new();
|
|
||||||
for disk_index in 0..4 {
|
|
||||||
let mut endpoint = Endpoint::try_from(format!("http://rustfs-{disk_index}.invalid:9000/data{disk_index}").as_str())
|
|
||||||
.expect("unresolved topology endpoint should parse without DNS");
|
|
||||||
endpoint.is_local = disk_index == 0;
|
|
||||||
endpoint.set_pool_index(0);
|
|
||||||
endpoint.set_set_index(0);
|
|
||||||
endpoint.set_disk_index(disk_index);
|
|
||||||
endpoints.push(endpoint);
|
|
||||||
}
|
|
||||||
let topology = EndpointServerPools::from(vec![PoolEndpoints {
|
|
||||||
legacy: false,
|
|
||||||
set_count: 1,
|
|
||||||
drives_per_set: 4,
|
|
||||||
endpoints: Endpoints::from(endpoints),
|
|
||||||
cmd_line: "unresolved-tier-mutation-topology".to_string(),
|
|
||||||
platform: "test".to_string(),
|
|
||||||
}]);
|
|
||||||
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
|
|
||||||
|
|
||||||
let peers = remote_tier_mutation_peers_from_topology(topology)
|
|
||||||
.await
|
|
||||||
.expect("every unresolved remote topology slot should retain a tier mutation client");
|
|
||||||
assert_eq!(
|
|
||||||
peers.iter().map(|peer| peer.peer_label()).collect::<Vec<_>>(),
|
|
||||||
vec![
|
|
||||||
"http://rustfs-1.invalid:9000".to_string(),
|
|
||||||
"http://rustfs-2.invalid:9000".to_string(),
|
|
||||||
"http://rustfs-3.invalid:9000".to_string(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn coordinator_fanout_prepare_failure_aborts_prepared_peers_without_cas() {
|
async fn coordinator_fanout_prepare_failure_aborts_prepared_peers_without_cas() {
|
||||||
let manager = TierConfigMgr::new();
|
let manager = TierConfigMgr::new();
|
||||||
|
|||||||
@@ -73,21 +73,6 @@ pub enum TransitionCandidateProbe {
|
|||||||
Unsupported,
|
Unsupported,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
pub(crate) struct TransitionCandidateIdentity {
|
|
||||||
pub transaction_id: uuid::Uuid,
|
|
||||||
pub destination_id: [u8; 32],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
pub(crate) trait TransitionCandidateReconciler {
|
|
||||||
async fn probe_transition_candidate_for(
|
|
||||||
&self,
|
|
||||||
object: &str,
|
|
||||||
identity: TransitionCandidateIdentity,
|
|
||||||
) -> Result<TransitionCandidateProbe, std::io::Error>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
pub trait WarmBackend {
|
pub trait WarmBackend {
|
||||||
async fn validate(&self) -> Result<(), std::io::Error> {
|
async fn validate(&self) -> Result<(), std::io::Error> {
|
||||||
@@ -204,20 +189,6 @@ pub fn build_transition_put_options(storage_class: String, mut metadata: HashMap
|
|||||||
metadata.remove(key);
|
metadata.remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
for suffix in [
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TRANSACTION_ID,
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
|
||||||
] {
|
|
||||||
for key in [
|
|
||||||
rustfs_utils::http::metadata_compat::internal_key_rustfs(suffix),
|
|
||||||
format!("{}{}", rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX, suffix),
|
|
||||||
] {
|
|
||||||
if let Some(value) = metadata.remove(&key) {
|
|
||||||
metadata.insert(format!("x-amz-meta-{key}"), value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
opts.user_metadata = metadata;
|
opts.user_metadata = metadata;
|
||||||
opts
|
opts
|
||||||
}
|
}
|
||||||
@@ -475,51 +446,6 @@ pub async fn new_warm_backend(tier: &TierConfig, probe: bool) -> Result<WarmBack
|
|||||||
Ok(d)
|
Ok(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn new_transition_candidate_reconciler(
|
|
||||||
tier: &TierConfig,
|
|
||||||
) -> Result<Option<Box<dyn TransitionCandidateReconciler + Send + Sync + 'static>>, AdminError> {
|
|
||||||
let reconciler: Box<dyn TransitionCandidateReconciler + Send + Sync + 'static> = match tier.tier_type {
|
|
||||||
TierType::S3 => Box::new(
|
|
||||||
WarmBackendS3::new(tier.s3.as_ref().ok_or_else(|| ERR_TIER_INVALID_CONFIG.clone())?, &tier.name)
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
let mut admin_err = ERR_TIER_INVALID_CONFIG.clone();
|
|
||||||
admin_err.message = err.to_string();
|
|
||||||
admin_err
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
TierType::MinIO => Box::new(
|
|
||||||
WarmBackendMinIO::new(tier.minio.as_ref().ok_or_else(|| ERR_TIER_INVALID_CONFIG.clone())?, &tier.name)
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
let mut admin_err = ERR_TIER_INVALID_CONFIG.clone();
|
|
||||||
admin_err.message = err.to_string();
|
|
||||||
admin_err
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
TierType::RustFS => Box::new(
|
|
||||||
WarmBackendRustFS::new(tier.rustfs.as_ref().ok_or_else(|| ERR_TIER_INVALID_CONFIG.clone())?, &tier.name)
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
let mut admin_err = ERR_TIER_INVALID_CONFIG.clone();
|
|
||||||
admin_err.message = err.to_string();
|
|
||||||
admin_err
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
TierType::R2 => Box::new(
|
|
||||||
WarmBackendR2::new(tier.r2.as_ref().ok_or_else(|| ERR_TIER_INVALID_CONFIG.clone())?, &tier.name)
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
let mut admin_err = ERR_TIER_INVALID_CONFIG.clone();
|
|
||||||
admin_err.message = err.to_string();
|
|
||||||
admin_err
|
|
||||||
})?,
|
|
||||||
),
|
|
||||||
_ => return Ok(None),
|
|
||||||
};
|
|
||||||
Ok(Some(reconciler))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -849,37 +775,6 @@ mod tests {
|
|||||||
assert!(!opts.user_metadata.contains_key(X_AMZ_REPLICATION_STATUS.as_str()));
|
assert!(!opts.user_metadata.contains_key(X_AMZ_REPLICATION_STATUS.as_str()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_transition_put_options_persists_both_candidate_identity_keys_as_s3_metadata() {
|
|
||||||
let mut metadata = HashMap::new();
|
|
||||||
rustfs_utils::http::metadata_compat::insert_str(
|
|
||||||
&mut metadata,
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TRANSACTION_ID,
|
|
||||||
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".to_string(),
|
|
||||||
);
|
|
||||||
rustfs_utils::http::metadata_compat::insert_str(
|
|
||||||
&mut metadata,
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
|
||||||
"5a".repeat(32),
|
|
||||||
);
|
|
||||||
|
|
||||||
let opts = build_transition_put_options("COLD".to_string(), metadata);
|
|
||||||
|
|
||||||
for suffix in [
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TRANSACTION_ID,
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
|
||||||
] {
|
|
||||||
assert!(opts.user_metadata.contains_key(&format!(
|
|
||||||
"x-amz-meta-{}",
|
|
||||||
rustfs_utils::http::metadata_compat::internal_key_rustfs(suffix)
|
|
||||||
)));
|
|
||||||
assert!(opts.user_metadata.contains_key(&format!(
|
|
||||||
"x-amz-meta-{}{suffix}",
|
|
||||||
rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_transition_put_options_requests_no_checksum_and_content_md5() {
|
fn build_transition_put_options_requests_no_checksum_and_content_md5() {
|
||||||
// Regression for rustfs/rustfs#4811: transition uploads must leave the
|
// Regression for rustfs/rustfs#4811: transition uploads must leave the
|
||||||
|
|||||||
@@ -135,20 +135,6 @@ impl WarmBackend for WarmBackendMinIO {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendMinIO {
|
|
||||||
async fn probe_transition_candidate_for(
|
|
||||||
&self,
|
|
||||||
object: &str,
|
|
||||||
identity: crate::services::tier::warm_backend::TransitionCandidateIdentity,
|
|
||||||
) -> Result<TransitionCandidateProbe, std::io::Error> {
|
|
||||||
crate::services::tier::warm_backend::TransitionCandidateReconciler::probe_transition_candidate_for(
|
|
||||||
&self.0, object, identity,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn optimal_part_size(object_size: i64) -> Result<i64, std::io::Error> {
|
fn optimal_part_size(object_size: i64) -> Result<i64, std::io::Error> {
|
||||||
let mut object_size = object_size;
|
let mut object_size = object_size;
|
||||||
if object_size == -1 {
|
if object_size == -1 {
|
||||||
|
|||||||
@@ -135,20 +135,6 @@ impl WarmBackend for WarmBackendR2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendR2 {
|
|
||||||
async fn probe_transition_candidate_for(
|
|
||||||
&self,
|
|
||||||
object: &str,
|
|
||||||
identity: crate::services::tier::warm_backend::TransitionCandidateIdentity,
|
|
||||||
) -> Result<TransitionCandidateProbe, std::io::Error> {
|
|
||||||
crate::services::tier::warm_backend::TransitionCandidateReconciler::probe_transition_candidate_for(
|
|
||||||
&self.0, object, identity,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn optimal_part_size(object_size: i64) -> Result<i64, std::io::Error> {
|
fn optimal_part_size(object_size: i64) -> Result<i64, std::io::Error> {
|
||||||
let mut object_size = object_size;
|
let mut object_size = object_size;
|
||||||
if object_size == -1 {
|
if object_size == -1 {
|
||||||
|
|||||||
@@ -132,20 +132,6 @@ impl WarmBackend for WarmBackendRustFS {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl crate::services::tier::warm_backend::TransitionCandidateReconciler for WarmBackendRustFS {
|
|
||||||
async fn probe_transition_candidate_for(
|
|
||||||
&self,
|
|
||||||
object: &str,
|
|
||||||
identity: crate::services::tier::warm_backend::TransitionCandidateIdentity,
|
|
||||||
) -> Result<TransitionCandidateProbe, std::io::Error> {
|
|
||||||
crate::services::tier::warm_backend::TransitionCandidateReconciler::probe_transition_candidate_for(
|
|
||||||
&self.0, object, identity,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn optimal_part_size(object_size: i64) -> Result<i64, std::io::Error> {
|
fn optimal_part_size(object_size: i64) -> Result<i64, std::io::Error> {
|
||||||
let mut object_size = object_size;
|
let mut object_size = object_size;
|
||||||
if object_size == -1 {
|
if object_size == -1 {
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ use crate::client::{
|
|||||||
api_remove::{RemoveObjectOptions, RemoveObjectResult},
|
api_remove::{RemoveObjectOptions, RemoveObjectResult},
|
||||||
api_s3_datatypes::ListVersionsResult,
|
api_s3_datatypes::ListVersionsResult,
|
||||||
credentials::{Credentials, SignatureType, Static, Value},
|
credentials::{Credentials, SignatureType, Static, Value},
|
||||||
provider_versions::validate_remote_version_id,
|
|
||||||
transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore},
|
transition_api::{BucketLookupType, Options, TransitionClient, TransitionCore},
|
||||||
transition_api::{ReadCloser, ReaderImpl},
|
transition_api::{ReadCloser, ReaderImpl},
|
||||||
};
|
};
|
||||||
@@ -37,10 +36,7 @@ use crate::error::ErrorResponse;
|
|||||||
use crate::error::error_resp_to_object_err;
|
use crate::error::error_resp_to_object_err;
|
||||||
use crate::services::tier::{
|
use crate::services::tier::{
|
||||||
tier_config::TierS3,
|
tier_config::TierS3,
|
||||||
warm_backend::{
|
warm_backend::{TransitionCandidateProbe, WarmBackend, WarmBackendGetOpts, build_transition_put_options},
|
||||||
TransitionCandidateIdentity, TransitionCandidateProbe, TransitionCandidateReconciler, WarmBackend, WarmBackendGetOpts,
|
|
||||||
build_transition_put_options,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use rustfs_utils::egress::validate_outbound_url;
|
use rustfs_utils::egress::validate_outbound_url;
|
||||||
@@ -193,188 +189,44 @@ impl WarmBackendS3 {
|
|||||||
remote_bucket_versioning_from_status(config.status.as_ref().map(|status| status.as_str()))
|
remote_bucket_versioning_from_status(config.status.as_ref().map(|status| status.as_str()))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn probe_transition_candidate_versions(
|
async fn list_transition_candidate_versions(&self, object: &str) -> Result<ListVersionsResult, std::io::Error> {
|
||||||
&self,
|
|
||||||
object: &str,
|
|
||||||
bucket_versioning: RemoteBucketVersioning,
|
|
||||||
) -> Result<TransitionCandidateProbe, std::io::Error> {
|
|
||||||
let remote_object = self.get_dest(object);
|
|
||||||
let mut opts = ListObjectsOptions::default();
|
let mut opts = ListObjectsOptions::default();
|
||||||
opts.set("prefix", &remote_object);
|
opts.set("prefix", &self.get_dest(object));
|
||||||
opts.set("max-keys", "1000");
|
opts.set("max-keys", "2");
|
||||||
|
self.client.list_object_versions_query(&self.bucket, &opts, "", "", "").await
|
||||||
let mut key_marker = String::new();
|
|
||||||
let mut version_id_marker = String::new();
|
|
||||||
let mut candidates = TransitionCandidateVersions::default();
|
|
||||||
loop {
|
|
||||||
let versions = self
|
|
||||||
.client
|
|
||||||
.list_object_versions_query(&self.bucket, &opts, &key_marker, &version_id_marker, "")
|
|
||||||
.await?;
|
|
||||||
candidates.extend(&remote_object, &versions);
|
|
||||||
if candidates.is_ambiguous() {
|
|
||||||
return Ok(TransitionCandidateProbe::Ambiguous);
|
|
||||||
}
|
|
||||||
if !versions.is_truncated {
|
|
||||||
return classify_transition_candidates(candidates, bucket_versioning);
|
|
||||||
}
|
|
||||||
|
|
||||||
advance_version_markers(&mut key_marker, &mut version_id_marker, &versions)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn probe_transition_candidate_identity(
|
|
||||||
&self,
|
|
||||||
object: &str,
|
|
||||||
identity: TransitionCandidateIdentity,
|
|
||||||
bucket_versioning: RemoteBucketVersioning,
|
|
||||||
) -> Result<TransitionCandidateProbe, std::io::Error> {
|
|
||||||
let remote_object = self.get_dest(object);
|
|
||||||
let mut opts = ListObjectsOptions::default();
|
|
||||||
opts.set("prefix", &remote_object);
|
|
||||||
opts.set("max-keys", "1000");
|
|
||||||
let mut key_marker = String::new();
|
|
||||||
let mut version_id_marker = String::new();
|
|
||||||
let mut matched_version = None;
|
|
||||||
let mut saw_unproven_candidate = false;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let versions = self
|
|
||||||
.client
|
|
||||||
.list_object_versions_query(&self.bucket, &opts, &key_marker, &version_id_marker, "")
|
|
||||||
.await?;
|
|
||||||
for version in versions.versions.iter().filter(|version| version.key == remote_object) {
|
|
||||||
let mut stat_opts = GetObjectOptions::default();
|
|
||||||
stat_opts.version_id.clone_from(&version.version_id);
|
|
||||||
let info = self.client.stat_object(&self.bucket, &remote_object, &stat_opts).await?;
|
|
||||||
let mut metadata = info.user_metadata;
|
|
||||||
for (name, value) in &info.metadata {
|
|
||||||
if (name
|
|
||||||
.as_str()
|
|
||||||
.starts_with(rustfs_utils::http::metadata_compat::RUSTFS_INTERNAL_PREFIX)
|
|
||||||
|| name
|
|
||||||
.as_str()
|
|
||||||
.starts_with(rustfs_utils::http::metadata_compat::MINIO_INTERNAL_PREFIX))
|
|
||||||
&& let Ok(value) = value.to_str()
|
|
||||||
{
|
|
||||||
metadata.insert(name.as_str().to_string(), value.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if transition_candidate_metadata_matches(&metadata, identity)? {
|
|
||||||
if matched_version.is_some() {
|
|
||||||
return Ok(TransitionCandidateProbe::Ambiguous);
|
|
||||||
}
|
|
||||||
matched_version = Some(version.version_id.clone());
|
|
||||||
} else {
|
|
||||||
saw_unproven_candidate = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !versions.is_truncated {
|
|
||||||
if matched_version.is_none() && saw_unproven_candidate {
|
|
||||||
return Ok(TransitionCandidateProbe::Unsupported);
|
|
||||||
}
|
|
||||||
let candidates = TransitionCandidateVersions {
|
|
||||||
version_id: matched_version,
|
|
||||||
ambiguous: false,
|
|
||||||
};
|
|
||||||
return classify_transition_candidates(candidates, bucket_versioning);
|
|
||||||
}
|
|
||||||
advance_version_markers(&mut key_marker, &mut version_id_marker, &versions)?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transition_candidate_metadata_matches(
|
fn classify_transition_candidate_versions(
|
||||||
metadata: &HashMap<String, String>,
|
remote_object: &str,
|
||||||
identity: TransitionCandidateIdentity,
|
|
||||||
) -> Result<bool, std::io::Error> {
|
|
||||||
use rustfs_utils::http::metadata_compat::{
|
|
||||||
SUFFIX_TRANSITION_TIER_DESTINATION_ID, SUFFIX_TRANSITION_TRANSACTION_ID, contains_key_str, get_consistent_str,
|
|
||||||
};
|
|
||||||
|
|
||||||
let transaction_id = get_consistent_str(metadata, SUFFIX_TRANSITION_TRANSACTION_ID);
|
|
||||||
let destination_id = get_consistent_str(metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID);
|
|
||||||
if transaction_id.is_none() || destination_id.is_none() {
|
|
||||||
if contains_key_str(metadata, SUFFIX_TRANSITION_TRANSACTION_ID)
|
|
||||||
|| contains_key_str(metadata, SUFFIX_TRANSITION_TIER_DESTINATION_ID)
|
|
||||||
{
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"transition candidate identity metadata is empty or conflicting",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
let expected_transaction_id = identity.transaction_id.to_string();
|
|
||||||
let expected_destination_id = rustfs_utils::crypto::hex(identity.destination_id);
|
|
||||||
Ok(transaction_id == Some(expected_transaction_id.as_str()) && destination_id == Some(expected_destination_id.as_str()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn classify_transition_candidates(
|
|
||||||
candidates: TransitionCandidateVersions,
|
|
||||||
bucket_versioning: RemoteBucketVersioning,
|
bucket_versioning: RemoteBucketVersioning,
|
||||||
) -> Result<TransitionCandidateProbe, std::io::Error> {
|
|
||||||
let probe = candidates.classify(bucket_versioning);
|
|
||||||
if let TransitionCandidateProbe::VersionedPresent(version_id) = &probe {
|
|
||||||
validate_remote_version_id(version_id)?;
|
|
||||||
}
|
|
||||||
Ok(probe)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn advance_version_markers(
|
|
||||||
key_marker: &mut String,
|
|
||||||
version_id_marker: &mut String,
|
|
||||||
versions: &ListVersionsResult,
|
versions: &ListVersionsResult,
|
||||||
) -> Result<(), std::io::Error> {
|
) -> TransitionCandidateProbe {
|
||||||
let next_markers = (&versions.next_key_marker, &versions.next_version_id_marker);
|
if versions.is_truncated {
|
||||||
if next_markers == (&*key_marker, &*version_id_marker) {
|
return TransitionCandidateProbe::Ambiguous;
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"ListObjectVersions pagination markers did not advance",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
key_marker.clone_from(&versions.next_key_marker);
|
|
||||||
version_id_marker.clone_from(&versions.next_version_id_marker);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
struct TransitionCandidateVersions {
|
|
||||||
version_id: Option<String>,
|
|
||||||
ambiguous: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TransitionCandidateVersions {
|
|
||||||
fn extend(&mut self, remote_object: &str, versions: &ListVersionsResult) {
|
|
||||||
for version in versions.versions.iter().filter(|version| version.key == remote_object) {
|
|
||||||
if self.version_id.is_some() {
|
|
||||||
self.ambiguous = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.version_id = Some(version.version_id.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_ambiguous(&self) -> bool {
|
if versions.delete_markers.iter().any(|marker| marker.key == remote_object) {
|
||||||
self.ambiguous
|
return TransitionCandidateProbe::Ambiguous;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify(self, bucket_versioning: RemoteBucketVersioning) -> TransitionCandidateProbe {
|
let mut exact_versions = versions.versions.iter().filter(|version| version.key == remote_object);
|
||||||
if self.ambiguous {
|
let Some(version) = exact_versions.next() else {
|
||||||
return TransitionCandidateProbe::Ambiguous;
|
return TransitionCandidateProbe::Missing;
|
||||||
}
|
};
|
||||||
let Some(version_id) = self.version_id else {
|
if exact_versions.next().is_some() {
|
||||||
return TransitionCandidateProbe::Missing;
|
return TransitionCandidateProbe::Ambiguous;
|
||||||
};
|
}
|
||||||
|
|
||||||
match bucket_versioning {
|
match bucket_versioning {
|
||||||
RemoteBucketVersioning::Disabled => TransitionCandidateProbe::UnversionedPresent,
|
RemoteBucketVersioning::Disabled => TransitionCandidateProbe::UnversionedPresent,
|
||||||
RemoteBucketVersioning::Suspended if version_id == "null" => TransitionCandidateProbe::VersionedPresent(version_id),
|
RemoteBucketVersioning::Suspended if version.version_id == "null" => {
|
||||||
RemoteBucketVersioning::Suspended | RemoteBucketVersioning::Enabled if !version_id.is_empty() => {
|
TransitionCandidateProbe::VersionedPresent(version.version_id.clone())
|
||||||
TransitionCandidateProbe::VersionedPresent(version_id)
|
|
||||||
}
|
|
||||||
RemoteBucketVersioning::Suspended | RemoteBucketVersioning::Enabled => TransitionCandidateProbe::Ambiguous,
|
|
||||||
}
|
}
|
||||||
|
RemoteBucketVersioning::Suspended | RemoteBucketVersioning::Enabled if !version.version_id.is_empty() => {
|
||||||
|
TransitionCandidateProbe::VersionedPresent(version.version_id.clone())
|
||||||
|
}
|
||||||
|
RemoteBucketVersioning::Suspended | RemoteBucketVersioning::Enabled => TransitionCandidateProbe::Ambiguous,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,161 +275,72 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_pages(bucket_versioning: RemoteBucketVersioning, pages: &[ListVersionsResult]) -> TransitionCandidateProbe {
|
|
||||||
let mut candidates = TransitionCandidateVersions::default();
|
|
||||||
for page in pages {
|
|
||||||
candidates.extend("archive/object", page);
|
|
||||||
}
|
|
||||||
candidates.classify(bucket_versioning)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn candidate_identity() -> TransitionCandidateIdentity {
|
|
||||||
TransitionCandidateIdentity {
|
|
||||||
transaction_id: uuid::Uuid::parse_str("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").unwrap(),
|
|
||||||
destination_id: [0x5a; 32],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn candidate_metadata(identity: TransitionCandidateIdentity) -> HashMap<String, String> {
|
|
||||||
let mut metadata = HashMap::new();
|
|
||||||
rustfs_utils::http::metadata_compat::insert_str(
|
|
||||||
&mut metadata,
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TRANSACTION_ID,
|
|
||||||
identity.transaction_id.to_string(),
|
|
||||||
);
|
|
||||||
rustfs_utils::http::metadata_compat::insert_str(
|
|
||||||
&mut metadata,
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
|
||||||
rustfs_utils::crypto::hex(identity.destination_id),
|
|
||||||
);
|
|
||||||
metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transition_candidate_identity_requires_exact_compatible_metadata() {
|
|
||||||
let identity = candidate_identity();
|
|
||||||
let metadata = candidate_metadata(identity);
|
|
||||||
assert!(transition_candidate_metadata_matches(&metadata, identity).unwrap());
|
|
||||||
|
|
||||||
let mut adjacent = metadata;
|
|
||||||
rustfs_utils::http::metadata_compat::insert_str(
|
|
||||||
&mut adjacent,
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TRANSACTION_ID,
|
|
||||||
uuid::Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
|
||||||
.unwrap()
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
assert!(!transition_candidate_metadata_matches(&adjacent, identity).unwrap());
|
|
||||||
|
|
||||||
assert!(!transition_candidate_metadata_matches(&HashMap::new(), identity).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transition_candidate_identity_rejects_conflicting_compatibility_keys() {
|
|
||||||
let identity = candidate_identity();
|
|
||||||
let mut metadata = candidate_metadata(identity);
|
|
||||||
metadata.insert(
|
|
||||||
rustfs_utils::http::metadata_compat::internal_key_rustfs(
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TRANSACTION_ID,
|
|
||||||
),
|
|
||||||
uuid::Uuid::new_v4().to_string(),
|
|
||||||
);
|
|
||||||
assert!(transition_candidate_metadata_matches(&metadata, identity).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transition_candidate_probe_classifier_is_fail_closed() {
|
fn transition_candidate_probe_classifier_is_fail_closed() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_pages(RemoteBucketVersioning::Disabled, &[list_versions(&[], &[], false)],),
|
classify_transition_candidate_versions(
|
||||||
|
"archive/object",
|
||||||
|
RemoteBucketVersioning::Disabled,
|
||||||
|
&list_versions(&[], &[], false),
|
||||||
|
),
|
||||||
TransitionCandidateProbe::Missing
|
TransitionCandidateProbe::Missing
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_pages(RemoteBucketVersioning::Disabled, &[list_versions(&[("archive/object", "")], &[], false)],),
|
classify_transition_candidate_versions(
|
||||||
|
"archive/object",
|
||||||
|
RemoteBucketVersioning::Disabled,
|
||||||
|
&list_versions(&[("archive/object", "")], &[], false),
|
||||||
|
),
|
||||||
TransitionCandidateProbe::UnversionedPresent
|
TransitionCandidateProbe::UnversionedPresent
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_pages(
|
classify_transition_candidate_versions(
|
||||||
|
"archive/object",
|
||||||
RemoteBucketVersioning::Enabled,
|
RemoteBucketVersioning::Enabled,
|
||||||
&[list_versions(&[("archive/object", "version-a")], &[], false)],
|
&list_versions(&[("archive/object", "version-a")], &[], false),
|
||||||
),
|
),
|
||||||
TransitionCandidateProbe::VersionedPresent("version-a".to_string())
|
TransitionCandidateProbe::VersionedPresent("version-a".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_pages(
|
classify_transition_candidate_versions(
|
||||||
|
"archive/object",
|
||||||
RemoteBucketVersioning::Suspended,
|
RemoteBucketVersioning::Suspended,
|
||||||
&[list_versions(&[("archive/object", "null")], &[], false)],
|
&list_versions(&[("archive/object", "null")], &[], false),
|
||||||
),
|
),
|
||||||
TransitionCandidateProbe::VersionedPresent("null".to_string())
|
TransitionCandidateProbe::VersionedPresent("null".to_string())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_pages(RemoteBucketVersioning::Enabled, &[list_versions(&[("archive/object", "")], &[], false)],),
|
classify_transition_candidate_versions(
|
||||||
TransitionCandidateProbe::Ambiguous
|
"archive/object",
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify_pages(
|
|
||||||
RemoteBucketVersioning::Enabled,
|
RemoteBucketVersioning::Enabled,
|
||||||
&[list_versions(
|
&list_versions(&[("archive/object", "")], &[], false),
|
||||||
&[("archive/object", "version-a"), ("archive/object", "version-b")],
|
|
||||||
&[],
|
|
||||||
false,
|
|
||||||
)],
|
|
||||||
),
|
),
|
||||||
TransitionCandidateProbe::Ambiguous
|
TransitionCandidateProbe::Ambiguous
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transition_candidate_probe_reconciles_all_pages_and_ignores_delete_markers() {
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify_pages(
|
classify_transition_candidate_versions(
|
||||||
|
"archive/object",
|
||||||
RemoteBucketVersioning::Enabled,
|
RemoteBucketVersioning::Enabled,
|
||||||
&[
|
&list_versions(&[("archive/object", "version-a"), ("archive/object", "version-b")], &[], false),
|
||||||
list_versions(&[], &[("archive/object", "marker-a")], true),
|
),
|
||||||
list_versions(&[("archive/object", "version-a"), ("archive/object-adjacent", "unrelated"),], &[], false,),
|
TransitionCandidateProbe::Ambiguous
|
||||||
],
|
);
|
||||||
),
|
assert_eq!(
|
||||||
TransitionCandidateProbe::VersionedPresent("version-a".to_string())
|
classify_transition_candidate_versions(
|
||||||
);
|
"archive/object",
|
||||||
assert_eq!(
|
RemoteBucketVersioning::Enabled,
|
||||||
classify_pages(
|
&list_versions(&[("archive/object", "version-a")], &[("archive/object", "marker-a")], false),
|
||||||
RemoteBucketVersioning::Enabled,
|
),
|
||||||
&[
|
TransitionCandidateProbe::Ambiguous
|
||||||
list_versions(&[("archive/object", "version-a")], &[], true),
|
);
|
||||||
list_versions(&[("archive/object", "version-b")], &[], false),
|
assert_eq!(
|
||||||
],
|
classify_transition_candidate_versions(
|
||||||
|
"archive/object",
|
||||||
|
RemoteBucketVersioning::Enabled,
|
||||||
|
&list_versions(&[("archive/object", "version-a")], &[], true),
|
||||||
),
|
),
|
||||||
TransitionCandidateProbe::Ambiguous
|
TransitionCandidateProbe::Ambiguous
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transition_candidate_pagination_advances_both_markers() {
|
|
||||||
let mut key_marker = "old-key".to_string();
|
|
||||||
let mut version_id_marker = "old-version".to_string();
|
|
||||||
let page = ListVersionsResult {
|
|
||||||
next_key_marker: "next-key".to_string(),
|
|
||||||
next_version_id_marker: "next-version".to_string(),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
advance_version_markers(&mut key_marker, &mut version_id_marker, &page)
|
|
||||||
.expect("new ListObjectVersions markers should advance pagination");
|
|
||||||
assert_eq!(key_marker, "next-key");
|
|
||||||
assert_eq!(version_id_marker, "next-version");
|
|
||||||
|
|
||||||
let err = advance_version_markers(&mut key_marker, &mut version_id_marker, &page)
|
|
||||||
.expect_err("repeated ListObjectVersions markers must fail closed");
|
|
||||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn transition_candidate_probe_rejects_untrusted_version_ids() {
|
|
||||||
let mut candidates = TransitionCandidateVersions::default();
|
|
||||||
candidates.extend("archive/object", &list_versions(&[("archive/object", "version\ninjection")], &[], false));
|
|
||||||
|
|
||||||
let err = classify_transition_candidates(candidates, RemoteBucketVersioning::Enabled)
|
|
||||||
.expect_err("control characters in listed version IDs must fail closed");
|
|
||||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -634,7 +397,12 @@ impl WarmBackend for WarmBackendS3 {
|
|||||||
|
|
||||||
async fn probe_transition_candidate(&self, object: &str) -> Result<TransitionCandidateProbe, std::io::Error> {
|
async fn probe_transition_candidate(&self, object: &str) -> Result<TransitionCandidateProbe, std::io::Error> {
|
||||||
let bucket_versioning = self.remote_bucket_versioning().await?;
|
let bucket_versioning = self.remote_bucket_versioning().await?;
|
||||||
self.probe_transition_candidate_versions(object, bucket_versioning).await
|
let versions = self.list_transition_candidate_versions(object).await?;
|
||||||
|
Ok(classify_transition_candidate_versions(
|
||||||
|
&self.get_dest(object),
|
||||||
|
bucket_versioning,
|
||||||
|
&versions,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn in_use(&self) -> Result<bool, std::io::Error> {
|
async fn in_use(&self) -> Result<bool, std::io::Error> {
|
||||||
@@ -646,16 +414,3 @@ impl WarmBackend for WarmBackendS3 {
|
|||||||
Ok(result.common_prefixes.len() > 0 || result.contents.len() > 0)
|
Ok(result.common_prefixes.len() > 0 || result.contents.len() > 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl TransitionCandidateReconciler for WarmBackendS3 {
|
|
||||||
async fn probe_transition_candidate_for(
|
|
||||||
&self,
|
|
||||||
object: &str,
|
|
||||||
identity: TransitionCandidateIdentity,
|
|
||||||
) -> Result<TransitionCandidateProbe, std::io::Error> {
|
|
||||||
let bucket_versioning = self.remote_bucket_versioning().await?;
|
|
||||||
self.probe_transition_candidate_identity(object, identity, bucket_versioning)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -505,7 +505,9 @@ impl SetDisks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn file_info_has_encryption_metadata(meta: &FileInfo) -> bool {
|
fn file_info_has_encryption_metadata(meta: &FileInfo) -> bool {
|
||||||
meta.metadata.keys().any(|name| http::is_object_encryption_marker(name))
|
meta.metadata
|
||||||
|
.keys()
|
||||||
|
.any(|name| http::is_encryption_metadata_key(name) || http::is_sse_header(name))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool {
|
fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool {
|
||||||
|
|||||||
@@ -110,10 +110,7 @@ use crate::{
|
|||||||
object_api::{GetObjectReader, ObjectInfo, PutObjReader},
|
object_api::{GetObjectReader, ObjectInfo, PutObjReader},
|
||||||
// event::name::EventName,
|
// event::name::EventName,
|
||||||
services::event_notification::{EventArgs, send_event},
|
services::event_notification::{EventArgs, send_event},
|
||||||
store::init_format::{
|
store::init_format::{get_format_erasure_in_quorum, load_format_erasure, load_format_erasure_all, save_format_file},
|
||||||
formats_match_reference_slots, get_format_erasure_in_quorum, load_format_erasure, load_format_erasure_all,
|
|
||||||
save_format_file,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use bytesize::ByteSize;
|
use bytesize::ByteSize;
|
||||||
@@ -147,17 +144,15 @@ use rustfs_object_capacity::capacity_scope::{
|
|||||||
CapacityScope, CapacityScopeDisk, current_dirty_generation, record_capacity_scope, record_global_dirty_scope,
|
CapacityScope, CapacityScopeDisk, current_dirty_generation, record_capacity_scope, record_global_dirty_scope,
|
||||||
};
|
};
|
||||||
use rustfs_s3_types::EventName;
|
use rustfs_s3_types::EventName;
|
||||||
#[cfg(test)]
|
|
||||||
use rustfs_utils::http::SSEC_ALGORITHM_HEADER;
|
|
||||||
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
|
use rustfs_utils::http::headers::AMZ_OBJECT_TAGGING;
|
||||||
use rustfs_utils::http::headers::AMZ_STORAGE_CLASS;
|
use rustfs_utils::http::headers::AMZ_STORAGE_CLASS;
|
||||||
use rustfs_utils::http::headers::{
|
use rustfs_utils::http::headers::{
|
||||||
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
|
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
|
||||||
};
|
};
|
||||||
use rustfs_utils::http::{
|
use rustfs_utils::http::{
|
||||||
SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC,
|
SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER, SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE,
|
||||||
SUFFIX_RESTORE_OPERATION_ID, contains_key_str, get_header_map, get_str, insert_str, is_object_encryption_marker,
|
SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_RESTORE_OPERATION_ID, contains_key_str,
|
||||||
remove_header_map,
|
get_header_map, get_str, insert_str, is_encryption_metadata_key, remove_header_map,
|
||||||
};
|
};
|
||||||
use rustfs_utils::{
|
use rustfs_utils::{
|
||||||
HashAlgorithm,
|
HashAlgorithm,
|
||||||
@@ -672,7 +667,10 @@ pub(crate) fn strip_internal_multipart_metadata(metadata: &mut HashMap<String, S
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn should_persist_encryption_original_size(metadata: &HashMap<String, String>) -> bool {
|
fn should_persist_encryption_original_size(metadata: &HashMap<String, String>) -> bool {
|
||||||
metadata.keys().any(|key| is_object_encryption_marker(key))
|
metadata.keys().any(|key| is_encryption_metadata_key(key))
|
||||||
|
|| metadata.contains_key(SSEC_ALGORITHM_HEADER)
|
||||||
|
|| metadata.contains_key(SSEC_KEY_HEADER)
|
||||||
|
|| metadata.contains_key(SSEC_KEY_MD5_HEADER)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-set memoized capacity dirty scope.
|
/// Per-set memoized capacity dirty scope.
|
||||||
@@ -5851,27 +5849,23 @@ mod tests {
|
|||||||
crate::disk::DataDirDeleteStatus::Deleted
|
crate::disk::DataDirDeleteStatus::Deleted
|
||||||
);
|
);
|
||||||
release_slow_candidate.notify_one();
|
release_slow_candidate.notify_one();
|
||||||
timeout(Duration::from_secs(1), async {
|
for _ in 0..10 {
|
||||||
loop {
|
tokio::task::yield_now().await;
|
||||||
match disk2
|
}
|
||||||
.delete_data_dir(
|
assert_eq!(
|
||||||
bucket,
|
disk2
|
||||||
data_dir,
|
.delete_data_dir(
|
||||||
DeleteOptions {
|
bucket,
|
||||||
recursive: true,
|
data_dir,
|
||||||
..Default::default()
|
DeleteOptions {
|
||||||
},
|
recursive: true,
|
||||||
)
|
..Default::default()
|
||||||
.await
|
},
|
||||||
.expect("a token acquired after the deadline must be released")
|
)
|
||||||
{
|
.await
|
||||||
crate::disk::DataDirDeleteStatus::Deleted => return,
|
.expect("a token acquired after the deadline must be released"),
|
||||||
crate::disk::DataDirDeleteStatus::Deferred => tokio::time::sleep(Duration::from_millis(1)).await,
|
crate::disk::DataDirDeleteStatus::Deleted
|
||||||
}
|
);
|
||||||
}
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("late snapshot lease cleanup must finish within the bounded wait");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(start_paused = true)]
|
#[tokio::test(start_paused = true)]
|
||||||
|
|||||||
@@ -1373,24 +1373,11 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
|
|||||||
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||||
let disks = self.disks.read().await.clone();
|
let disks = self.disks.read().await.clone();
|
||||||
let (formats, errs) = load_format_erasure_all(&disks, true).await;
|
let (formats, errs) = load_format_erasure_all(&disks, true).await;
|
||||||
if errs.iter().any(|err| {
|
let ref_format = match get_format_erasure_in_quorum(&formats) {
|
||||||
matches!(
|
Ok(format) => format,
|
||||||
err,
|
|
||||||
Some(DiskError::InconsistentDisk | DiskError::CorruptedFormat | DiskError::CorruptedBackend)
|
|
||||||
)
|
|
||||||
}) {
|
|
||||||
return Ok((HealResultItem::default(), Some(StorageError::CorruptedFormat)));
|
|
||||||
}
|
|
||||||
let slot_offset = self
|
|
||||||
.set_index
|
|
||||||
.checked_mul(self.set_drive_count)
|
|
||||||
.ok_or_else(|| Error::other("erasure set slot offset overflow"))?;
|
|
||||||
let ref_format = match get_format_erasure_in_quorum(&formats, slot_offset) {
|
|
||||||
Ok(format) if format.shared_identity() == self.format.shared_identity() => format,
|
|
||||||
Ok(_) => return Ok((HealResultItem::default(), Some(StorageError::CorruptedFormat))),
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let can_use_cached_layout = count_errs(&errs, &DiskError::UnformattedDisk) > 0
|
let can_use_cached_layout = count_errs(&errs, &DiskError::UnformattedDisk) > 0
|
||||||
&& formats_match_reference_slots(&formats, &self.format, slot_offset)
|
&& formats.iter().flatten().all(|format| self.format.check_other(format).is_ok())
|
||||||
&& errs
|
&& errs
|
||||||
.iter()
|
.iter()
|
||||||
.all(|err| err.is_none() || matches!(err, Some(DiskError::UnformattedDisk)));
|
.all(|err| err.is_none() || matches!(err, Some(DiskError::UnformattedDisk)));
|
||||||
@@ -1401,9 +1388,6 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !formats_match_reference_slots(&formats, &ref_format, slot_offset) {
|
|
||||||
return Ok((HealResultItem::default(), Some(StorageError::CorruptedFormat)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let endpoints = crate::layout::endpoints::Endpoints::from(self.set_endpoints.clone());
|
let endpoints = crate::layout::endpoints::Endpoints::from(self.set_endpoints.clone());
|
||||||
let before_drives = crate::layout::set_heal::formats_to_drives_info(&endpoints, &formats, &errs);
|
let before_drives = crate::layout::set_heal::formats_to_drives_info(&endpoints, &formats, &errs);
|
||||||
@@ -1559,16 +1543,11 @@ mod heal_result_report_tests {
|
|||||||
use crate::disk::error::DiskError;
|
use crate::disk::error::DiskError;
|
||||||
use crate::disk::format::FormatV3;
|
use crate::disk::format::FormatV3;
|
||||||
use crate::disk::{DiskAPI as _, DiskOption, DiskStore, RUSTFS_META_TMP_BUCKET, ReadOptions, new_disk};
|
use crate::disk::{DiskAPI as _, DiskOption, DiskStore, RUSTFS_META_TMP_BUCKET, ReadOptions, new_disk};
|
||||||
use crate::error::Error;
|
|
||||||
use crate::object_api::{ObjectOptions, PutObjReader};
|
use crate::object_api::{ObjectOptions, PutObjReader};
|
||||||
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_isolated;
|
use crate::set_disk::ops::object::hermetic_set_disks_support::hermetic_set_disks_isolated;
|
||||||
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
use crate::storage_api_contracts::bucket::{BucketOperations as _, MakeBucketOptions};
|
||||||
use crate::storage_api_contracts::heal::HealOperations as _;
|
|
||||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
|
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
|
||||||
use crate::{
|
use crate::{config::storageclass, store::init_format::save_format_file};
|
||||||
config::storageclass,
|
|
||||||
store::init_format::{load_format_erasure, save_format_file},
|
|
||||||
};
|
|
||||||
use rustfs_common::heal_channel::{DriveState, HealOpts, HealScanMode};
|
use rustfs_common::heal_channel::{DriveState, HealOpts, HealScanMode};
|
||||||
use rustfs_filemeta::{BLOCK_SIZE_V2, FileInfo, ObjectPartInfo, TRANSITION_COMPLETE};
|
use rustfs_filemeta::{BLOCK_SIZE_V2, FileInfo, ObjectPartInfo, TRANSITION_COMPLETE};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -1884,44 +1863,6 @@ mod heal_result_report_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn format_heal_cached_layout_rejects_a_disk_from_another_slot() {
|
|
||||||
let mut _temp_dirs = Vec::new();
|
|
||||||
let mut endpoints = Vec::new();
|
|
||||||
let mut disks = Vec::new();
|
|
||||||
for disk_index in 0..3 {
|
|
||||||
let (temp_dir, mut endpoint, disk) = real_disk().await;
|
|
||||||
endpoint.set_pool_index(0);
|
|
||||||
endpoint.set_set_index(0);
|
|
||||||
endpoint.set_disk_index(disk_index);
|
|
||||||
_temp_dirs.push(temp_dir);
|
|
||||||
endpoints.push(endpoint);
|
|
||||||
disks.push(Some(disk));
|
|
||||||
}
|
|
||||||
let set = set_disks_with(disks.clone(), endpoints, 1).await;
|
|
||||||
let mut wrong_slot = set.format.clone();
|
|
||||||
wrong_slot.erasure.this = set.format.erasure.sets[0][1];
|
|
||||||
save_format_file(&disks[0], &Some(wrong_slot))
|
|
||||||
.await
|
|
||||||
.expect("wrong-slot format fixture should be saved");
|
|
||||||
let mut correct_slot = set.format.clone();
|
|
||||||
correct_slot.erasure.this = set.format.erasure.sets[0][2];
|
|
||||||
save_format_file(&disks[2], &Some(correct_slot))
|
|
||||||
.await
|
|
||||||
.expect("correct format fixture should be saved");
|
|
||||||
|
|
||||||
let (_, heal_err) = set
|
|
||||||
.heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect("format heal should report the quorum failure in its result");
|
|
||||||
|
|
||||||
assert!(matches!(heal_err, Some(Error::CorruptedFormat)));
|
|
||||||
let unformatted = load_format_erasure(disks[1].as_ref().expect("second disk should be online"), true)
|
|
||||||
.await
|
|
||||||
.expect_err("a rejected fallback must not format the missing slot");
|
|
||||||
assert_eq!(unformatted, DiskError::UnformattedDisk);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression for #955: an offline disk must contribute exactly one drive
|
// Regression for #955: an offline disk must contribute exactly one drive
|
||||||
// record. Before the fix the offline branch fell through and pushed a second
|
// record. Before the fix the offline branch fell through and pushed a second
|
||||||
// (Corrupt) record for the same disk, so `before/after.drives` grew to
|
// (Corrupt) record for the same disk, so `before/after.drives` grew to
|
||||||
|
|||||||
@@ -343,23 +343,20 @@ impl SetDisks {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Claiming a misplaced drive into `self.disks` would let two slots or
|
// The drive's format may place it in a different erasure set than this
|
||||||
// sets manage the same drive and degrade together (backlog#799 B19).
|
// one. Claiming a misplaced drive into `self.disks` would let two sets
|
||||||
if set_idx != self.set_index || self.set_endpoints.get(disk_idx) != Some(ep) {
|
// manage the same drive and degrade together, so reject it here
|
||||||
|
// (backlog#799 B19).
|
||||||
|
if set_idx != self.set_index {
|
||||||
warn!(
|
warn!(
|
||||||
endpoint = %ep,
|
"renew_disk: drive {:?} belongs to set {} but is being renewed on set {}; skipping",
|
||||||
format_set_index = set_idx,
|
ep, set_idx, self.set_index
|
||||||
format_disk_index = disk_idx,
|
|
||||||
endpoint_pool_index = ep.pool_idx,
|
|
||||||
endpoint_set_index = ep.set_idx,
|
|
||||||
endpoint_disk_index = ep.disk_idx,
|
|
||||||
expected_pool_index = self.pool_index,
|
|
||||||
expected_set_index = self.set_index,
|
|
||||||
"renew_disk rejected a drive whose endpoint and format do not identify the same topology slot"
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check that the endpoint matches
|
||||||
|
|
||||||
let _ = new_disk.set_disk_id(Some(fm.erasure.this)).await;
|
let _ = new_disk.set_disk_id(Some(fm.erasure.this)).await;
|
||||||
new_disk.enable_health_check();
|
new_disk.enable_health_check();
|
||||||
|
|
||||||
@@ -718,108 +715,6 @@ mod tests {
|
|||||||
drop(temp_dirs);
|
drop(temp_dirs);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn renew_disk_rejects_a_format_from_another_slot_or_cluster() {
|
|
||||||
let disk_count = 3;
|
|
||||||
let format = FormatV3::new(1, disk_count);
|
|
||||||
let mut temp_dirs = Vec::with_capacity(disk_count);
|
|
||||||
let mut endpoints = Vec::with_capacity(disk_count);
|
|
||||||
let mut fixture_disks = Vec::with_capacity(disk_count);
|
|
||||||
|
|
||||||
for disk_idx in 0..disk_count {
|
|
||||||
let (temp_dir, endpoint, disk) = make_formatted_local_disk(disk_idx, &format).await;
|
|
||||||
temp_dirs.push(temp_dir);
|
|
||||||
endpoints.push(endpoint);
|
|
||||||
fixture_disks.push(disk);
|
|
||||||
}
|
|
||||||
|
|
||||||
let set_disks = SetDisks::new(
|
|
||||||
"test-owner".to_string(),
|
|
||||||
Arc::new(RwLock::new(vec![Some(fixture_disks[0].clone()), None, None])),
|
|
||||||
disk_count,
|
|
||||||
disk_count / 2,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
endpoints.clone(),
|
|
||||||
format.clone(),
|
|
||||||
Vec::new(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut other_cluster_format = format.clone();
|
|
||||||
other_cluster_format.id = Uuid::new_v4();
|
|
||||||
other_cluster_format.erasure.this = format.erasure.sets[0][2];
|
|
||||||
save_format_file(&Some(fixture_disks[2].clone()), &Some(other_cluster_format))
|
|
||||||
.await
|
|
||||||
.expect("other-cluster format should be written for the rejection test");
|
|
||||||
|
|
||||||
set_disks.renew_disk(&endpoints[2]).await;
|
|
||||||
|
|
||||||
let disks = set_disks.get_disks_internal().await;
|
|
||||||
assert_eq!(
|
|
||||||
disks[0]
|
|
||||||
.as_ref()
|
|
||||||
.expect("the canonical first slot must remain attached")
|
|
||||||
.endpoint(),
|
|
||||||
endpoints[0]
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
disks[2].is_none(),
|
|
||||||
"a disk from another deployment must remain detached even when its slot UUID matches"
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut correct_format = format.clone();
|
|
||||||
correct_format.erasure.this = format.erasure.sets[0][2];
|
|
||||||
let replacement_disk = new_disk(
|
|
||||||
&endpoints[2],
|
|
||||||
&DiskOption {
|
|
||||||
cleanup: false,
|
|
||||||
health_check: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("third endpoint should reopen after other-cluster rejection");
|
|
||||||
save_format_file(&Some(replacement_disk), &Some(correct_format))
|
|
||||||
.await
|
|
||||||
.expect("correct slot format should be restored");
|
|
||||||
|
|
||||||
set_disks.renew_disk(&endpoints[2]).await;
|
|
||||||
|
|
||||||
let disks = set_disks.get_disks_internal().await;
|
|
||||||
assert_eq!(
|
|
||||||
disks[0]
|
|
||||||
.as_ref()
|
|
||||||
.expect("the canonical first slot must remain attached")
|
|
||||||
.endpoint(),
|
|
||||||
endpoints[0]
|
|
||||||
);
|
|
||||||
assert_eq!(disks[2].as_ref().expect("the restored third slot should attach").endpoint(), endpoints[2]);
|
|
||||||
|
|
||||||
let third_disk = disks[2].clone();
|
|
||||||
let mut wrong_slot_format = format.clone();
|
|
||||||
wrong_slot_format.erasure.this = format.erasure.sets[0][0];
|
|
||||||
save_format_file(&third_disk, &Some(wrong_slot_format))
|
|
||||||
.await
|
|
||||||
.expect("wrong-slot format should be written for the rejection test");
|
|
||||||
set_disks.disks.write().await[2] = None;
|
|
||||||
|
|
||||||
let mut misplaced_endpoint = endpoints[2].clone();
|
|
||||||
misplaced_endpoint.set_disk_index(0);
|
|
||||||
set_disks.renew_disk(&misplaced_endpoint).await;
|
|
||||||
|
|
||||||
let disks = set_disks.get_disks_internal().await;
|
|
||||||
assert_eq!(
|
|
||||||
disks[0]
|
|
||||||
.as_ref()
|
|
||||||
.expect("the canonical first slot must remain attached")
|
|
||||||
.endpoint(),
|
|
||||||
endpoints[0]
|
|
||||||
);
|
|
||||||
assert!(disks[2].is_none(), "a disk claiming another endpoint's slot must remain detached");
|
|
||||||
|
|
||||||
drop(temp_dirs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDisks split P0 (#816): the borrow handle must mirror the core state and
|
// SetDisks split P0 (#816): the borrow handle must mirror the core state and
|
||||||
// the List operation family must run identically through it.
|
// the List operation family must run identically through it.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -3379,98 +3379,85 @@ mod tests {
|
|||||||
#[tokio::test(flavor = "multi_thread")]
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn complete_holds_object_then_upload_lock_through_commit() {
|
async fn complete_holds_object_then_upload_lock_through_commit() {
|
||||||
temp_env::async_with_vars(
|
temp_env::async_with_vars([(crate::object_api::ENV_RUSTFS_ENCRYPTED_RANGE_SEEK, Some("true"))], async {
|
||||||
[
|
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
|
||||||
(crate::object_api::ENV_RUSTFS_ENCRYPTED_RANGE_SEEK, Some("true")),
|
let signaling = Arc::new(SignalingLockClient::new(Arc::new(LocalClient::with_manager(manager))));
|
||||||
(rustfs_config::ENV_OBJECT_LOCK_ACQUIRE_TIMEOUT, Some("60")),
|
let lockers: Vec<Arc<dyn LockClient>> = vec![signaling.clone()];
|
||||||
],
|
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks_with_lockers(4, 0, 2, lockers).await;
|
||||||
async {
|
let bucket = "multipart-layout-lock-order-bucket";
|
||||||
let manager = Arc::new(rustfs_lock::GlobalLockManager::new());
|
let object = "object";
|
||||||
let signaling = Arc::new(SignalingLockClient::new(Arc::new(LocalClient::with_manager(manager))));
|
make_bucket_on_all(&disk_stores, bucket).await;
|
||||||
let lockers: Vec<Arc<dyn LockClient>> = vec![signaling.clone()];
|
let create_opts = ObjectOptions {
|
||||||
let (_temp_dirs, disk_stores, set_disks) = hermetic_set_disks_with_lockers(4, 0, 2, lockers).await;
|
user_defined: HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]),
|
||||||
let bucket = "multipart-layout-lock-order-bucket";
|
..Default::default()
|
||||||
let object = "object";
|
};
|
||||||
make_bucket_on_all(&disk_stores, bucket).await;
|
let (upload_id, parts) = stage_upload_with_create_opts(&set_disks, bucket, object, &[0x43; 4096], &create_opts).await;
|
||||||
let create_opts = ObjectOptions {
|
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
|
||||||
user_defined: HashMap::from([(SSEC_ALGORITHM_HEADER.to_string(), "AES256".to_string())]),
|
let upload_resource = rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path);
|
||||||
..Default::default()
|
let object_resource = rustfs_lock::ObjectKey::new(bucket, object);
|
||||||
};
|
signaling.set_target(upload_resource.clone());
|
||||||
let (upload_id, parts) =
|
signaling.clear_observed();
|
||||||
stage_upload_with_create_opts(&set_disks, bucket, object, &[0x43; 4096], &create_opts).await;
|
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
|
||||||
let upload_id_path = SetDisks::get_upload_id_dir(bucket, object, &upload_id);
|
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::AfterRename);
|
||||||
let upload_resource = rustfs_lock::ObjectKey::new(RUSTFS_META_MULTIPART_BUCKET, upload_id_path);
|
|
||||||
let object_resource = rustfs_lock::ObjectKey::new(bucket, object);
|
|
||||||
signaling.set_target(upload_resource.clone());
|
|
||||||
signaling.clear_observed();
|
|
||||||
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::DistErasure).await;
|
|
||||||
let barrier = MultipartCommitBarrier::install(bucket, object, MultipartCommitPause::AfterRename);
|
|
||||||
|
|
||||||
let complete_store = set_disks.clone();
|
let complete_store = set_disks.clone();
|
||||||
let complete_upload_id = upload_id.clone();
|
let complete_upload_id = upload_id.clone();
|
||||||
let complete = tokio::spawn(async move {
|
let complete = tokio::spawn(async move {
|
||||||
complete_store
|
complete_store
|
||||||
.complete_multipart_upload(bucket, object, &complete_upload_id, parts, &ObjectOptions::default())
|
.complete_multipart_upload(bucket, object, &complete_upload_id, parts, &ObjectOptions::default())
|
||||||
.await
|
|
||||||
});
|
|
||||||
barrier.wait_until_paused().await;
|
|
||||||
|
|
||||||
let observed = signaling.observed();
|
|
||||||
let object_position = observed
|
|
||||||
.iter()
|
|
||||||
.position(|resource| resource == &object_resource)
|
|
||||||
.expect("completion should acquire the object lock");
|
|
||||||
let upload_position = observed
|
|
||||||
.iter()
|
|
||||||
.position(|resource| resource == &upload_resource)
|
|
||||||
.expect("completion should acquire the upload lock");
|
|
||||||
assert!(object_position < upload_position, "completion must acquire object before upload");
|
|
||||||
|
|
||||||
let abort_store = set_disks.clone();
|
|
||||||
let abort_upload_id = upload_id.clone();
|
|
||||||
let abort = tokio::spawn(async move {
|
|
||||||
abort_store
|
|
||||||
.abort_multipart_upload(bucket, object, &abort_upload_id, &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
signaling.wait_for_attempts(2).await;
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert!(!abort.is_finished(), "abort must wait until completion releases the upload lock");
|
|
||||||
|
|
||||||
let list_store = set_disks.clone();
|
|
||||||
let list_upload_id = upload_id.clone();
|
|
||||||
let list = tokio::spawn(async move {
|
|
||||||
list_store
|
|
||||||
.list_object_parts(bucket, object, &list_upload_id, None, MAX_PARTS_COUNT, &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
signaling.wait_for_attempts(3).await;
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
assert!(!list.is_finished(), "ListParts must wait until completion releases the upload lock");
|
|
||||||
|
|
||||||
barrier.release();
|
|
||||||
complete
|
|
||||||
.await
|
.await
|
||||||
.expect("completion task should not panic")
|
});
|
||||||
.expect("completion should commit after the barrier is released");
|
barrier.wait_until_paused().await;
|
||||||
let abort_err = abort
|
|
||||||
|
let observed = signaling.observed();
|
||||||
|
let object_position = observed
|
||||||
|
.iter()
|
||||||
|
.position(|resource| resource == &object_resource)
|
||||||
|
.expect("completion should acquire the object lock");
|
||||||
|
let upload_position = observed
|
||||||
|
.iter()
|
||||||
|
.position(|resource| resource == &upload_resource)
|
||||||
|
.expect("completion should acquire the upload lock");
|
||||||
|
assert!(object_position < upload_position, "completion must acquire object before upload");
|
||||||
|
|
||||||
|
let abort_store = set_disks.clone();
|
||||||
|
let abort_upload_id = upload_id.clone();
|
||||||
|
let abort = tokio::spawn(async move {
|
||||||
|
abort_store
|
||||||
|
.abort_multipart_upload(bucket, object, &abort_upload_id, &ObjectOptions::default())
|
||||||
.await
|
.await
|
||||||
.expect("abort task should not panic")
|
});
|
||||||
.expect_err("the committed upload should no longer exist when abort acquires the lock");
|
signaling.wait_for_attempts(2).await;
|
||||||
assert!(
|
tokio::task::yield_now().await;
|
||||||
matches!(abort_err, StorageError::InvalidUploadID(..)),
|
assert!(!abort.is_finished(), "abort must wait until completion releases the upload lock");
|
||||||
"abort should return InvalidUploadID after completion, got {abort_err:?}"
|
|
||||||
);
|
let list_store = set_disks.clone();
|
||||||
let list_err = list
|
let list_upload_id = upload_id.clone();
|
||||||
|
let list = tokio::spawn(async move {
|
||||||
|
list_store
|
||||||
|
.list_object_parts(bucket, object, &list_upload_id, None, MAX_PARTS_COUNT, &ObjectOptions::default())
|
||||||
.await
|
.await
|
||||||
.expect("ListParts task should not panic")
|
});
|
||||||
.expect_err("the committed upload should no longer exist when ListParts acquires the lock");
|
signaling.wait_for_attempts(3).await;
|
||||||
assert!(
|
tokio::task::yield_now().await;
|
||||||
matches!(list_err, StorageError::InvalidUploadID(..)),
|
assert!(!list.is_finished(), "ListParts must wait until completion releases the upload lock");
|
||||||
"ListParts should return InvalidUploadID after completion, got {list_err:?}"
|
|
||||||
);
|
barrier.release();
|
||||||
},
|
complete
|
||||||
)
|
.await
|
||||||
|
.expect("completion task should not panic")
|
||||||
|
.expect("completion should commit after the barrier is released");
|
||||||
|
let abort_err = abort
|
||||||
|
.await
|
||||||
|
.expect("abort task should not panic")
|
||||||
|
.expect_err("the committed upload should no longer exist when abort acquires the lock");
|
||||||
|
assert!(matches!(abort_err, StorageError::InvalidUploadID(..)));
|
||||||
|
let list_err = list
|
||||||
|
.await
|
||||||
|
.expect("ListParts task should not panic")
|
||||||
|
.expect_err("the committed upload should no longer exist when ListParts acquires the lock");
|
||||||
|
assert!(matches!(list_err, StorageError::InvalidUploadID(..)));
|
||||||
|
})
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ use crate::object_api::{GetObjectBodySource, get_object_body_cache_hook_suppress
|
|||||||
use crate::services::tier::tier::{TierConfigMgr, TierOperationLease};
|
use crate::services::tier::tier::{TierConfigMgr, TierOperationLease};
|
||||||
use crate::store::ECStore;
|
use crate::store::ECStore;
|
||||||
use futures::FutureExt as _;
|
use futures::FutureExt as _;
|
||||||
use http::HeaderValue;
|
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
|
|
||||||
fn erasure_from_file_info(fi: &FileInfo, uses_legacy: bool) -> Result<coding::Erasure> {
|
fn erasure_from_file_info(fi: &FileInfo, uses_legacy: bool) -> Result<coding::Erasure> {
|
||||||
@@ -50,17 +49,6 @@ fn erasure_from_file_info(fi: &FileInfo, uses_legacy: bool) -> Result<coding::Er
|
|||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_object_reader_with_context(
|
|
||||||
ctx: &InstanceContext,
|
|
||||||
reader: Box<dyn AsyncRead + Unpin + Send + Sync>,
|
|
||||||
range: Option<HTTPRangeSpec>,
|
|
||||||
object_info: &ObjectInfo,
|
|
||||||
opts: &ObjectOptions,
|
|
||||||
headers: &HeaderMap<HeaderValue>,
|
|
||||||
) -> Result<(GetObjectReader, usize, i64)> {
|
|
||||||
GetObjectReader::new_with_resolver(reader, range, object_info, opts, headers, ctx.object_encryption_resolver()).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Length of the full plaintext body when — and only when — this read's output
|
/// Length of the full plaintext body when — and only when — this read's output
|
||||||
/// is exactly the object's complete plaintext, so the app-layer body cache may
|
/// is exactly the object's complete plaintext, so the app-layer body cache may
|
||||||
/// serve it in place of the erasure read.
|
/// serve it in place of the erasure read.
|
||||||
@@ -725,8 +713,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
|||||||
size_bucket,
|
size_bucket,
|
||||||
);
|
);
|
||||||
record_get_object_reader_path_observation(GET_OBJECT_PATH_CODEC_STREAMING, object_class, size_bucket);
|
record_get_object_reader_path_observation(GET_OBJECT_PATH_CODEC_STREAMING, object_class, size_bucket);
|
||||||
let (mut reader, _offset, _length) =
|
let (mut reader, _offset, _length) = GetObjectReader::new(stream, range, &object_info, opts, &h).await?;
|
||||||
get_object_reader_with_context(&self.ctx, stream, range, &object_info, opts, &h).await?;
|
|
||||||
// Carry the hook probe result so the app layer skips its
|
// Carry the hook probe result so the app layer skips its
|
||||||
// now-redundant lookup on the streaming miss path (ODC-16).
|
// now-redundant lookup on the streaming miss path (ODC-16).
|
||||||
reader.body_source = body_source;
|
reader.body_source = body_source;
|
||||||
@@ -758,8 +745,7 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
|||||||
let (rd, wd) = tokio::io::duplex(duplex_buffer_size);
|
let (rd, wd) = tokio::io::duplex(duplex_buffer_size);
|
||||||
debug!(bucket, object, duplex_buffer_size, "Created duplex pipe for object data transfer");
|
debug!(bucket, object, duplex_buffer_size, "Created duplex pipe for object data transfer");
|
||||||
|
|
||||||
let (mut reader, offset, length) =
|
let (mut reader, offset, length) = GetObjectReader::new(Box::new(rd), range, &object_info, opts, &h).await?;
|
||||||
get_object_reader_with_context(&self.ctx, Box::new(rd), range, &object_info, opts, &h).await?;
|
|
||||||
// Carry the hook probe result so the app layer skips its now-redundant
|
// Carry the hook probe result so the app layer skips its now-redundant
|
||||||
// lookup on the streaming miss path (ODC-16).
|
// lookup on the streaming miss path (ODC-16).
|
||||||
reader.body_source = body_source;
|
reader.body_source = body_source;
|
||||||
@@ -2334,25 +2320,13 @@ async fn pause_transition_commit(bucket: &str, object: &str, pause: TransitionCo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn persisted_transition_version(
|
fn persisted_transition_version(
|
||||||
remote_version: &str,
|
remote_version: &str,
|
||||||
) -> std::io::Result<(Option<String>, rustfs_filemeta::TransitionVersionState)> {
|
) -> std::io::Result<(Option<String>, rustfs_filemeta::TransitionVersionState)> {
|
||||||
persisted_transition_version_with_gate(remote_version, remote_version_state_writer_enabled())
|
persisted_transition_version_with_gate(remote_version, remote_version_state_writer_enabled())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
fn remote_version_state_writer_enabled() -> bool {
|
fn remote_version_state_writer_enabled() -> bool {
|
||||||
remote_version_state_writer_fleet_proof().is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remote_version_state_writer_fleet_proof() -> Option<crate::services::notification_sys::RemoteVersionStateFleetProofToken> {
|
|
||||||
remote_version_state_writer_requested()
|
|
||||||
.then(crate::services::notification_sys::acquire_remote_version_state_fleet_proof)
|
|
||||||
.flatten()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remote_version_state_writer_requested() -> bool {
|
|
||||||
remote_version_state_writer_enabled_for(
|
remote_version_state_writer_enabled_for(
|
||||||
rustfs_utils::get_env_bool(
|
rustfs_utils::get_env_bool(
|
||||||
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_WRITE,
|
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_WRITE,
|
||||||
@@ -2362,25 +2336,11 @@ fn remote_version_state_writer_requested() -> bool {
|
|||||||
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
|
rustfs_config::ENV_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
|
||||||
rustfs_config::DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
|
rustfs_config::DEFAULT_TIER_REMOTE_VERSION_STATE_FLEET_CONFIRMED,
|
||||||
),
|
),
|
||||||
true,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remote_version_state_writer_fleet_proof_matches(
|
fn remote_version_state_writer_enabled_for(requested: bool, fleet_confirmed: bool) -> bool {
|
||||||
proof: &crate::services::notification_sys::RemoteVersionStateFleetProofToken,
|
requested && fleet_confirmed
|
||||||
) -> bool {
|
|
||||||
remote_version_state_writer_fleet_proof_matches_for(
|
|
||||||
remote_version_state_writer_requested(),
|
|
||||||
crate::services::notification_sys::remote_version_state_fleet_proof_matches(proof),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remote_version_state_writer_fleet_proof_matches_for(requested: bool, fleet_proof_matches: bool) -> bool {
|
|
||||||
requested && fleet_proof_matches
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remote_version_state_writer_enabled_for(requested: bool, fleet_confirmed: bool, fleet_proof_valid: bool) -> bool {
|
|
||||||
requested && fleet_confirmed && fleet_proof_valid
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn persisted_transition_version_with_gate(
|
fn persisted_transition_version_with_gate(
|
||||||
@@ -2399,7 +2359,7 @@ fn persisted_transition_version_with_gate(
|
|||||||
Ok(_) => Ok((Some(remote_version.to_string()), rustfs_filemeta::TransitionVersionState::Exact)),
|
Ok(_) => Ok((Some(remote_version.to_string()), rustfs_filemeta::TransitionVersionState::Exact)),
|
||||||
Err(_) if !remote_version_state_writer_enabled => Err(std::io::Error::new(
|
Err(_) if !remote_version_state_writer_enabled => Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::Unsupported,
|
std::io::ErrorKind::Unsupported,
|
||||||
"opaque remote tier versions require the operator-attested live fleet capability gate",
|
"opaque remote tier versions require the operator-attested fleet gate",
|
||||||
)),
|
)),
|
||||||
Err(_) if remote_version == "null" => {
|
Err(_) if remote_version == "null" => {
|
||||||
Ok((Some(remote_version.to_string()), rustfs_filemeta::TransitionVersionState::SuspendedNull))
|
Ok((Some(remote_version.to_string()), rustfs_filemeta::TransitionVersionState::SuspendedNull))
|
||||||
@@ -2645,7 +2605,7 @@ mod transition_upload_completion_tests {
|
|||||||
mod transition_version_id_tests {
|
mod transition_version_id_tests {
|
||||||
use super::{
|
use super::{
|
||||||
TransitionUploadCandidate, persisted_transition_version, persisted_transition_version_with_gate,
|
TransitionUploadCandidate, persisted_transition_version, persisted_transition_version_with_gate,
|
||||||
remote_version_state_writer_enabled_for, remote_version_state_writer_fleet_proof_matches_for,
|
remote_version_state_writer_enabled_for,
|
||||||
};
|
};
|
||||||
use rustfs_filemeta::TransitionVersionState;
|
use rustfs_filemeta::TransitionVersionState;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -2688,35 +2648,15 @@ mod transition_version_id_tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_version_state_writer_requires_request_and_fleet_confirmation() {
|
fn remote_version_state_writer_requires_request_and_fleet_confirmation() {
|
||||||
for (case, requested, fleet_confirmed, fleet_proof_valid, expected) in [
|
for (case, requested, fleet_confirmed, expected) in [
|
||||||
("old defaults", false, false, false, false),
|
("old defaults", false, false, false),
|
||||||
("missing fleet confirmation", true, false, true, false),
|
("missing fleet confirmation", true, false, false),
|
||||||
("missing local opt-in", false, true, true, false),
|
("missing local opt-in", false, true, false),
|
||||||
("missing fleet proof", true, true, false, false),
|
("explicitly unconfirmed fleet", true, false, false),
|
||||||
("explicitly unconfirmed fleet", true, false, true, false),
|
("rolled-back writer", false, true, false),
|
||||||
("rolled-back writer", false, true, true, false),
|
("fully upgraded fleet", true, true, true),
|
||||||
("fully upgraded fleet", true, true, true, true),
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(remote_version_state_writer_enabled_for(requested, fleet_confirmed), expected, "{case}");
|
||||||
remote_version_state_writer_enabled_for(requested, fleet_confirmed, fleet_proof_valid),
|
|
||||||
expected,
|
|
||||||
"{case}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn remote_version_state_commit_rechecks_operator_gate_and_live_proof() {
|
|
||||||
for (case, requested, fleet_proof_matches, expected) in [
|
|
||||||
("operator gate closed", false, true, false),
|
|
||||||
("fleet proof changed", true, false, false),
|
|
||||||
("current authorization", true, true, true),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
remote_version_state_writer_fleet_proof_matches_for(requested, fleet_proof_matches),
|
|
||||||
expected,
|
|
||||||
"{case}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3901,16 +3841,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
let dest_obj = transaction.remote_object.clone();
|
let dest_obj = transaction.remote_object.clone();
|
||||||
let mut transition_meta = (*oi.user_defined).clone();
|
let mut transition_meta = (*oi.user_defined).clone();
|
||||||
transition_meta.insert("name".to_string(), object.to_string());
|
transition_meta.insert("name".to_string(), object.to_string());
|
||||||
rustfs_utils::http::metadata_compat::insert_str(
|
|
||||||
&mut transition_meta,
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TRANSACTION_ID,
|
|
||||||
transaction.transaction_id.to_string(),
|
|
||||||
);
|
|
||||||
rustfs_utils::http::metadata_compat::insert_str(
|
|
||||||
&mut transition_meta,
|
|
||||||
rustfs_utils::http::metadata_compat::SUFFIX_TRANSITION_TIER_DESTINATION_ID,
|
|
||||||
rustfs_utils::crypto::hex(transaction.backend_fingerprint),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(content_type) = oi.content_type.as_ref().filter(|value| !value.is_empty()) {
|
if let Some(content_type) = oi.content_type.as_ref().filter(|value| !value.is_empty()) {
|
||||||
transition_meta.insert(CONTENT_TYPE.to_ascii_lowercase(), content_type.clone());
|
transition_meta.insert(CONTENT_TYPE.to_ascii_lowercase(), content_type.clone());
|
||||||
@@ -4021,24 +3951,20 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object).await;
|
delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object).await;
|
||||||
return Err(err.into());
|
return Err(err.into());
|
||||||
}
|
}
|
||||||
let fleet_proof = remote_version_state_writer_fleet_proof();
|
let (transition_version_id, transition_version_state) = match persisted_transition_version(candidate.remote_version()) {
|
||||||
let remote_version_requires_fleet_proof =
|
Ok(version) => version,
|
||||||
!candidate.remote_version().is_empty() && Uuid::parse_str(candidate.remote_version()).is_err();
|
Err(err) => {
|
||||||
let (transition_version_id, transition_version_state) =
|
let cleanup_api = transition_cleanup_store(&self.ctx).await;
|
||||||
match persisted_transition_version_with_gate(candidate.remote_version(), fleet_proof.is_some()) {
|
if let Err(cleanup_err) = upload_cleanup.cleanup_rejected_upload(cleanup_api).await {
|
||||||
Ok(version) => version,
|
return Err(StorageError::Io(std::io::Error::other(format!(
|
||||||
Err(err) => {
|
"{err}; rejected remote upload cleanup failed: {cleanup_err}"
|
||||||
let cleanup_api = transition_cleanup_store(&self.ctx).await;
|
))));
|
||||||
if let Err(cleanup_err) = upload_cleanup.cleanup_rejected_upload(cleanup_api).await {
|
|
||||||
return Err(StorageError::Io(std::io::Error::other(format!(
|
|
||||||
"{err}; rejected remote upload cleanup failed: {cleanup_err}"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object)
|
|
||||||
.await;
|
|
||||||
return Err(err.into());
|
|
||||||
}
|
}
|
||||||
};
|
delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object)
|
||||||
|
.await;
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
if let Err(err) = advance_and_save_transition_transaction(
|
if let Err(err) = advance_and_save_transition_transaction(
|
||||||
transaction_api.as_ref(),
|
transaction_api.as_ref(),
|
||||||
&mut transaction,
|
&mut transaction,
|
||||||
@@ -4154,21 +4080,6 @@ impl crate::storage_api_contracts::object::ObjectOperations for SetDisks {
|
|||||||
}
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pause_transition_commit(bucket, object, TransitionCommitPause::AfterLeaseValidation).await;
|
pause_transition_commit(bucket, object, TransitionCommitPause::AfterLeaseValidation).await;
|
||||||
// This check is the fleet-proof lease linearization point. Revocation
|
|
||||||
// blocks later commits; an already-authorized local quorum commit is
|
|
||||||
// allowed to finish without holding a synchronous lock across I/O.
|
|
||||||
if remote_version_requires_fleet_proof
|
|
||||||
&& !fleet_proof
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(remote_version_state_writer_fleet_proof_matches)
|
|
||||||
{
|
|
||||||
drop(transition_lock_guard);
|
|
||||||
if upload_cleanup.cleanup().await.is_ok() {
|
|
||||||
delete_transition_transaction_after_remote_cleanup(transaction_api.as_ref(), transaction_id, bucket, object)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
return Err(Error::other("remote version state fleet capability changed during transition"));
|
|
||||||
}
|
|
||||||
if let Err(err) = advance_and_save_transition_transaction(
|
if let Err(err) = advance_and_save_transition_transaction(
|
||||||
transaction_api.as_ref(),
|
transaction_api.as_ref(),
|
||||||
&mut transaction,
|
&mut transaction,
|
||||||
@@ -4550,61 +4461,6 @@ mod erasure_construction_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod object_encryption_resolver_wiring_tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::object_api::{EncryptionResolutionError, ObjectEncryptionResolver, ReadEncryptionMaterial, ReadEncryptionRequest};
|
|
||||||
use std::io::Cursor;
|
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
||||||
|
|
||||||
struct CountingResolver {
|
|
||||||
calls: AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl ObjectEncryptionResolver for CountingResolver {
|
|
||||||
async fn resolve_read_material(
|
|
||||||
&self,
|
|
||||||
_request: ReadEncryptionRequest<'_>,
|
|
||||||
) -> std::result::Result<Option<ReadEncryptionMaterial>, EncryptionResolutionError> {
|
|
||||||
self.calls.fetch_add(1, Ordering::Relaxed);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_object_reader_forwards_instance_resolver() {
|
|
||||||
let resolver = Arc::new(CountingResolver {
|
|
||||||
calls: AtomicUsize::new(0),
|
|
||||||
});
|
|
||||||
let ctx = InstanceContext::new();
|
|
||||||
assert!(
|
|
||||||
ctx.set_object_encryption_resolver(resolver.clone()).is_ok(),
|
|
||||||
"fresh context should accept resolver"
|
|
||||||
);
|
|
||||||
let object_info = ObjectInfo {
|
|
||||||
bucket: "bucket".to_string(),
|
|
||||||
name: "object".to_string(),
|
|
||||||
size: 1,
|
|
||||||
user_defined: Arc::new(HashMap::from([("x-amz-server-side-encryption".to_string(), "AES256".to_string())])),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = get_object_reader_with_context(
|
|
||||||
&ctx,
|
|
||||||
Box::new(Cursor::new(Vec::<u8>::new())),
|
|
||||||
None,
|
|
||||||
&object_info,
|
|
||||||
&ObjectOptions::default(),
|
|
||||||
&HeaderMap::new(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_err(), "resolver returning no material must fail closed");
|
|
||||||
assert_eq!(resolver.calls.load(Ordering::Relaxed), 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
|
pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
|
||||||
//! Shared hermetic `SetDisks` construction for the ops tests below: the
|
//! Shared hermetic `SetDisks` construction for the ops tests below: the
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ impl SetDisks {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub async fn read_version_optimized(
|
pub async fn read_version_optimized(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -238,7 +238,7 @@ impl SetDisks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(level = "debug", skip(self))]
|
#[tracing::instrument(level = "debug", skip(self))]
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub(super) async fn get_object_fileinfo(
|
pub(super) async fn get_object_fileinfo(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -410,7 +410,7 @@ impl SetDisks {
|
|||||||
Ok((fi, parts_metadata, op_online_disks))
|
Ok((fi, parts_metadata, op_online_disks))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub(super) async fn get_object_info_and_quorum(
|
pub(super) async fn get_object_info_and_quorum(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -605,7 +605,7 @@ impl SetDisks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub(super) async fn get_object_with_fileinfo<W>(
|
pub(super) async fn get_object_with_fileinfo<W>(
|
||||||
// &self,
|
// &self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -1140,7 +1140,7 @@ impl SetDisks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub(super) async fn get_object_decode_reader_with_fileinfo(
|
pub(super) async fn get_object_decode_reader_with_fileinfo(
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
@@ -1296,7 +1296,7 @@ impl SetDisks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
async fn build_codec_streaming_part_reader(
|
async fn build_codec_streaming_part_reader(
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
object: &str,
|
object: &str,
|
||||||
@@ -1469,7 +1469,7 @@ fn multipart_part_checksum_algo(fi: &FileInfo, part_number: usize) -> HashAlgori
|
|||||||
/// `get_object_with_fileinfo` (backlog#870) so both report the same
|
/// `get_object_with_fileinfo` (backlog#870) so both report the same
|
||||||
/// stage-duration semantics.
|
/// stage-duration semantics.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
async fn setup_multipart_part_readers(
|
async fn setup_multipart_part_readers(
|
||||||
files: &[FileInfo],
|
files: &[FileInfo],
|
||||||
disks: &[Option<DiskStore>],
|
disks: &[Option<DiskStore>],
|
||||||
|
|||||||
@@ -310,13 +310,12 @@ impl ECStore {
|
|||||||
meta.set_created(opts.created_at);
|
meta.set_created(opts.created_at);
|
||||||
|
|
||||||
if opts.lock_enabled {
|
if opts.lock_enabled {
|
||||||
meta.object_lock_config_xml =
|
meta.object_lock_config_xml = crate::bucket::utils::serialize::<ObjectLockConfiguration>(&enableObjcetLockConfig)?;
|
||||||
crate::bucket::utils::serialize::<ObjectLockConfiguration>(&ENABLED_OBJECT_LOCK_CONFIG)?;
|
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&enableVersioningConfig)?;
|
||||||
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.versioning_enabled {
|
if opts.versioning_enabled {
|
||||||
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
|
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&enableVersioningConfig)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
await_bucket_namespace_operation(
|
await_bucket_namespace_operation(
|
||||||
@@ -569,7 +568,6 @@ mod tests {
|
|||||||
};
|
};
|
||||||
use crate::bucket::metadata::table_bucket_catalog_metadata_prefix;
|
use crate::bucket::metadata::table_bucket_catalog_metadata_prefix;
|
||||||
use crate::bucket::metadata_sys;
|
use crate::bucket::metadata_sys;
|
||||||
use crate::cluster::rpc::peer_s3_client::install_delete_bucket_empty_scan_barrier;
|
|
||||||
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
|
use crate::disk::{BUCKET_META_PREFIX, RUSTFS_META_BUCKET};
|
||||||
use crate::error::StorageError;
|
use crate::error::StorageError;
|
||||||
use crate::object_api::{ObjectOptions, PutObjReader};
|
use crate::object_api::{ObjectOptions, PutObjReader};
|
||||||
@@ -591,7 +589,6 @@ mod tests {
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::time::{Duration, SystemTime};
|
use std::time::{Duration, SystemTime};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
use tokio::sync::{Notify, OnceCell};
|
use tokio::sync::{Notify, OnceCell};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -1051,7 +1048,7 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("bucket should be created");
|
.expect("bucket should be created");
|
||||||
for disk_path in &disk_paths {
|
for disk_path in &disk_paths {
|
||||||
tokio::fs::create_dir_all(disk_path.join(&bucket).join("empty-directory/nested/leaf"))
|
tokio::fs::create_dir_all(disk_path.join(&bucket).join("empty-directory"))
|
||||||
.await
|
.await
|
||||||
.expect("empty directory remnant should be created");
|
.expect("empty directory remnant should be created");
|
||||||
}
|
}
|
||||||
@@ -1260,55 +1257,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial]
|
|
||||||
async fn bucket_delete_preserves_put_committed_after_empty_scan() {
|
|
||||||
let (_, ecstore) = setup_bucket_delete_test_env().await;
|
|
||||||
let bucket = format!("bucket-delete-empty-scan-race-{}", Uuid::new_v4().simple());
|
|
||||||
let object = "committed-after-empty-scan";
|
|
||||||
let payload = b"object committed after DeleteBucket empty scan".to_vec();
|
|
||||||
|
|
||||||
ecstore
|
|
||||||
.make_bucket(&bucket, &MakeBucketOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("bucket should be created");
|
|
||||||
|
|
||||||
let barrier = install_delete_bucket_empty_scan_barrier();
|
|
||||||
let delete_store = ecstore.clone();
|
|
||||||
let delete_bucket = bucket.clone();
|
|
||||||
let delete = tokio::spawn(async move {
|
|
||||||
delete_store
|
|
||||||
.delete_bucket(&delete_bucket, &DeleteBucketOptions::default())
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
barrier.wait_until_paused().await;
|
|
||||||
|
|
||||||
let mut put_reader = PutObjReader::from_vec(payload.clone());
|
|
||||||
ecstore
|
|
||||||
.put_object(&bucket, object, &mut put_reader, &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("PUT should commit while DeleteBucket is paused after its empty scan");
|
|
||||||
|
|
||||||
barrier.release();
|
|
||||||
let err = delete
|
|
||||||
.await
|
|
||||||
.expect("DeleteBucket task should join")
|
|
||||||
.expect_err("DeleteBucket must reject a PUT committed after its empty scan");
|
|
||||||
assert!(matches!(err, StorageError::BucketNotEmpty(name) if name == bucket));
|
|
||||||
|
|
||||||
let mut reader = ecstore
|
|
||||||
.get_object_reader(&bucket, object, None, http::HeaderMap::new(), &ObjectOptions::default())
|
|
||||||
.await
|
|
||||||
.expect("committed object should remain readable after DeleteBucket fails");
|
|
||||||
let mut restored = Vec::new();
|
|
||||||
reader
|
|
||||||
.stream
|
|
||||||
.read_to_end(&mut restored)
|
|
||||||
.await
|
|
||||||
.expect("object body should remain readable");
|
|
||||||
assert_eq!(restored, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
async fn bucket_recreation_does_not_publish_unverified_usage() {
|
async fn bucket_recreation_does_not_publish_unverified_usage() {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ impl ECStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut count_no_heal = 0;
|
let mut count_no_heal = 0;
|
||||||
let mut first_error = None;
|
|
||||||
for pool in self.pools.iter() {
|
for pool in self.pools.iter() {
|
||||||
let (mut result, err) = pool.heal_format(dry_run).await?;
|
let (mut result, err) = pool.heal_format(dry_run).await?;
|
||||||
if let Some(err) = err {
|
if let Some(err) = err {
|
||||||
@@ -38,8 +37,8 @@ impl ECStore {
|
|||||||
StorageError::NoHealRequired => {
|
StorageError::NoHealRequired => {
|
||||||
count_no_heal += 1;
|
count_no_heal += 1;
|
||||||
}
|
}
|
||||||
err => {
|
_ => {
|
||||||
first_error.get_or_insert(err);
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,9 +47,6 @@ impl ECStore {
|
|||||||
r.before.drives.append(&mut result.before.drives);
|
r.before.drives.append(&mut result.before.drives);
|
||||||
r.after.drives.append(&mut result.after.drives);
|
r.after.drives.append(&mut result.after.drives);
|
||||||
}
|
}
|
||||||
if let Some(err) = first_error {
|
|
||||||
return Ok((r, Some(err)));
|
|
||||||
}
|
|
||||||
if count_no_heal == self.pools.len() {
|
if count_no_heal == self.pools.len() {
|
||||||
info!(
|
info!(
|
||||||
event = EVENT_HEAL_FORMAT_COMPLETED,
|
event = EVENT_HEAL_FORMAT_COMPLETED,
|
||||||
@@ -169,134 +165,3 @@ impl ECStore {
|
|||||||
Err(StorageError::NotImplemented)
|
Err(StorageError::NotImplemented)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::disk::{DiskOption, format::FormatV3, new_disk};
|
|
||||||
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
|
|
||||||
use crate::store::init_format::{load_format_erasure, save_format_file};
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn handle_heal_format_continues_after_a_pool_error() {
|
|
||||||
let canonical_format = FormatV3::new(1, 3);
|
|
||||||
let mut foreign_format = canonical_format.clone();
|
|
||||||
foreign_format.id = Uuid::new_v4();
|
|
||||||
let mut temp_dirs = Vec::new();
|
|
||||||
let mut endpoints = Vec::new();
|
|
||||||
let mut disks = Vec::new();
|
|
||||||
|
|
||||||
for disk_index in 0..3 {
|
|
||||||
let temp_dir = tempfile::tempdir().expect("temporary disk root should be created");
|
|
||||||
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("temporary path should be UTF-8"))
|
|
||||||
.expect("temporary endpoint should parse");
|
|
||||||
endpoint.set_pool_index(0);
|
|
||||||
endpoint.set_set_index(0);
|
|
||||||
endpoint.set_disk_index(disk_index);
|
|
||||||
let disk = new_disk(
|
|
||||||
&endpoint,
|
|
||||||
&DiskOption {
|
|
||||||
cleanup: false,
|
|
||||||
health_check: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("temporary disk should open");
|
|
||||||
let mut disk_format = foreign_format.clone();
|
|
||||||
disk_format.erasure.this = foreign_format.erasure.sets[0][disk_index];
|
|
||||||
save_format_file(&Some(disk.clone()), &Some(disk_format))
|
|
||||||
.await
|
|
||||||
.expect("foreign format should be written");
|
|
||||||
temp_dirs.push(temp_dir);
|
|
||||||
endpoints.push(endpoint);
|
|
||||||
disks.push(Some(disk));
|
|
||||||
}
|
|
||||||
|
|
||||||
let pool_endpoints = PoolEndpoints {
|
|
||||||
legacy: false,
|
|
||||||
set_count: 1,
|
|
||||||
drives_per_set: 3,
|
|
||||||
endpoints: Endpoints::from(endpoints),
|
|
||||||
cmd_line: "foreign-format-majority-test".to_string(),
|
|
||||||
platform: "test".to_string(),
|
|
||||||
};
|
|
||||||
let pool = Sets::new(disks, &pool_endpoints, &canonical_format, 0, 1)
|
|
||||||
.await
|
|
||||||
.expect("test pool should build around the cached canonical format");
|
|
||||||
|
|
||||||
let mut recoverable_format = FormatV3::new(1, 3);
|
|
||||||
recoverable_format.id = canonical_format.id;
|
|
||||||
let mut recoverable_temp_dirs = Vec::new();
|
|
||||||
let mut recoverable_endpoints = Vec::new();
|
|
||||||
let mut recoverable_disks = Vec::new();
|
|
||||||
let mut unformatted_disk = None;
|
|
||||||
for disk_index in 0..3 {
|
|
||||||
let temp_dir = tempfile::tempdir().expect("temporary disk root should be created");
|
|
||||||
let mut endpoint = Endpoint::try_from(temp_dir.path().to_str().expect("temporary path should be UTF-8"))
|
|
||||||
.expect("temporary endpoint should parse");
|
|
||||||
endpoint.set_pool_index(1);
|
|
||||||
endpoint.set_set_index(0);
|
|
||||||
endpoint.set_disk_index(disk_index);
|
|
||||||
let disk = new_disk(
|
|
||||||
&endpoint,
|
|
||||||
&DiskOption {
|
|
||||||
cleanup: false,
|
|
||||||
health_check: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("temporary disk should open");
|
|
||||||
if disk_index < 2 {
|
|
||||||
let mut disk_format = recoverable_format.clone();
|
|
||||||
disk_format.erasure.this = recoverable_format.erasure.sets[0][disk_index];
|
|
||||||
save_format_file(&Some(disk.clone()), &Some(disk_format))
|
|
||||||
.await
|
|
||||||
.expect("recoverable format should be written");
|
|
||||||
} else {
|
|
||||||
unformatted_disk = Some(disk.clone());
|
|
||||||
}
|
|
||||||
recoverable_temp_dirs.push(temp_dir);
|
|
||||||
recoverable_endpoints.push(endpoint);
|
|
||||||
recoverable_disks.push(Some(disk));
|
|
||||||
}
|
|
||||||
let recoverable_pool_endpoints = PoolEndpoints {
|
|
||||||
legacy: false,
|
|
||||||
set_count: 1,
|
|
||||||
drives_per_set: 3,
|
|
||||||
endpoints: Endpoints::from(recoverable_endpoints),
|
|
||||||
cmd_line: "recoverable-format-test".to_string(),
|
|
||||||
platform: "test".to_string(),
|
|
||||||
};
|
|
||||||
let recoverable_pool = Sets::new(recoverable_disks, &recoverable_pool_endpoints, &recoverable_format, 1, 1)
|
|
||||||
.await
|
|
||||||
.expect("recoverable test pool should build");
|
|
||||||
|
|
||||||
let endpoint_pools = EndpointServerPools::from(vec![pool_endpoints.clone(), recoverable_pool_endpoints.clone()]);
|
|
||||||
let store = ECStore {
|
|
||||||
id: canonical_format.id,
|
|
||||||
disk_map: HashMap::new(),
|
|
||||||
pools: vec![pool, recoverable_pool],
|
|
||||||
peer_sys: S3PeerSys::new(&endpoint_pools),
|
|
||||||
pool_meta: RwLock::new(PoolMeta::default()),
|
|
||||||
rebalance_meta: RwLock::new(None),
|
|
||||||
decommission_cancelers: RwLock::new(Vec::new()),
|
|
||||||
start_gate: Mutex::new(()),
|
|
||||||
pool_meta_save_gate: Mutex::new(()),
|
|
||||||
ctx: crate::runtime::instance::bootstrap_ctx(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let (result, err) = store
|
|
||||||
.handle_heal_format(false)
|
|
||||||
.await
|
|
||||||
.expect("format heal should return the typed pool error");
|
|
||||||
assert!(
|
|
||||||
matches!(err, Some(StorageError::CorruptedFormat)),
|
|
||||||
"foreign format majority must not be downgraded to a successful heal: {err:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(result.disk_count, 3, "the recoverable pool should still be inspected");
|
|
||||||
let healed = load_format_erasure(&unformatted_disk.expect("the unformatted disk handle should be retained"), true)
|
|
||||||
.await
|
|
||||||
.expect("the later pool should be healed despite the first pool error");
|
|
||||||
assert_eq!(healed.erasure.this, recoverable_format.erasure.sets[0][2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -101,10 +101,6 @@ fn should_retry_local_decommission_resume(err: &Error, attempt: usize) -> bool {
|
|||||||
matches!(err, Error::ConfigNotFound) && attempt < LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES
|
matches!(err, Error::ConfigNotFound) && attempt < LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_retry_format_load(err: &Error) -> bool {
|
|
||||||
!matches!(err, Error::CorruptedFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn should_auto_start_rebalance_after_init(decommission_running: bool, rebalance_meta_loaded: bool) -> bool {
|
fn should_auto_start_rebalance_after_init(decommission_running: bool, rebalance_meta_loaded: bool) -> bool {
|
||||||
rebalance_meta_loaded && !decommission_running
|
rebalance_meta_loaded && !decommission_running
|
||||||
}
|
}
|
||||||
@@ -298,7 +294,7 @@ impl ECStore {
|
|||||||
// periodic monitoring until format loading succeeds. Startup RPC
|
// periodic monitoring until format loading succeeds. Startup RPC
|
||||||
// failures can still spawn recovery probes for peers that come up
|
// failures can still spawn recovery probes for peers that come up
|
||||||
// after this node.
|
// after this node.
|
||||||
let (mut disks, errs) = init_format::init_disks(
|
let (disks, errs) = init_format::init_disks(
|
||||||
&pool_eps.endpoints,
|
&pool_eps.endpoints,
|
||||||
&DiskOption {
|
&DiskOption {
|
||||||
cleanup: true,
|
cleanup: true,
|
||||||
@@ -313,10 +309,9 @@ impl ECStore {
|
|||||||
let mut times = 0;
|
let mut times = 0;
|
||||||
let mut interval = 1;
|
let mut interval = 1;
|
||||||
loop {
|
loop {
|
||||||
match init_format::connect_load_init_formats_with_instance_ctx(
|
match init_format::connect_load_init_formats(
|
||||||
&instance_ctx,
|
|
||||||
pool_first_is_local,
|
pool_first_is_local,
|
||||||
&mut disks,
|
&disks,
|
||||||
pool_eps.set_count,
|
pool_eps.set_count,
|
||||||
pool_eps.drives_per_set,
|
pool_eps.drives_per_set,
|
||||||
deployment_id,
|
deployment_id,
|
||||||
@@ -324,7 +319,6 @@ impl ECStore {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(fm) => break Ok(fm),
|
Ok(fm) => break Ok(fm),
|
||||||
Err(e) if !should_retry_format_load(&e) => break Err(e),
|
|
||||||
// Wrap the final error if we are giving up
|
// Wrap the final error if we are giving up
|
||||||
Err(e) if times >= 10 => {
|
Err(e) if times >= 10 => {
|
||||||
break Err(Error::other(format!("store init failed to load formats after {times} retries: {e}")));
|
break Err(Error::other(format!("store init failed to load formats after {times} retries: {e}")));
|
||||||
@@ -557,7 +551,7 @@ mod tests {
|
|||||||
LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES, load_pool_meta_for_startup, pool_first_endpoint_is_local,
|
LOCAL_DECOMMISSION_RESUME_MAX_CONFIG_RETRIES, load_pool_meta_for_startup, pool_first_endpoint_is_local,
|
||||||
pool_meta_has_active_decommission, preflight_startup_rpc_secret_with, resolve_startup_pool_defaults_with,
|
pool_meta_has_active_decommission, preflight_startup_rpc_secret_with, resolve_startup_pool_defaults_with,
|
||||||
resolve_store_init_stage_result, save_validated_pool_meta_for_startup, should_auto_start_rebalance_after_init,
|
resolve_store_init_stage_result, save_validated_pool_meta_for_startup, should_auto_start_rebalance_after_init,
|
||||||
should_retry_format_load, should_retry_local_decommission_resume, wait_for_local_decommission_resume_delay,
|
should_retry_local_decommission_resume, wait_for_local_decommission_resume_delay,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -568,12 +562,10 @@ mod tests {
|
|||||||
},
|
},
|
||||||
tier_sweeper::Jentry,
|
tier_sweeper::Jentry,
|
||||||
transition_transaction::{
|
transition_transaction::{
|
||||||
TRANSITION_TRANSACTION_RECORD_PREFIX, TransitionCleanupDecision, TransitionCleanupProof, TransitionOperatorError,
|
TRANSITION_TRANSACTION_RECORD_PREFIX, TransitionCleanupDecision, TransitionCleanupProof, TransitionRemoteVersion,
|
||||||
TransitionOperatorProbe, TransitionRemoteVersion, TransitionSourceIdentity, TransitionSourceVersionMode,
|
TransitionSourceIdentity, TransitionSourceVersionMode, TransitionTransaction, TransitionTransactionInit,
|
||||||
TransitionTransaction, TransitionTransactionInit, TransitionTransactionState,
|
TransitionTransactionState, load_transition_transaction_record, recover_transition_transaction_records,
|
||||||
delete_transition_candidate_for_operator, finalize_missing_transition_transaction_for_operator,
|
save_transition_transaction_record,
|
||||||
inspect_transition_transaction_for_operator, load_transition_transaction_record,
|
|
||||||
recover_transition_transaction_records, save_transition_transaction_record,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
client::transition_api::ReaderImpl,
|
client::transition_api::ReaderImpl,
|
||||||
@@ -583,7 +575,6 @@ mod tests {
|
|||||||
services::tier::{
|
services::tier::{
|
||||||
test_util::{MockWarmBackend, MockWarmOp, TransitionCleanupStoreBarrier, register_mock_tier},
|
test_util::{MockWarmBackend, MockWarmOp, TransitionCleanupStoreBarrier, register_mock_tier},
|
||||||
tier::{TIER_CONFIG_FILE, TierConfigMgr},
|
tier::{TIER_CONFIG_FILE, TierConfigMgr},
|
||||||
tier_config::{TierConfig, TierType, TierWasabi},
|
|
||||||
tier_mutation_intent::{
|
tier_mutation_intent::{
|
||||||
TIER_MUTATION_INTENT_RECORD_PREFIX, TierMutationIntent, TierMutationIntentKind, TierMutationIntentState,
|
TIER_MUTATION_INTENT_RECORD_PREFIX, TierMutationIntent, TierMutationIntentKind, TierMutationIntentState,
|
||||||
TierMutationIntentTarget, advance_tier_mutation_intent_record_idempotent, delete_tier_mutation_intent_record,
|
TierMutationIntentTarget, advance_tier_mutation_intent_record_idempotent, delete_tier_mutation_intent_record,
|
||||||
@@ -779,13 +770,6 @@ mod tests {
|
|||||||
assert!(!should_retry_local_decommission_resume(&StorageError::SlowDown, 0));
|
assert!(!should_retry_local_decommission_resume(&StorageError::SlowDown, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_should_retry_format_load_rejects_permanent_corruption() {
|
|
||||||
assert!(!should_retry_format_load(&StorageError::CorruptedFormat));
|
|
||||||
assert!(should_retry_format_load(&StorageError::ErasureReadQuorum));
|
|
||||||
assert!(should_retry_format_load(&StorageError::FirstDiskWait));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_auto_start_rebalance_after_init_allows_loaded_rebalance_without_decommission() {
|
fn test_should_auto_start_rebalance_after_init_allows_loaded_rebalance_without_decommission() {
|
||||||
assert!(should_auto_start_rebalance_after_init(false, true));
|
assert!(should_auto_start_rebalance_after_init(false, true));
|
||||||
@@ -1179,37 +1163,6 @@ mod tests {
|
|||||||
.len()
|
.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
|
||||||
async fn register_transition_reconcile_test_tier(
|
|
||||||
handle: &Arc<tokio::sync::RwLock<TierConfigMgr>>,
|
|
||||||
tier_name: &str,
|
|
||||||
) -> MockWarmBackend {
|
|
||||||
let backend = MockWarmBackend::new();
|
|
||||||
let mut manager = handle.write().await;
|
|
||||||
manager.tiers.insert(
|
|
||||||
tier_name.to_string(),
|
|
||||||
TierConfig {
|
|
||||||
version: "v1".to_string(),
|
|
||||||
tier_type: TierType::Wasabi,
|
|
||||||
name: tier_name.to_string(),
|
|
||||||
wasabi: Some(TierWasabi {
|
|
||||||
name: tier_name.to_string(),
|
|
||||||
endpoint: "https://s3.wasabisys.com".to_string(),
|
|
||||||
access_key: "test-access-key".to_string(),
|
|
||||||
secret_key: "test-secret-key".to_string(),
|
|
||||||
bucket: "mock-tier".to_string(),
|
|
||||||
prefix: format!("mock/{}/", uuid::Uuid::new_v4()),
|
|
||||||
region: "us-east-1".to_string(),
|
|
||||||
}),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
manager
|
|
||||||
.install_test_driver(tier_name, Box::new(backend.clone()))
|
|
||||||
.expect("mock fallback tier driver should install");
|
|
||||||
backend
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
async fn wait_for_tier_delete_journal_recovery(
|
async fn wait_for_tier_delete_journal_recovery(
|
||||||
store: Arc<crate::store::ECStore>,
|
store: Arc<crate::store::ECStore>,
|
||||||
@@ -1260,16 +1213,6 @@ mod tests {
|
|||||||
|
|
||||||
let registered: Vec<String> = instance_ctx.local_disk_map().read().await.keys().cloned().collect();
|
let registered: Vec<String> = instance_ctx.local_disk_map().read().await.keys().cloned().collect();
|
||||||
assert_eq!(registered.len(), 4, "the passed context must register all four local disks");
|
assert_eq!(registered.len(), 4, "the passed context must register all four local disks");
|
||||||
let registered_disk_ids = instance_ctx.local_disk_id_map();
|
|
||||||
let registered_disk_ids = registered_disk_ids.read().await;
|
|
||||||
assert_eq!(registered_disk_ids.len(), 4, "the passed context must publish all four disk IDs");
|
|
||||||
for endpoint in registered_disk_ids.values() {
|
|
||||||
assert!(
|
|
||||||
registered.contains(endpoint),
|
|
||||||
"every disk ID in the passed context must resolve to one of its registered endpoints"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
drop(registered_disk_ids);
|
|
||||||
let bootstrap = crate::runtime::instance::bootstrap_ctx();
|
let bootstrap = crate::runtime::instance::bootstrap_ctx();
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
bootstrap.deployment_id(),
|
bootstrap.deployment_id(),
|
||||||
@@ -1284,15 +1227,6 @@ mod tests {
|
|||||||
"the bootstrap context must not absorb the fresh store's disks"
|
"the bootstrap context must not absorb the fresh store's disks"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
drop(bootstrap_map);
|
|
||||||
let bootstrap_disk_ids = bootstrap.local_disk_id_map();
|
|
||||||
let bootstrap_disk_ids = bootstrap_disk_ids.read().await;
|
|
||||||
for endpoint in bootstrap_disk_ids.values() {
|
|
||||||
assert!(
|
|
||||||
!registered.contains(endpoint),
|
|
||||||
"the bootstrap context must not absorb the fresh store's disk IDs"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2804,7 +2738,7 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
|
||||||
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
|
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||||
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
||||||
.await
|
.await
|
||||||
.expect("tier lease should resolve")
|
.expect("tier lease should resolve")
|
||||||
@@ -2875,157 +2809,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial(storage_class_env)]
|
|
||||||
async fn operator_reconcile_deletes_exact_candidate_before_finalizing_record() {
|
|
||||||
let temp_dir = tempfile::tempdir().expect("create temp store dir");
|
|
||||||
let (ctx, store, _shutdown) =
|
|
||||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-operator-reconcile", &[4])).await;
|
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
|
||||||
|
|
||||||
let tier_name = "TXOPERATOR";
|
|
||||||
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
|
|
||||||
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
|
||||||
.await
|
|
||||||
.expect("tier lease should resolve")
|
|
||||||
.backend_identity();
|
|
||||||
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
|
|
||||||
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
|
|
||||||
transaction_id: uuid::Uuid::new_v4(),
|
|
||||||
owner_epoch: uuid::Uuid::new_v4(),
|
|
||||||
write_id: uuid::Uuid::new_v4(),
|
|
||||||
source: TransitionSourceIdentity {
|
|
||||||
bucket: "source-bucket".to_string(),
|
|
||||||
object: "source-object".to_string(),
|
|
||||||
version_id: None,
|
|
||||||
data_dir: uuid::Uuid::new_v4(),
|
|
||||||
mod_time_unix_nanos: 1_770_000_000_000_000_000,
|
|
||||||
size: 42,
|
|
||||||
etag: "source-etag".to_string(),
|
|
||||||
version_mode: TransitionSourceVersionMode::Unversioned,
|
|
||||||
},
|
|
||||||
tier_name: tier_name.to_string(),
|
|
||||||
backend_fingerprint: backend_identity,
|
|
||||||
not_after_unix_nanos: 1,
|
|
||||||
})
|
|
||||||
.expect("transaction should build");
|
|
||||||
transaction
|
|
||||||
.advance(transaction.fence(), TransitionTransactionState::UploadOutcomeUnknown, None)
|
|
||||||
.expect("transaction should enter unknown upload outcome state");
|
|
||||||
|
|
||||||
let remote_version = uuid::Uuid::new_v4().to_string();
|
|
||||||
backend.set_put_remote_version(Some(remote_version.clone())).await;
|
|
||||||
let candidate = bytes::Bytes::from_static(b"operator-confirmed transition candidate");
|
|
||||||
backend
|
|
||||||
.put(
|
|
||||||
&transaction.remote_object,
|
|
||||||
ReaderImpl::Body(candidate.clone()),
|
|
||||||
i64::try_from(candidate.len()).expect("test candidate length should fit i64"),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("mock backend should accept candidate");
|
|
||||||
save_transition_transaction_record(store.clone(), &transaction)
|
|
||||||
.await
|
|
||||||
.expect("transaction record should persist");
|
|
||||||
|
|
||||||
let status = inspect_transition_transaction_for_operator(store.clone(), transaction.transaction_id)
|
|
||||||
.await
|
|
||||||
.expect("operator inspection should probe the candidate");
|
|
||||||
assert_eq!(status.probe, TransitionOperatorProbe::VersionedPresent(remote_version.clone()));
|
|
||||||
|
|
||||||
let wrong_version = uuid::Uuid::new_v4().to_string();
|
|
||||||
let err = delete_transition_candidate_for_operator(store.clone(), transaction.transaction_id, &wrong_version)
|
|
||||||
.await
|
|
||||||
.expect_err("a mismatched exact version must fail before deleting a candidate");
|
|
||||||
assert!(matches!(
|
|
||||||
err,
|
|
||||||
TransitionOperatorError::CandidateVersionMismatch {
|
|
||||||
expected,
|
|
||||||
actual: TransitionOperatorProbe::VersionedPresent(ref observed),
|
|
||||||
} if expected == wrong_version && observed == &remote_version
|
|
||||||
));
|
|
||||||
assert!(backend.contains(&transaction.remote_object).await);
|
|
||||||
assert_eq!(backend.exact_remove_count(), 0);
|
|
||||||
load_transition_transaction_record(store.clone(), transaction.transaction_id)
|
|
||||||
.await
|
|
||||||
.expect("an incorrect exact version must retain the transaction journal");
|
|
||||||
|
|
||||||
let result = delete_transition_candidate_for_operator(store.clone(), transaction.transaction_id, &remote_version)
|
|
||||||
.await
|
|
||||||
.expect("operator-confirmed exact candidate should be deleted");
|
|
||||||
assert_eq!(result.status.probe, TransitionOperatorProbe::Missing);
|
|
||||||
assert!(result.journal_observed_after_delete);
|
|
||||||
assert_eq!(backend.exact_remove_count(), 1);
|
|
||||||
assert_eq!(backend.remove_versions().await, vec![(transaction.remote_object.clone(), remote_version)]);
|
|
||||||
load_transition_transaction_record(store.clone(), transaction.transaction_id)
|
|
||||||
.await
|
|
||||||
.expect("candidate deletion must retain the transaction journal");
|
|
||||||
|
|
||||||
finalize_missing_transition_transaction_for_operator(store.clone(), transaction.transaction_id)
|
|
||||||
.await
|
|
||||||
.expect("a separately confirmed missing candidate should permit finalization");
|
|
||||||
assert!(matches!(
|
|
||||||
load_transition_transaction_record(store, transaction.transaction_id).await,
|
|
||||||
Err(Error::ConfigNotFound)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
|
||||||
#[tokio::test]
|
|
||||||
#[serial_test::serial(storage_class_env)]
|
|
||||||
async fn operator_finalize_retains_record_without_missing_proof() {
|
|
||||||
let temp_dir = tempfile::tempdir().expect("create temp store dir");
|
|
||||||
let (ctx, store, _shutdown) =
|
|
||||||
without_storage_class_env(build_isolated_test_store(temp_dir.path(), "transition-operator-fail-closed", &[4])).await;
|
|
||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
|
||||||
|
|
||||||
let tier_name = "TXOPERATORFAIL";
|
|
||||||
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
|
|
||||||
let backend_identity = TierConfigMgr::acquire_operation_lease(&ctx.tier_config_mgr(), tier_name)
|
|
||||||
.await
|
|
||||||
.expect("tier lease should resolve")
|
|
||||||
.backend_identity();
|
|
||||||
let mut transaction = TransitionTransaction::new(TransitionTransactionInit {
|
|
||||||
deployment_id: ctx.deployment_id().expect("test store should initialize deployment id"),
|
|
||||||
transaction_id: uuid::Uuid::new_v4(),
|
|
||||||
owner_epoch: uuid::Uuid::new_v4(),
|
|
||||||
write_id: uuid::Uuid::new_v4(),
|
|
||||||
source: TransitionSourceIdentity {
|
|
||||||
bucket: "source-bucket".to_string(),
|
|
||||||
object: "source-object".to_string(),
|
|
||||||
version_id: None,
|
|
||||||
data_dir: uuid::Uuid::new_v4(),
|
|
||||||
mod_time_unix_nanos: 1_770_000_000_000_000_000,
|
|
||||||
size: 42,
|
|
||||||
etag: "source-etag".to_string(),
|
|
||||||
version_mode: TransitionSourceVersionMode::Unversioned,
|
|
||||||
},
|
|
||||||
tier_name: tier_name.to_string(),
|
|
||||||
backend_fingerprint: backend_identity,
|
|
||||||
not_after_unix_nanos: 1,
|
|
||||||
})
|
|
||||||
.expect("transaction should build");
|
|
||||||
transaction
|
|
||||||
.advance(transaction.fence(), TransitionTransactionState::UploadOutcomeUnknown, None)
|
|
||||||
.expect("transaction should enter unknown upload outcome state");
|
|
||||||
save_transition_transaction_record(store.clone(), &transaction)
|
|
||||||
.await
|
|
||||||
.expect("transaction record should persist");
|
|
||||||
backend
|
|
||||||
.set_transition_candidate_probe_override(Some(TransitionCandidateProbe::Unsupported))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
finalize_missing_transition_transaction_for_operator(store.clone(), transaction.transaction_id).await,
|
|
||||||
Err(TransitionOperatorError::CandidateNotMissing(TransitionOperatorProbe::Unsupported))
|
|
||||||
));
|
|
||||||
load_transition_transaction_record(store, transaction.transaction_id)
|
|
||||||
.await
|
|
||||||
.expect("an unsupported probe must retain the transaction journal");
|
|
||||||
assert_eq!(backend.remove_count().await, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "test-util")]
|
#[cfg(feature = "test-util")]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial_test::serial(storage_class_env)]
|
#[serial_test::serial(storage_class_env)]
|
||||||
@@ -3036,7 +2819,7 @@ mod tests {
|
|||||||
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
crate::bucket::metadata_sys::init_bucket_metadata_sys(store.clone(), Vec::new()).await;
|
||||||
|
|
||||||
let tier_name = "TXRESPONSELOSS";
|
let tier_name = "TXRESPONSELOSS";
|
||||||
let backend = register_transition_reconcile_test_tier(&ctx.tier_config_mgr(), tier_name).await;
|
let backend = register_mock_tier(&ctx.tier_config_mgr(), tier_name).await;
|
||||||
let bucket = "transition-response-loss-bucket";
|
let bucket = "transition-response-loss-bucket";
|
||||||
let object = "source.bin";
|
let object = "source.bin";
|
||||||
store
|
store
|
||||||
|
|||||||
+144
-1250
File diff suppressed because it is too large
Load Diff
@@ -6895,7 +6895,6 @@ mod test {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("local disk should be created");
|
.expect("local disk should be created");
|
||||||
disk.make_volume("bucket").await.expect("test bucket should be created");
|
|
||||||
let mut fi = FileInfo::new("object", 1, 1);
|
let mut fi = FileInfo::new("object", 1, 1);
|
||||||
fi.volume = "bucket".to_owned();
|
fi.volume = "bucket".to_owned();
|
||||||
fi.name = "object".to_owned();
|
fi.name = "object".to_owned();
|
||||||
|
|||||||
@@ -428,11 +428,11 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref ENABLED_OBJECT_LOCK_CONFIG: ObjectLockConfiguration = ObjectLockConfiguration {
|
static ref enableObjcetLockConfig: ObjectLockConfiguration = ObjectLockConfiguration {
|
||||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
static ref ENABLED_VERSIONING_CONFIG: VersioningConfiguration = VersioningConfiguration {
|
static ref enableVersioningConfig: VersioningConfiguration = VersioningConfiguration {
|
||||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -989,7 +989,7 @@ mod tests {
|
|||||||
|
|
||||||
init_local_disks(endpoint_pools.clone()).await.expect("init local disks");
|
init_local_disks(endpoint_pools.clone()).await.expect("init local disks");
|
||||||
|
|
||||||
let (mut disks, errs) = init_disks(
|
let (disks, errs) = init_disks(
|
||||||
&endpoint_pools.as_ref().first().expect("pool endpoints").endpoints,
|
&endpoint_pools.as_ref().first().expect("pool endpoints").endpoints,
|
||||||
&DiskOption {
|
&DiskOption {
|
||||||
cleanup: true,
|
cleanup: true,
|
||||||
@@ -999,7 +999,7 @@ mod tests {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert!(errs.iter().all(|err| err.is_none()), "disk init should succeed: {errs:?}");
|
assert!(errs.iter().all(|err| err.is_none()), "disk init should succeed: {errs:?}");
|
||||||
connect_load_init_formats(true, &mut disks, 1, 4, None)
|
connect_load_init_formats(true, &disks, 1, 4, None)
|
||||||
.await
|
.await
|
||||||
.expect("initialize format metadata");
|
.expect("initialize format metadata");
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip(self, data))]
|
#[instrument(skip(self, data))]
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub(super) async fn handle_put_object_part(
|
pub(super) async fn handle_put_object_part(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
|
|||||||
@@ -815,7 +815,7 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(level = "debug", skip(self))]
|
#[instrument(level = "debug", skip(self))]
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub(super) async fn handle_get_object_reader(
|
pub(super) async fn handle_get_object_reader(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
@@ -849,7 +849,7 @@ impl ECStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(level = "debug", skip(self, data))]
|
#[instrument(level = "debug", skip(self, data))]
|
||||||
#[hotpath::measure]
|
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||||
pub(super) async fn handle_put_object(
|
pub(super) async fn handle_put_object(
|
||||||
&self,
|
&self,
|
||||||
bucket: &str,
|
bucket: &str,
|
||||||
|
|||||||
@@ -28,26 +28,8 @@ async fn remember_local_disk_id(disk: &DiskStore) -> Option<Uuid> {
|
|||||||
|
|
||||||
async fn remember_local_disk_id_with_instance_ctx(instance_ctx: &Arc<InstanceContext>, disk: &DiskStore) -> Option<Uuid> {
|
async fn remember_local_disk_id_with_instance_ctx(instance_ctx: &Arc<InstanceContext>, disk: &DiskStore) -> Option<Uuid> {
|
||||||
let disk_id = disk.get_disk_id().await.ok().flatten()?;
|
let disk_id = disk.get_disk_id().await.ok().flatten()?;
|
||||||
record_local_disk_id_if_active(instance_ctx, disk, disk_id)
|
runtime_sources::record_local_disk_id(instance_ctx, disk_id, disk.endpoint().to_string()).await;
|
||||||
.await
|
Some(disk_id)
|
||||||
.then_some(disk_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn record_local_disk_id_if_active(instance_ctx: &Arc<InstanceContext>, disk: &DiskStore, disk_id: Uuid) -> bool {
|
|
||||||
let endpoint = disk.endpoint().to_string();
|
|
||||||
let local_disk_map = instance_ctx.local_disk_map();
|
|
||||||
let local_disks = local_disk_map.read().await;
|
|
||||||
let Some(active_disk) = local_disks.get(&endpoint).and_then(Option::as_ref) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
if !Arc::ptr_eq(active_disk, disk) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock order is local_disk_map -> local_disk_id_map so quarantine is the
|
|
||||||
// linearization point for rejecting an in-flight stale disk snapshot.
|
|
||||||
instance_ctx.local_disk_id_map().write().await.insert(disk_id, endpoint);
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn find_local_disk(disk_path: &str) -> Option<DiskStore> {
|
pub async fn find_local_disk(disk_path: &str) -> Option<DiskStore> {
|
||||||
@@ -246,7 +228,6 @@ pub async fn get_disk_infos(disks: &[Option<DiskStore>]) -> Vec<Option<DiskInfo>
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::disk::new_disk;
|
|
||||||
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
|
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
|
||||||
|
|
||||||
fn single_local_disk_pools(dir: &std::path::Path) -> EndpointServerPools {
|
fn single_local_disk_pools(dir: &std::path::Path) -> EndpointServerPools {
|
||||||
@@ -333,56 +314,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn stale_local_disk_snapshot_cannot_repopulate_the_id_registry() {
|
|
||||||
let temp_dir = tempfile::tempdir().expect("create temp disk dir");
|
|
||||||
let endpoint_pools = single_local_disk_pools(temp_dir.path());
|
|
||||||
let instance_ctx = Arc::new(InstanceContext::new());
|
|
||||||
init_local_disks_with_instance_ctx(&instance_ctx, endpoint_pools)
|
|
||||||
.await
|
|
||||||
.expect("local disk should be registered");
|
|
||||||
let disk = instance_ctx
|
|
||||||
.local_disk_map()
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.values()
|
|
||||||
.find_map(|disk| disk.clone())
|
|
||||||
.expect("registered local disk");
|
|
||||||
let endpoint = disk.endpoint().to_string();
|
|
||||||
let disk_id = Uuid::new_v4();
|
|
||||||
|
|
||||||
let local_disk_map = instance_ctx.local_disk_map();
|
|
||||||
let mut quarantine = local_disk_map.write().await;
|
|
||||||
let replacement = new_disk(
|
|
||||||
&disk.endpoint(),
|
|
||||||
&DiskOption {
|
|
||||||
cleanup: false,
|
|
||||||
health_check: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("replacement disk should initialize");
|
|
||||||
assert!(!Arc::ptr_eq(&disk, &replacement));
|
|
||||||
let task_ctx = instance_ctx.clone();
|
|
||||||
let task_disk = disk.clone();
|
|
||||||
let remember = tokio::spawn(async move { record_local_disk_id_if_active(&task_ctx, &task_disk, disk_id).await });
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
quarantine.insert(endpoint.clone(), Some(replacement.clone()));
|
|
||||||
drop(quarantine);
|
|
||||||
|
|
||||||
assert!(!remember.await.expect("stale lookup task should complete"));
|
|
||||||
assert!(!instance_ctx.local_disk_id_map().read().await.contains_key(&disk_id));
|
|
||||||
let active = instance_ctx
|
|
||||||
.local_disk_map()
|
|
||||||
.read()
|
|
||||||
.await
|
|
||||||
.get(&endpoint)
|
|
||||||
.cloned()
|
|
||||||
.flatten()
|
|
||||||
.expect("replacement disk should remain registered");
|
|
||||||
assert!(Arc::ptr_eq(&active, &replacement));
|
|
||||||
assert!(record_local_disk_id_if_active(&instance_ctx, &replacement, disk_id).await);
|
|
||||||
assert_eq!(instance_ctx.local_disk_id_map().read().await.get(&disk_id), Some(&endpoint));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
//! Test-only guard against tracing's process-global callsite-interest cache.
|
|
||||||
//!
|
|
||||||
//! Any test that installs its own subscriber and then asserts on span or event
|
|
||||||
//! context is asserting on process-global state. See
|
|
||||||
//! [`pin_callsite_interest_for_test`] for what goes wrong and why holding its
|
|
||||||
//! guard fixes it.
|
|
||||||
|
|
||||||
/// Stop a sibling test thread from poisoning tracing's process-global callsite
|
|
||||||
/// interest cache while this test asserts on span or event context.
|
|
||||||
///
|
|
||||||
/// `tracing` caches every callsite's `Interest` in **process-global** state, and
|
|
||||||
/// the first thread to reach a callsite fixes that value. While at most one
|
|
||||||
/// dispatcher is registered, tracing-core takes a fast path
|
|
||||||
/// (`Dispatchers::rebuilder` -> `Rebuilder::JustOne`) that derives a
|
|
||||||
/// newly-registered callsite's interest from whichever subscriber is current
|
|
||||||
/// *on the registering thread*, and registration happens exactly once (guarded
|
|
||||||
/// by a CAS in `DefaultCallsite::register`).
|
|
||||||
///
|
|
||||||
/// In a multi-threaded `cargo test` binary a sibling test therefore routinely
|
|
||||||
/// reaches a production callsite first, from a thread with no subscriber
|
|
||||||
/// installed: the interest is derived from that thread's `NoSubscriber` and
|
|
||||||
/// cached as `Interest::never()` for the whole process. From then on the
|
|
||||||
/// callsite is dead for *every* later caller, including a test that installed
|
|
||||||
/// its own subscriber — an `info_span!` silently evaluates to `Span::none()`, and
|
|
||||||
/// a `warn!`/`info!` event never fires at all.
|
|
||||||
///
|
|
||||||
/// Registering a second, inert dispatcher closes both halves of that race:
|
|
||||||
///
|
|
||||||
/// * constructing it rebuilds every *already-registered* callsite's interest
|
|
||||||
/// against the live dispatcher set — which includes the caller's subscriber —
|
|
||||||
/// repairing whatever a sibling may already have poisoned; and
|
|
||||||
/// * holding it alive keeps tracing-core off the single-dispatcher fast path, so
|
|
||||||
/// a callsite registered *later* by any thread is resolved against that live
|
|
||||||
/// set instead of the registering thread's `NoSubscriber`.
|
|
||||||
///
|
|
||||||
/// Call this **after** installing the test's subscriber, and hold the returned
|
|
||||||
/// guard for the rest of the test. Only the `cargo test` fallback needs it:
|
|
||||||
/// nextest runs each test in its own process, where there is no sibling thread
|
|
||||||
/// to lose the race to (see `docs/testing/README.md`).
|
|
||||||
#[must_use = "callsite interest is only pinned while the returned guard is held"]
|
|
||||||
pub(crate) fn pin_callsite_interest_for_test() -> tracing::Dispatch {
|
|
||||||
tracing::Dispatch::new(tracing_core::subscriber::NoSubscriber::default())
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## MinIO-generated encrypted fixtures
|
## MinIO-generated encrypted fixtures
|
||||||
|
|
||||||
`rustfs/src/storage/minio_generated_read_test.rs` validates the `bitrot -> GetObjectReader` path against raw MinIO backend data captured by
|
`minio_generated_read_test.rs` validates the `bitrot -> GetObjectReader` path against raw MinIO backend data captured by
|
||||||
`.\rustfs\scripts\minio_fixture_lab\lab.py`.
|
`.\rustfs\scripts\minio_fixture_lab\lab.py`.
|
||||||
|
|
||||||
It currently covers multipart fixtures for:
|
It currently covers multipart fixtures for:
|
||||||
@@ -20,5 +20,5 @@ Example:
|
|||||||
```powershell
|
```powershell
|
||||||
$env:RUSTFS_MINIO_FIXTURE_ROOT = '.\rustfs\tmp\minio-fixture-lab-local-key'
|
$env:RUSTFS_MINIO_FIXTURE_ROOT = '.\rustfs\tmp\minio-fixture-lab-local-key'
|
||||||
$env:RUSTFS_MINIO_STATIC_KMS_KEY_B64 = '<base64-32-byte-local-minio-kms-key>'
|
$env:RUSTFS_MINIO_STATIC_KMS_KEY_B64 = '<base64-32-byte-local-minio-kms-key>'
|
||||||
cargo +1.97.1 test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored
|
cargo +1.97.1 test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored
|
||||||
```
|
```
|
||||||
|
|||||||
+9
-11
@@ -4,13 +4,14 @@ use std::fs;
|
|||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use super::sse::SseObjectEncryptionResolver;
|
mod storage_api;
|
||||||
use super::storage_api::ecstore_test_support::{
|
|
||||||
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
|
|
||||||
};
|
|
||||||
use rustfs_filemeta::{FileInfo, FileInfoOpts, get_file_info};
|
use rustfs_filemeta::{FileInfo, FileInfoOpts, get_file_info};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use storage_api::minio_generated_read::{
|
||||||
|
DiskAPI as _, DiskOption, Endpoint, Erasure, GetObjectReader, ObjectInfo, ObjectOptions, create_bitrot_reader, new_disk,
|
||||||
|
};
|
||||||
use temp_env::async_with_vars;
|
use temp_env::async_with_vars;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWrite};
|
use tokio::io::{AsyncReadExt, AsyncWrite};
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ impl AsyncWrite for VecAsyncWriter {
|
|||||||
fn fixture_root() -> PathBuf {
|
fn fixture_root() -> PathBuf {
|
||||||
std::env::var_os("RUSTFS_MINIO_FIXTURE_ROOT")
|
std::env::var_os("RUSTFS_MINIO_FIXTURE_ROOT")
|
||||||
.map(PathBuf::from)
|
.map(PathBuf::from)
|
||||||
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../crates/rio-v2/tests/fixtures/minio-generated"))
|
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../rio-v2/tests/fixtures/minio-generated"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn case_dir(case_id: &str) -> PathBuf {
|
fn case_dir(case_id: &str) -> PathBuf {
|
||||||
@@ -136,14 +137,12 @@ async fn read_fixture_plaintext(encrypted: Vec<u8>, object_info: ObjectInfo, kms
|
|||||||
("RUSTFS_SSE_S3_MASTER_KEY", None::<String>),
|
("RUSTFS_SSE_S3_MASTER_KEY", None::<String>),
|
||||||
],
|
],
|
||||||
async move {
|
async move {
|
||||||
let resolver = SseObjectEncryptionResolver;
|
let (mut reader, offset, length) = GetObjectReader::new(
|
||||||
let (mut reader, offset, length) = GetObjectReader::new_with_resolver(
|
|
||||||
Box::new(Cursor::new(encrypted)),
|
Box::new(Cursor::new(encrypted)),
|
||||||
None,
|
None,
|
||||||
&object_info,
|
&object_info,
|
||||||
&ObjectOptions::default(),
|
&ObjectOptions::default(),
|
||||||
&http::HeaderMap::new(),
|
&http::HeaderMap::new(),
|
||||||
Some(&resolver),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
|
.map_err(|err| format!("construct GetObjectReader from MinIO raw fixture: {err:?}"))?;
|
||||||
@@ -220,12 +219,11 @@ async fn encrypted_fixture_bytes(case_dir: &Path, manifest: &ManifestRecord, fil
|
|||||||
readers.push(reader);
|
readers.push(reader);
|
||||||
}
|
}
|
||||||
|
|
||||||
let erasure = Erasure::try_new(
|
let erasure = Erasure::new(
|
||||||
file_info.erasure.data_blocks,
|
file_info.erasure.data_blocks,
|
||||||
file_info.erasure.parity_blocks,
|
file_info.erasure.parity_blocks,
|
||||||
file_info.erasure.block_size,
|
file_info.erasure.block_size,
|
||||||
)
|
);
|
||||||
.expect("fixture erasure geometry");
|
|
||||||
let mut writer = VecAsyncWriter::default();
|
let mut writer = VecAsyncWriter::default();
|
||||||
let (written, err) = erasure.decode(&mut writer, readers, 0, part.size, part.size).await;
|
let (written, err) = erasure.decode(&mut writer, readers, 0, part.size, part.size).await;
|
||||||
if let Some(err) = err {
|
if let Some(err) = err {
|
||||||
@@ -27,14 +27,7 @@ categories = ["web-programming", "development-tools"]
|
|||||||
[lib]
|
[lib]
|
||||||
doctest = false
|
doctest = false
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
hotpath = ["hotpath/hotpath"]
|
|
||||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
|
||||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,10 @@ documentation = "https://docs.rs/rustfs-filemeta/latest/rustfs_filemeta/"
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
hotpath = ["hotpath/hotpath", "hotpath/tokio", "rustfs-utils/hotpath"]
|
hotpath = ["dep:hotpath", "hotpath/hotpath"]
|
||||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "rustfs-utils/hotpath-alloc"]
|
|
||||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-utils/hotpath-cpu"]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
hotpath = { workspace = true, optional = true }
|
||||||
crc-fast = { workspace = true }
|
crc-fast = { workspace = true }
|
||||||
rmp.workspace = true
|
rmp.workspace = true
|
||||||
rmp-serde.workspace = true
|
rmp-serde.workspace = true
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ impl FileMeta {
|
|||||||
!matches!(Self::check_xl2_v1(buf), Err(_e))
|
!matches!(Self::check_xl2_v1(buf), Err(_e))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure(impl_type = "FileMeta")]
|
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "FileMeta"))]
|
||||||
pub fn load(buf: &[u8]) -> Result<FileMeta> {
|
pub fn load(buf: &[u8]) -> Result<FileMeta> {
|
||||||
let mut xl = FileMeta::default();
|
let mut xl = FileMeta::default();
|
||||||
xl.unmarshal_msg(buf)?;
|
xl.unmarshal_msg(buf)?;
|
||||||
@@ -112,7 +112,7 @@ impl FileMeta {
|
|||||||
Ok((bin_len, remaining))
|
Ok((bin_len, remaining))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure(impl_type = "FileMeta")]
|
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "FileMeta"))]
|
||||||
pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result<u64> {
|
pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result<u64> {
|
||||||
let i = buf.len() as u64;
|
let i = buf.len() as u64;
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ impl FileMeta {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure(impl_type = "FileMeta")]
|
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "FileMeta"))]
|
||||||
pub fn marshal_msg(&self) -> Result<Vec<u8>> {
|
pub fn marshal_msg(&self) -> Result<Vec<u8>> {
|
||||||
let mut wr = Vec::new();
|
let mut wr = Vec::new();
|
||||||
|
|
||||||
|
|||||||
@@ -29,48 +29,7 @@ categories = ["web-programming", "development-tools", "filesystem"]
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
hotpath = [
|
|
||||||
"hotpath/hotpath",
|
|
||||||
"hotpath/tokio",
|
|
||||||
"hotpath/futures",
|
|
||||||
"rustfs-common/hotpath",
|
|
||||||
"rustfs-concurrency/hotpath",
|
|
||||||
"rustfs-config/hotpath",
|
|
||||||
"rustfs-ecstore/hotpath",
|
|
||||||
"rustfs-madmin/hotpath",
|
|
||||||
"rustfs-storage-api/hotpath",
|
|
||||||
"rustfs-utils/hotpath",
|
|
||||||
"rustfs-test-utils/hotpath",
|
|
||||||
]
|
|
||||||
hotpath-alloc = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-alloc",
|
|
||||||
"rustfs-common/hotpath-alloc",
|
|
||||||
"rustfs-concurrency/hotpath-alloc",
|
|
||||||
"rustfs-config/hotpath-alloc",
|
|
||||||
"rustfs-ecstore/hotpath-alloc",
|
|
||||||
"rustfs-madmin/hotpath-alloc",
|
|
||||||
"rustfs-storage-api/hotpath-alloc",
|
|
||||||
"rustfs-utils/hotpath-alloc",
|
|
||||||
"rustfs-test-utils/hotpath-alloc",
|
|
||||||
]
|
|
||||||
hotpath-cpu = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-cpu",
|
|
||||||
"rustfs-common/hotpath-cpu",
|
|
||||||
"rustfs-concurrency/hotpath-cpu",
|
|
||||||
"rustfs-config/hotpath-cpu",
|
|
||||||
"rustfs-ecstore/hotpath-cpu",
|
|
||||||
"rustfs-madmin/hotpath-cpu",
|
|
||||||
"rustfs-storage-api/hotpath-cpu",
|
|
||||||
"rustfs-utils/hotpath-cpu",
|
|
||||||
"rustfs-test-utils/hotpath-cpu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
rustfs-config = { workspace = true }
|
rustfs-config = { workspace = true }
|
||||||
rustfs-concurrency = { workspace = true }
|
rustfs-concurrency = { workspace = true }
|
||||||
rustfs-ecstore = { workspace = true }
|
rustfs-ecstore = { workspace = true }
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ impl ErasureSetHealer {
|
|||||||
|
|
||||||
/// execute erasure set heal with resume
|
/// execute erasure set heal with resume
|
||||||
#[tracing::instrument(skip(self, buckets), fields(set_disk_id = %set_disk_id, bucket_count = buckets.len()))]
|
#[tracing::instrument(skip(self, buckets), fields(set_disk_id = %set_disk_id, bucket_count = buckets.len()))]
|
||||||
#[hotpath::measure]
|
|
||||||
pub async fn heal_erasure_set(&self, buckets: &[String], set_disk_id: &str) -> Result<()> {
|
pub async fn heal_erasure_set(&self, buckets: &[String], set_disk_id: &str) -> Result<()> {
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::erasure_healer",
|
target: "rustfs::heal::erasure_healer",
|
||||||
|
|||||||
@@ -584,7 +584,6 @@ impl HealTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument(skip(self), fields(task_id = %self.id, heal_type = ?self.heal_type))]
|
#[tracing::instrument(skip(self), fields(task_id = %self.id, heal_type = ?self.heal_type))]
|
||||||
#[hotpath::measure]
|
|
||||||
pub async fn execute(&self) -> Result<()> {
|
pub async fn execute(&self) -> Result<()> {
|
||||||
// update status and timestamps atomically to avoid race conditions
|
// update status and timestamps atomically to avoid race conditions
|
||||||
let now = SystemTime::now();
|
let now = SystemTime::now();
|
||||||
@@ -760,7 +759,6 @@ impl HealTask {
|
|||||||
|
|
||||||
// specific heal implementation method
|
// specific heal implementation method
|
||||||
#[tracing::instrument(skip(self), fields(bucket = %bucket, object = %object, version_id = ?version_id))]
|
#[tracing::instrument(skip(self), fields(bucket = %bucket, object = %object, version_id = ?version_id))]
|
||||||
#[hotpath::measure]
|
|
||||||
async fn heal_object(&self, bucket: &str, object: &str, version_id: Option<&str>) -> Result<()> {
|
async fn heal_object(&self, bucket: &str, object: &str, version_id: Option<&str>) -> Result<()> {
|
||||||
debug!(
|
debug!(
|
||||||
target: "rustfs::heal::task",
|
target: "rustfs::heal::task",
|
||||||
@@ -1406,7 +1404,6 @@ impl HealTask {
|
|||||||
self.heal_bucket_objects(bucket, prefix).await
|
self.heal_bucket_objects(bucket, prefix).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
|
||||||
async fn heal_bucket_objects(&self, bucket: &str, prefix: &str) -> Result<()> {
|
async fn heal_bucket_objects(&self, bucket: &str, prefix: &str) -> Result<()> {
|
||||||
let mut continuation_token: Option<String> = None;
|
let mut continuation_token: Option<String> = None;
|
||||||
let mut scanned = 0u64;
|
let mut scanned = 0u64;
|
||||||
|
|||||||
@@ -28,55 +28,7 @@ documentation = "https://docs.rs/rustfs-iam/latest/rustfs_iam/"
|
|||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[features]
|
|
||||||
default = []
|
|
||||||
hotpath = [
|
|
||||||
"hotpath/hotpath",
|
|
||||||
"hotpath/tokio",
|
|
||||||
"hotpath/futures",
|
|
||||||
"hotpath/reqwest-0-13",
|
|
||||||
"rustfs-config/hotpath",
|
|
||||||
"rustfs-credentials/hotpath",
|
|
||||||
"rustfs-crypto/hotpath",
|
|
||||||
"rustfs-ecstore/hotpath",
|
|
||||||
"rustfs-io-metrics/hotpath",
|
|
||||||
"rustfs-madmin/hotpath",
|
|
||||||
"rustfs-policy/hotpath",
|
|
||||||
"rustfs-storage-api/hotpath",
|
|
||||||
"rustfs-utils/hotpath",
|
|
||||||
"rustfs-test-utils/hotpath",
|
|
||||||
]
|
|
||||||
hotpath-alloc = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-alloc",
|
|
||||||
"rustfs-config/hotpath-alloc",
|
|
||||||
"rustfs-credentials/hotpath-alloc",
|
|
||||||
"rustfs-crypto/hotpath-alloc",
|
|
||||||
"rustfs-ecstore/hotpath-alloc",
|
|
||||||
"rustfs-io-metrics/hotpath-alloc",
|
|
||||||
"rustfs-madmin/hotpath-alloc",
|
|
||||||
"rustfs-policy/hotpath-alloc",
|
|
||||||
"rustfs-storage-api/hotpath-alloc",
|
|
||||||
"rustfs-utils/hotpath-alloc",
|
|
||||||
"rustfs-test-utils/hotpath-alloc",
|
|
||||||
]
|
|
||||||
hotpath-cpu = [
|
|
||||||
"hotpath",
|
|
||||||
"hotpath/hotpath-cpu",
|
|
||||||
"rustfs-config/hotpath-cpu",
|
|
||||||
"rustfs-credentials/hotpath-cpu",
|
|
||||||
"rustfs-crypto/hotpath-cpu",
|
|
||||||
"rustfs-ecstore/hotpath-cpu",
|
|
||||||
"rustfs-io-metrics/hotpath-cpu",
|
|
||||||
"rustfs-madmin/hotpath-cpu",
|
|
||||||
"rustfs-policy/hotpath-cpu",
|
|
||||||
"rustfs-storage-api/hotpath-cpu",
|
|
||||||
"rustfs-utils/hotpath-cpu",
|
|
||||||
"rustfs-test-utils/hotpath-cpu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
hotpath.workspace = true
|
|
||||||
rustfs-credentials = { workspace = true }
|
rustfs-credentials = { workspace = true }
|
||||||
rustfs-config = { workspace = true, features = ["server-config-model"] }
|
rustfs-config = { workspace = true, features = ["server-config-model"] }
|
||||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ where
|
|||||||
Ok(now)
|
Ok(now)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_policies(&self, bucket_name: &str) -> Result<HashMap<String, Policy>> {
|
pub async fn list_polices(&self, bucket_name: &str) -> Result<HashMap<String, Policy>> {
|
||||||
let mut m = HashMap::new();
|
let mut m = HashMap::new();
|
||||||
|
|
||||||
self.api.load_policy_docs(&mut m).await?;
|
self.api.load_policy_docs(&mut m).await?;
|
||||||
@@ -588,15 +588,6 @@ where
|
|||||||
Ok(filtered)
|
Ok(filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backward-compatible misspelling retained until the next breaking release.
|
|
||||||
#[deprecated(
|
|
||||||
since = "1.0.0",
|
|
||||||
note = "use list_policies instead; this alias will be removed in the next breaking release"
|
|
||||||
)]
|
|
||||||
pub async fn list_polices(&self, bucket_name: &str) -> Result<HashMap<String, Policy>> {
|
|
||||||
self.list_policies(bucket_name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn merge_policies(&self, name: &str) -> (String, Policy) {
|
pub async fn merge_policies(&self, name: &str) -> (String, Policy) {
|
||||||
let mut policies = Vec::new();
|
let mut policies = Vec::new();
|
||||||
let mut to_merge = Vec::new();
|
let mut to_merge = Vec::new();
|
||||||
@@ -2193,7 +2184,7 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_default_policies() -> HashMap<String, PolicyDoc> {
|
pub fn get_default_policyes() -> HashMap<String, PolicyDoc> {
|
||||||
let default_policies = &DEFAULT_POLICIES;
|
let default_policies = &DEFAULT_POLICIES;
|
||||||
default_policies
|
default_policies
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2210,15 +2201,6 @@ pub fn get_default_policies() -> HashMap<String, PolicyDoc> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Backward-compatible misspelling retained until the next breaking release.
|
|
||||||
#[deprecated(
|
|
||||||
since = "1.0.0",
|
|
||||||
note = "use get_default_policies instead; this alias will be removed in the next breaking release"
|
|
||||||
)]
|
|
||||||
pub fn get_default_policyes() -> HashMap<String, PolicyDoc> {
|
|
||||||
get_default_policies()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_default_canned_policies(policies: &mut HashMap<String, PolicyDoc>) {
|
fn set_default_canned_policies(policies: &mut HashMap<String, PolicyDoc>) {
|
||||||
let default_policies = &DEFAULT_POLICIES;
|
let default_policies = &DEFAULT_POLICIES;
|
||||||
for (k, v) in default_policies.iter() {
|
for (k, v) in default_policies.iter() {
|
||||||
@@ -2918,7 +2900,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_default_policies() {
|
fn test_get_default_policies() {
|
||||||
let policies = get_default_policies();
|
let policies = get_default_policyes();
|
||||||
|
|
||||||
// Should contain some default policies
|
// Should contain some default policies
|
||||||
assert!(!policies.is_empty());
|
assert!(!policies.is_empty());
|
||||||
@@ -2931,12 +2913,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[allow(deprecated)]
|
|
||||||
fn deprecated_get_default_policyes_matches_current_api() {
|
|
||||||
assert_eq!(get_default_policyes().len(), get_default_policies().len());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_get_token_signing_key() {
|
fn test_get_token_signing_key() {
|
||||||
// This function returns the global action credential's secret key
|
// This function returns the global action credential's secret key
|
||||||
|
|||||||
+27
-58
@@ -1139,11 +1139,8 @@ impl OidcSys {
|
|||||||
let mut policies = Vec::new();
|
let mut policies = Vec::new();
|
||||||
let mut groups = Vec::new();
|
let mut groups = Vec::new();
|
||||||
|
|
||||||
// Role-policy and claim-based authorization are separate OIDC modes. When a
|
// Add default role policy if configured
|
||||||
// role policy is configured, group claims still provide group context but
|
if !config.role_policy.is_empty() {
|
||||||
// must not also become policy names.
|
|
||||||
let has_role_policy = !config.role_policy.trim().is_empty();
|
|
||||||
if has_role_policy {
|
|
||||||
for policy in config.role_policy.split(',') {
|
for policy in config.role_policy.split(',') {
|
||||||
let policy = policy.trim();
|
let policy = policy.trim();
|
||||||
if !policy.is_empty() {
|
if !policy.is_empty() {
|
||||||
@@ -1152,20 +1149,21 @@ impl OidcSys {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map groups claim to policies
|
||||||
for group in &claims.groups {
|
for group in &claims.groups {
|
||||||
groups.push(group.clone());
|
groups.push(group.clone());
|
||||||
if !has_role_policy {
|
let policy_name = if config.claim_prefix.is_empty() {
|
||||||
let policy_name = if config.claim_prefix.is_empty() {
|
group.clone()
|
||||||
group.clone()
|
} else {
|
||||||
} else {
|
format!("{}{}", config.claim_prefix, group)
|
||||||
format!("{}{}", config.claim_prefix, group)
|
};
|
||||||
};
|
policies.push(policy_name);
|
||||||
policies.push(policy_name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !has_role_policy && config.claim_name != config.groups_claim {
|
// Map primary claim (if different from groups)
|
||||||
for val in extract_groups_claim(&claims.raw, &config.claim_name) {
|
if config.claim_name != config.groups_claim {
|
||||||
|
let claim_values = extract_groups_claim(&claims.raw, &config.claim_name);
|
||||||
|
for val in claim_values {
|
||||||
let policy_name = if config.claim_prefix.is_empty() {
|
let policy_name = if config.claim_prefix.is_empty() {
|
||||||
val
|
val
|
||||||
} else {
|
} else {
|
||||||
@@ -3203,22 +3201,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn role_policy_does_not_map_groups_as_policies() {
|
fn test_map_claims_to_policies_with_provider() {
|
||||||
let mut config = test_config("authentik");
|
let mut config = test_config("okta");
|
||||||
config.role_policy = "consoleAdmin".to_string();
|
config.role_policy = "readwrite".to_string();
|
||||||
config.claim_name = "policy".to_string();
|
config.display_name = "Okta".to_string();
|
||||||
|
|
||||||
let sys = make_test_sys(vec![config]);
|
let sys = make_test_sys(vec![config]);
|
||||||
|
|
||||||
let claims = OidcClaims {
|
let claims = OidcClaims {
|
||||||
groups: vec!["authentik Admins".to_string(), "users".to_string()],
|
sub: "user123".to_string(),
|
||||||
raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]),
|
email: "user@example.com".to_string(),
|
||||||
..Default::default()
|
username: "user".to_string(),
|
||||||
|
groups: vec!["admin".to_string(), "devs".to_string()],
|
||||||
|
raw: HashMap::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let (policies, groups) = sys.map_claims_to_policies("authentik", &claims);
|
let (policies, groups) = sys.map_claims_to_policies("okta", &claims);
|
||||||
assert_eq!(groups, vec!["authentik Admins", "users"]);
|
assert_eq!(groups, vec!["admin", "devs"]);
|
||||||
assert_eq!(policies, vec!["consoleAdmin"]);
|
assert!(policies.contains(&"readwrite".to_string()));
|
||||||
|
assert!(policies.contains(&"admin".to_string()));
|
||||||
|
assert!(policies.contains(&"devs".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3243,39 +3245,6 @@ mod tests {
|
|||||||
assert_eq!(policies.len(), 1);
|
assert_eq!(policies.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn blank_role_policy_uses_claim_mapping() {
|
|
||||||
let mut config = test_config("keycloak");
|
|
||||||
config.role_policy = " ".to_string();
|
|
||||||
|
|
||||||
let sys = make_test_sys(vec![config]);
|
|
||||||
let claims = OidcClaims {
|
|
||||||
groups: vec!["readonly".to_string()],
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims);
|
|
||||||
assert_eq!(groups, vec!["readonly"]);
|
|
||||||
assert_eq!(policies, vec!["readonly"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn claim_mapping_keeps_groups_with_distinct_primary_claim() {
|
|
||||||
let mut config = test_config("keycloak");
|
|
||||||
config.claim_name = "policy".to_string();
|
|
||||||
|
|
||||||
let sys = make_test_sys(vec![config]);
|
|
||||||
let claims = OidcClaims {
|
|
||||||
groups: vec!["developers".to_string()],
|
|
||||||
raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims);
|
|
||||||
assert_eq!(groups, vec!["developers"]);
|
|
||||||
assert_eq!(policies, vec!["developers", "readonly"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_list_providers() {
|
fn test_list_providers() {
|
||||||
let mut config = test_config("keycloak");
|
let mut config = test_config("keycloak");
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use crate::{
|
|||||||
cache::{Cache, CacheEntity},
|
cache::{Cache, CacheEntity},
|
||||||
error::{is_err_no_such_policy, is_err_no_such_user},
|
error::{is_err_no_such_policy, is_err_no_such_user},
|
||||||
keyring,
|
keyring,
|
||||||
manager::{extract_jwt_claims, extract_jwt_claims_allow_missing_exp, get_default_policies},
|
manager::{extract_jwt_claims, extract_jwt_claims_allow_missing_exp, get_default_policyes},
|
||||||
root_credentials,
|
root_credentials,
|
||||||
};
|
};
|
||||||
use futures::future::join_all;
|
use futures::future::join_all;
|
||||||
@@ -469,7 +469,6 @@ impl ObjectStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
|
||||||
async fn list_all_iamconfig_items(&self) -> Result<HashMap<String, Vec<String>>> {
|
async fn list_all_iamconfig_items(&self) -> Result<HashMap<String, Vec<String>>> {
|
||||||
let (tx, mut rx) = mpsc::channel::<StringOrErr>(100);
|
let (tx, mut rx) = mpsc::channel::<StringOrErr>(100);
|
||||||
|
|
||||||
@@ -509,7 +508,6 @@ impl ObjectStore {
|
|||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[hotpath::measure]
|
|
||||||
async fn load_policy_doc_concurrent(&self, names: &[String], mode: LoadMode) -> Result<Vec<PolicyDoc>> {
|
async fn load_policy_doc_concurrent(&self, names: &[String], mode: LoadMode) -> Result<Vec<PolicyDoc>> {
|
||||||
let mut futures = Vec::with_capacity(names.len());
|
let mut futures = Vec::with_capacity(names.len());
|
||||||
|
|
||||||
@@ -1129,7 +1127,7 @@ impl Store for ObjectStore {
|
|||||||
let cache_snapshot = cache.snapshot();
|
let cache_snapshot = cache.snapshot();
|
||||||
let listed_config_items = self.list_all_iamconfig_items().await?;
|
let listed_config_items = self.list_all_iamconfig_items().await?;
|
||||||
|
|
||||||
let mut policy_docs_cache = CacheEntity::new(get_default_policies());
|
let mut policy_docs_cache = CacheEntity::new(get_default_policyes());
|
||||||
|
|
||||||
if let Some(policies_list) = listed_config_items.get(POLICIES_LIST_KEY) {
|
if let Some(policies_list) = listed_config_items.get(POLICIES_LIST_KEY) {
|
||||||
// Load in fixed-size chunks so each policy is fetched exactly once.
|
// Load in fixed-size chunks so each policy is fetched exactly once.
|
||||||
|
|||||||
+5
-20
@@ -18,7 +18,7 @@ use crate::error::is_err_no_such_temp_account;
|
|||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::federation::OIDC_VIRTUAL_PARENT_CLAIM;
|
use crate::federation::OIDC_VIRTUAL_PARENT_CLAIM;
|
||||||
use crate::manager::extract_jwt_claims;
|
use crate::manager::extract_jwt_claims;
|
||||||
use crate::manager::get_default_policies;
|
use crate::manager::get_default_policyes;
|
||||||
use crate::manager::{IamCache, IamSyncMetricsSnapshot};
|
use crate::manager::{IamCache, IamSyncMetricsSnapshot};
|
||||||
use crate::store::GroupInfo;
|
use crate::store::GroupInfo;
|
||||||
use crate::store::MappedPolicy;
|
use crate::store::MappedPolicy;
|
||||||
@@ -249,7 +249,7 @@ impl<T: Store> IamSys<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_policy(&self, name: &str, notify: bool) -> Result<()> {
|
pub async fn delete_policy(&self, name: &str, notify: bool) -> Result<()> {
|
||||||
for k in get_default_policies().keys() {
|
for k in get_default_policyes().keys() {
|
||||||
if k == name {
|
if k == name {
|
||||||
return Err(Error::other("system policy can not be deleted"));
|
return Err(Error::other("system policy can not be deleted"));
|
||||||
}
|
}
|
||||||
@@ -291,17 +291,8 @@ impl<T: Store> IamSys<T> {
|
|||||||
self.store.api.load_mapped_policies(user_type, is_group, m).await
|
self.store.api.load_mapped_policies(user_type, is_group, m).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_policies(&self, bucket_name: &str) -> Result<HashMap<String, Policy>> {
|
|
||||||
self.store.list_policies(bucket_name).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backward-compatible misspelling retained until the next breaking release.
|
|
||||||
#[deprecated(
|
|
||||||
since = "1.0.0",
|
|
||||||
note = "use list_policies instead; this alias will be removed in the next breaking release"
|
|
||||||
)]
|
|
||||||
pub async fn list_polices(&self, bucket_name: &str) -> Result<HashMap<String, Policy>> {
|
pub async fn list_polices(&self, bucket_name: &str) -> Result<HashMap<String, Policy>> {
|
||||||
self.list_policies(bucket_name).await
|
self.store.list_polices(bucket_name).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn list_policy_docs(&self, bucket_name: &str) -> Result<HashMap<String, PolicyDoc>> {
|
pub async fn list_policy_docs(&self, bucket_name: &str) -> Result<HashMap<String, PolicyDoc>> {
|
||||||
@@ -1692,17 +1683,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::cache::{Cache, CacheEntity};
|
use crate::cache::{Cache, CacheEntity};
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::manager::get_default_policies;
|
use crate::manager::get_default_policyes;
|
||||||
use crate::store::{GroupInfo, MappedPolicy, Store, UserType};
|
use crate::store::{GroupInfo, MappedPolicy, Store, UserType};
|
||||||
use rustfs_credentials::{Credentials, init_global_action_credentials};
|
use rustfs_credentials::{Credentials, init_global_action_credentials};
|
||||||
use rustfs_policy::auth::{UserIdentity, get_new_credentials_with_metadata};
|
use rustfs_policy::auth::{UserIdentity, get_new_credentials_with_metadata};
|
||||||
use rustfs_policy::policy::Args;
|
use rustfs_policy::policy::Args;
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[allow(deprecated)]
|
|
||||||
fn deprecated_list_polices_api_is_available() {
|
|
||||||
let _ = IamSys::<StsTestMockStore>::list_polices;
|
|
||||||
}
|
|
||||||
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
|
use rustfs_policy::policy::action::{Action, AdminAction, S3Action};
|
||||||
use rustfs_policy::policy::policy_uses_existing_object_tag_conditions;
|
use rustfs_policy::policy::policy_uses_existing_object_tag_conditions;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -1940,7 +1925,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn load_all(&self, cache: &Cache) -> Result<()> {
|
async fn load_all(&self, cache: &Cache) -> Result<()> {
|
||||||
let mut policy_docs = get_default_policies();
|
let mut policy_docs = get_default_policyes();
|
||||||
let custom_claim_policy =
|
let custom_claim_policy =
|
||||||
Policy::parse_config(CUSTOM_STS_CLAIM_POLICY_JSON.as_bytes()).expect("custom STS claim policy should parse");
|
Policy::parse_config(CUSTOM_STS_CLAIM_POLICY_JSON.as_bytes()).expect("custom STS claim policy should parse");
|
||||||
policy_docs.insert(CUSTOM_STS_CLAIM_POLICY.to_string(), PolicyDoc::new(custom_claim_policy));
|
policy_docs.insert(CUSTOM_STS_CLAIM_POLICY.to_string(), PolicyDoc::new(custom_claim_policy));
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user