mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-30 18:12:14 +00:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e93509c4f | |||
| ee1aee0248 | |||
| 124b32742c | |||
| d618c78766 | |||
| 40ef0db9cc | |||
| b457c6abcc | |||
| 7051a5ce41 | |||
| 1d3ba1eb8b | |||
| 8368017fb2 | |||
| 699ef14ddd | |||
| 704ea43da5 | |||
| 35a20622f1 | |||
| 7662b2436a | |||
| d4f2efa2ad | |||
| 19cdd806a2 | |||
| 6e5f330ff5 | |||
| e86d4cb579 | |||
| e08847d2b6 | |||
| 145d38133b | |||
| 30dc04c94b | |||
| ad7663afd1 | |||
| 6e6b38ad8e | |||
| 8601179c39 | |||
| b83c9c4663 | |||
| 67904a6c18 | |||
| 2e5cef513f | |||
| 2d4f77fd3b | |||
| 920705417c | |||
| b097c94c59 | |||
| 3991a1d73c |
@@ -10,10 +10,16 @@ never weaken a check to get green.
|
||||
|
||||
## `check_layer_dependencies.sh` — layer DAG in `rustfs/src`
|
||||
|
||||
Enforces `interface (admin, storage/ecfs, storage/s3_api) → app → infra`; no
|
||||
upward imports. Known legacy violations live in
|
||||
Enforces `composition (server, startup/init) → interface (admin,
|
||||
storage/ecfs, storage/s3_api) → app → infra`; no upward imports. Server source
|
||||
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`.
|
||||
|
||||
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
|
||||
downward (move the shared type/function to the lower layer).
|
||||
- **You legitimately removed a baseline entry**: run
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
---
|
||||
name: rustfs-release-publish
|
||||
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 (发版/发布)."
|
||||
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 (发版/发布)."
|
||||
---
|
||||
# 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Pipeline shape:
|
||||
|
||||
```
|
||||
bump version files to <target> (final version, ONE commit) -> merge
|
||||
check console main against its latest Release
|
||||
-> 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
|
||||
-> verify release artifacts -> run binary locally + console checks
|
||||
-> verify preview Release assets -> run binary locally + console checks
|
||||
-> validate with latest rc client
|
||||
-> tag <target> at the SAME commit (zero delta) -> re-verify CI/release
|
||||
```
|
||||
@@ -23,7 +25,7 @@ On validation failure: fix lands on main via normal PR (version files are alread
|
||||
## Required inputs
|
||||
|
||||
- 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.*'` — and for stable targets `git tag -l '<target>-rc.*'` — after `git fetch --tags`).
|
||||
- Preview iteration `N` (default: next unused preview tag for that target; check with `git tag -l '<target>-preview.*'` after `git fetch --tags`).
|
||||
|
||||
If the target version is missing or ambiguous, stop and ask before doing anything (see the semver gate below).
|
||||
|
||||
@@ -45,14 +47,18 @@ Rules:
|
||||
|
||||
## Preview tag naming
|
||||
|
||||
- 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.
|
||||
- **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.
|
||||
- Use `<target>-preview.N` for every target, e.g. `1.0.0-beta.10-preview.3` or `1.1.0-preview.1`.
|
||||
- The canonical suffix is exactly `-preview.<digits>`. `build.yml` recognizes it before alpha/beta/rc classification and routes it to the preview-only path; any other tag containing `-preview` fails closed instead of being treated as a release.
|
||||
- A preview Release MUST be published with `isPrerelease=true` and `isLatest=false`. Any `*-latest` preview asset or preview-triggered `latest.json`, R2, Docker, or Helm publication is a pipeline failure.
|
||||
|
||||
## 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.
|
||||
- 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>"`.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -63,6 +69,61 @@ Rules:
|
||||
- `gh auth status` works; confirm you can view `gh release list -L 3`.
|
||||
- Confirm the exact final target version with the user if not explicit.
|
||||
|
||||
### Console release gate
|
||||
|
||||
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)
|
||||
|
||||
- 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.
|
||||
@@ -85,16 +146,17 @@ git push origin "<preview-tag>"
|
||||
|
||||
Pushing the tag triggers `.github/workflows/build.yml` ("Build and Release"); `docker.yml` chains off it via `workflow_run`.
|
||||
|
||||
The preview run builds versioned artifacts and publishes them in a GitHub prerelease. 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.
|
||||
|
||||
## Phase 3 — CI and artifact verification
|
||||
## Phase 3 — CI and preview Release verification
|
||||
|
||||
- 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.
|
||||
- Verify the GitHub release: `gh release view "<preview-tag>" --json isPrerelease,assets`
|
||||
- `isPrerelease` must be `true`.
|
||||
- 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`.
|
||||
- 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).
|
||||
- Find and watch the tag build: `gh run list --workflow build.yml --branch "<preview-tag>" --limit 1` then `gh run watch <run-id>`. Every build matrix target must succeed (linux x86_64/aarch64 × musl/gnu, macos-aarch64, windows-x86_64).
|
||||
- Confirm the Release publication jobs (`create-release`, `upload-release-assets`, and `publish-release`) succeed while `update-latest-version` is skipped.
|
||||
- Verify `gh release view "<preview-tag>" --json isPrerelease,assets,url`: `isPrerelease` must be `true`, and the Release must contain all 6 versioned platform zips, checksums, SBOM, and provenance with no `-latest` assets. Confirm `gh api repos/{owner}/{repo}/releases/latest --jq .tag_name` does not return `<preview-tag>`.
|
||||
- Record `PREVIOUS_DELIVERABLE`, selected from published Releases by `publishedAt` after excluding the current tag and every `-preview.N` tag. Verify `gh release view "<preview-tag>" --json body --jq .body` contains `## What's Changed` and, when `PREVIOUS_DELIVERABLE` exists, `**Full Changelog**: https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<preview-tag>`. For a repository with no previous deliverable, verify a Full Changelog link exists and record the GitHub baseline fallback.
|
||||
- Confirm preview-triggered Docker and Helm jobs are skipped. Preview validation covers the built RustFS binaries, embedded console, and rc compatibility; Docker image construction and Helm publication are deferred to the final tag because the Dockerfiles consume GitHub Release assets.
|
||||
|
||||
## Phase 4 — Run the artifact locally, verify the console
|
||||
|
||||
@@ -157,13 +219,15 @@ 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>`.
|
||||
- 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 tag's complete publication path: all matrix and release jobs green; `gh release view "<target>"` shows the full versioned and `-latest` asset set plus checksums, SBOM, and provenance; Docker and Helm workflows succeed; `latest.json` points to `<target>`. A stable target must have `isPrerelease=false` and `isLatest=true`. An alpha/beta/rc target must have `isPrerelease=true`; GitHub does not permit prereleases to be Latest, but the project `latest.json` still advances to the final non-preview target.
|
||||
- Verify the final Release body contains `## What's Changed` and a Full Changelog link. When `PREVIOUS_DELIVERABLE` exists, the link MUST be `https://github.com/rustfs/rustfs/compare/<PREVIOUS_DELIVERABLE>...<target>` and the baseline MUST equal the preview Release baseline; for example, both `1.0.0-beta.12-preview.1` and `1.0.0-beta.12` compare from `1.0.0-beta.11`.
|
||||
- Optionally spot-check `./rustfs --version` from a final-tag artifact — it must report `<target>`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Always report:
|
||||
|
||||
- Console gate result: previous/latest Console tags, whether merged changes required a release, `CONSOLE_HASH`, and Console run/Release URLs when a release was published.
|
||||
- Target version, preview tag(s) used, `PREVIEW_HASH` (which both tags point at).
|
||||
- Per-phase result (PASS/FAIL/BLOCKED) with key evidence: CI run URLs, release URLs, console check results, the rc command matrix.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Read before editing
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ on:
|
||||
- 'deny.toml'
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened, closed ]
|
||||
@@ -33,6 +35,8 @@ on:
|
||||
- 'deny.toml'
|
||||
- '.github/actions/**'
|
||||
- '.github/workflows/**'
|
||||
- 'scripts/release/create_or_update_release.sh'
|
||||
- 'scripts/security/check_preview_release_workflow.sh'
|
||||
- 'scripts/security/check_workflow_pins.sh'
|
||||
schedule:
|
||||
- cron: '0 3 * * 0' # Weekly on Sunday 03:00 UTC (staggered after the midnight ci/build crons)
|
||||
@@ -96,6 +100,9 @@ jobs:
|
||||
- name: Report unpinned GitHub Actions
|
||||
run: ./scripts/security/check_workflow_pins.sh --enforce
|
||||
|
||||
- name: Check preview release workflow policy
|
||||
run: ./scripts/security/check_preview_release_workflow.sh
|
||||
|
||||
dependency-review:
|
||||
name: Dependency Review
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+50
-81
@@ -107,13 +107,21 @@ jobs:
|
||||
|
||||
# Determine build type based on trigger
|
||||
if [[ "${{ startsWith(github.ref, 'refs/tags/') }}" == "true" ]]; then
|
||||
# Tag push - release or prerelease
|
||||
# Tag push - preview, release, or prerelease
|
||||
should_build=true
|
||||
tag_name="${GITHUB_REF#refs/tags/}"
|
||||
version="${tag_name}"
|
||||
|
||||
# Check if this is a prerelease
|
||||
if [[ "$tag_name" == *"alpha"* ]] || [[ "$tag_name" == *"beta"* ]] || [[ "$tag_name" == *"rc"* ]]; then
|
||||
# Preview tags publish a GitHub prerelease for validation, but
|
||||
# must not update any latest channel.
|
||||
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"
|
||||
is_prerelease=true
|
||||
echo "🚀 Prerelease build detected: $tag_name"
|
||||
@@ -714,6 +722,10 @@ jobs:
|
||||
echo ""
|
||||
|
||||
case "$BUILD_TYPE" in
|
||||
"preview")
|
||||
echo "🔍 Preview artifacts are published in a GitHub prerelease"
|
||||
echo "⏭️ Preview releases do not update latest channels"
|
||||
;;
|
||||
"development")
|
||||
echo "🛠️ Development build artifacts have been uploaded to OSS dev directory"
|
||||
echo "⚠️ This is a development build - not suitable for production use"
|
||||
@@ -732,7 +744,9 @@ jobs:
|
||||
|
||||
echo ""
|
||||
echo "🐳 Docker Images:"
|
||||
if [[ "${{ github.event.inputs.build_docker }}" == "false" ]]; then
|
||||
if [[ "$BUILD_TYPE" == "preview" ]]; 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)"
|
||||
elif [[ "$BUILD_STATUS" == "success" ]]; then
|
||||
echo "🔄 Docker images will be built and pushed automatically via workflow_run event"
|
||||
@@ -740,11 +754,11 @@ jobs:
|
||||
echo "❌ Docker image build will be skipped due to build failure"
|
||||
fi
|
||||
|
||||
# Create GitHub Release (only for tag pushes)
|
||||
# Create GitHub Release for every valid release tag, including previews
|
||||
create-release:
|
||||
name: Create GitHub Release
|
||||
needs: [ build-check, build-rustfs ]
|
||||
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
||||
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')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -767,9 +781,12 @@ jobs:
|
||||
VERSION="${{ needs.build-check.outputs.version }}"
|
||||
IS_PRERELEASE="${{ needs.build-check.outputs.is_prerelease }}"
|
||||
BUILD_TYPE="${{ needs.build-check.outputs.build_type }}"
|
||||
TARGET_COMMITISH=$(git rev-parse --verify "refs/tags/${TAG}^{commit}")
|
||||
|
||||
# Determine release type for title
|
||||
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
if [[ "$BUILD_TYPE" == "preview" ]]; then
|
||||
RELEASE_TYPE="preview"
|
||||
elif [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
if [[ "$TAG" == *"alpha"* ]]; then
|
||||
RELEASE_TYPE="alpha"
|
||||
elif [[ "$TAG" == *"beta"* ]]; then
|
||||
@@ -783,54 +800,24 @@ jobs:
|
||||
RELEASE_TYPE="release"
|
||||
fi
|
||||
|
||||
# Check if release already exists
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
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')
|
||||
# Create release title
|
||||
if [[ "$IS_PRERELEASE" == "true" ]]; then
|
||||
TITLE="RustFS $VERSION (${RELEASE_TYPE})"
|
||||
else
|
||||
# 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')
|
||||
TITLE="RustFS $VERSION"
|
||||
fi
|
||||
|
||||
echo "release_id=$RELEASE_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "release_url=$RELEASE_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "Created release: $RELEASE_URL"
|
||||
./scripts/release/create_or_update_release.sh \
|
||||
"$TAG" \
|
||||
"$TARGET_COMMITISH" \
|
||||
"$TITLE" \
|
||||
"$IS_PRERELEASE"
|
||||
|
||||
# Prepare and upload release assets
|
||||
upload-release-assets:
|
||||
name: Upload Release Assets
|
||||
needs: [ build-check, build-rustfs, create-release ]
|
||||
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
||||
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')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -920,8 +907,8 @@ jobs:
|
||||
# the pointed-to version is a prerelease.
|
||||
update-latest-version:
|
||||
name: Update Latest Version
|
||||
needs: [ build-check, upload-release-assets ]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: [ build-check, publish-release ]
|
||||
if: startsWith(github.ref, 'refs/tags/') && (needs.build-check.outputs.build_type == 'release' || needs.build-check.outputs.build_type == 'prerelease')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update latest.json
|
||||
@@ -980,51 +967,33 @@ jobs:
|
||||
publish-release:
|
||||
name: Publish Release
|
||||
needs: [ build-check, create-release, upload-release-assets ]
|
||||
if: startsWith(github.ref, 'refs/tags/') && needs.build-check.outputs.build_type != 'development'
|
||||
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')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
|
||||
- name: Update release notes and publish
|
||||
- name: Publish release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
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 }}"
|
||||
RELEASE_ID="${{ needs.create-release.outputs.release_id }}"
|
||||
|
||||
# Determine release type
|
||||
if [[ "$BUILD_TYPE" == "prerelease" ]]; then
|
||||
if [[ "$TAG" == *"alpha"* ]]; then
|
||||
RELEASE_TYPE="alpha"
|
||||
elif [[ "$TAG" == *"beta"* ]]; then
|
||||
RELEASE_TYPE="beta"
|
||||
elif [[ "$TAG" == *"rc"* ]]; then
|
||||
RELEASE_TYPE="rc"
|
||||
else
|
||||
RELEASE_TYPE="prerelease"
|
||||
fi
|
||||
# Publish the release and correct its channel state on retries.
|
||||
# Only a stable final release may become GitHub Latest.
|
||||
if [[ "$BUILD_TYPE" == "release" ]]; then
|
||||
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
|
||||
-F draft=false \
|
||||
-F prerelease=false \
|
||||
-f make_latest=true >/dev/null
|
||||
else
|
||||
RELEASE_TYPE="release"
|
||||
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
|
||||
-F draft=false \
|
||||
-F prerelease=true \
|
||||
-f make_latest=false >/dev/null
|
||||
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 "📄 Release URL: ${{ needs.create-release.outputs.release_url }}"
|
||||
|
||||
@@ -82,7 +82,8 @@ jobs:
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
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
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
@@ -220,6 +221,13 @@ jobs:
|
||||
create_latest=true
|
||||
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)
|
||||
v*alpha*|v*beta*|v*rc*|*alpha*|*beta*|*rc*)
|
||||
build_type="prerelease"
|
||||
|
||||
@@ -33,11 +33,12 @@ jobs:
|
||||
build-helm-package:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_dispatch' && !contains(github.event.inputs.version, '-preview')) ||
|
||||
(
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
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:
|
||||
|
||||
Generated
+168
-126
File diff suppressed because it is too large
Load Diff
+54
-52
@@ -69,7 +69,7 @@ edition = "2024"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/rustfs/rustfs"
|
||||
rust-version = "1.97.1"
|
||||
version = "1.0.0-beta.11"
|
||||
version = "1.0.0-beta.12"
|
||||
homepage = "https://rustfs.com"
|
||||
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"]
|
||||
@@ -86,58 +86,58 @@ redundant_clone = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
# RustFS Internal Crates
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-beta.11" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.11" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.11" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.11" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-beta.11" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.11" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.11" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.11" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.11" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.11" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.11" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.11" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.11" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.11" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.11" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.11" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.11" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.11" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.11" }
|
||||
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.11" }
|
||||
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.11" }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.11" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.11" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.11" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.11" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.11" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.11" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.11" }
|
||||
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.11" }
|
||||
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.11" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.11" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.11" }
|
||||
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.11" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.11" }
|
||||
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.11" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.11" }
|
||||
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.11" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.11" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.11" }
|
||||
rustfs = { path = "./rustfs", version = "1.0.0-beta.12" }
|
||||
rustfs-heal = { path = "crates/heal", version = "1.0.0-beta.12" }
|
||||
rustfs-audit = { path = "crates/audit", version = "1.0.0-beta.12" }
|
||||
rustfs-checksums = { path = "crates/checksums", version = "1.0.0-beta.12" }
|
||||
rustfs-common = { path = "crates/common", version = "1.0.0-beta.12" }
|
||||
rustfs-data-usage = { path = "crates/data-usage", version = "1.0.0-beta.12" }
|
||||
rustfs-config = { path = "./crates/config", version = "1.0.0-beta.12" }
|
||||
rustfs-concurrency = { path = "./crates/concurrency", version = "1.0.0-beta.12" }
|
||||
rustfs-credentials = { path = "crates/credentials", version = "1.0.0-beta.12" }
|
||||
rustfs-crypto = { path = "crates/crypto", version = "1.0.0-beta.12" }
|
||||
rustfs-ecstore = { path = "crates/ecstore", version = "1.0.0-beta.12" }
|
||||
rustfs-filemeta = { path = "crates/filemeta", version = "1.0.0-beta.12" }
|
||||
rustfs-iam = { path = "crates/iam", version = "1.0.0-beta.12" }
|
||||
rustfs-keystone = { path = "crates/keystone", version = "1.0.0-beta.12" }
|
||||
rustfs-lifecycle = { path = "crates/lifecycle", version = "1.0.0-beta.12" }
|
||||
rustfs-kms = { path = "crates/kms", version = "1.0.0-beta.12" }
|
||||
rustfs-lock = { path = "crates/lock", version = "1.0.0-beta.12" }
|
||||
rustfs-madmin = { path = "crates/madmin", version = "1.0.0-beta.12" }
|
||||
rustfs-notify = { path = "crates/notify", version = "1.0.0-beta.12" }
|
||||
rustfs-io-metrics = { path = "crates/io-metrics", version = "1.0.0-beta.12" }
|
||||
rustfs-io-core = { path = "crates/io-core", version = "1.0.0-beta.12" }
|
||||
rustfs-object-capacity = { path = "crates/object-capacity", version = "1.0.0-beta.12" }
|
||||
rustfs-object-data-cache = { path = "crates/object-data-cache", version = "1.0.0-beta.12" }
|
||||
rustfs-log-analyzer = { path = "crates/log-analyzer", version = "1.0.0-beta.12" }
|
||||
rustfs-obs = { path = "crates/obs", version = "1.0.0-beta.12" }
|
||||
rustfs-policy = { path = "crates/policy", version = "1.0.0-beta.12" }
|
||||
rustfs-protos = { path = "crates/protos", version = "1.0.0-beta.12" }
|
||||
rustfs-protocols = { path = "crates/protocols", version = "1.0.0-beta.12" }
|
||||
rustfs-replication = { path = "crates/replication", version = "1.0.0-beta.12" }
|
||||
rustfs-rio = { path = "crates/rio", version = "1.0.0-beta.12" }
|
||||
rustfs-rio-v2 = { path = "crates/rio-v2", version = "1.0.0-beta.12" }
|
||||
rustfs-s3-types = { path = "crates/s3-types", version = "1.0.0-beta.12" }
|
||||
rustfs-s3-ops = { path = "crates/s3-ops", version = "1.0.0-beta.12" }
|
||||
rustfs-s3select-api = { path = "crates/s3select-api", version = "1.0.0-beta.12" }
|
||||
rustfs-s3select-query = { path = "crates/s3select-query", version = "1.0.0-beta.12" }
|
||||
rustfs-scanner = { path = "crates/scanner", version = "1.0.0-beta.12" }
|
||||
rustfs-security-governance = { path = "crates/security-governance", version = "1.0.0-beta.12" }
|
||||
rustfs-extension-schema = { path = "crates/extension-schema", version = "1.0.0-beta.12" }
|
||||
rustfs-signer = { path = "crates/signer", version = "1.0.0-beta.12" }
|
||||
rustfs-storage-api = { path = "crates/storage-api", version = "1.0.0-beta.12" }
|
||||
rustfs-trusted-proxies = { path = "crates/trusted-proxies", version = "1.0.0-beta.12" }
|
||||
rustfs-targets = { path = "crates/targets", version = "1.0.0-beta.12" }
|
||||
rustfs-test-utils = { path = "crates/test-utils", version = "1.0.0-beta.12" }
|
||||
rustfs-tls-runtime = { path = "crates/tls-runtime", version = "1.0.0-beta.12" }
|
||||
rustfs-utils = { path = "crates/utils", version = "1.0.0-beta.12" }
|
||||
rustfs-zip = { path = "./crates/zip", version = "1.0.0-beta.12" }
|
||||
|
||||
# Async Runtime and Networking
|
||||
async-channel = "2.5.0"
|
||||
async_zip = { default-features = false, version = "0.0.18" }
|
||||
mysql_async = { default-features = false, version = "0.37" }
|
||||
async-compression = { version = "0.4.42" }
|
||||
async-compression = { version = "0.4.43" }
|
||||
async-recursion = "1.1.1"
|
||||
async-trait = "0.1.91"
|
||||
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-rustls = { default-features = false, version = "0.27.9" }
|
||||
hyper-util = { version = "0.1.20" }
|
||||
http = "1.4.2"
|
||||
http = "1.5.0"
|
||||
http-body = "1.1.0"
|
||||
http-body-util = "0.1.4"
|
||||
minlz = "1.2.3"
|
||||
@@ -200,7 +200,7 @@ jsonwebtoken = { version = "11.0.0" }
|
||||
openidconnect = { default-features = false, version = "4.0" }
|
||||
pbkdf2 = "0.13.0"
|
||||
rsa = { version = "=0.10.0-rc.18" }
|
||||
rustls = { default-features = false, version = "0.23.42" }
|
||||
rustls = { default-features = false, version = "0.23.43" }
|
||||
rustls-native-certs = "0.8"
|
||||
rustls-pki-types = "1.15.1"
|
||||
sha1 = "0.11.0"
|
||||
@@ -256,6 +256,7 @@ hashbrown = { version = "0.17.1" }
|
||||
hex = "0.4.3"
|
||||
hex-simd = "0.8.0"
|
||||
highway = { version = "1.3.0" }
|
||||
hostname = "0.4.2"
|
||||
ipnetwork = { version = "0.21.1" }
|
||||
lazy_static = "1.5.0"
|
||||
libc = "0.2.189"
|
||||
@@ -283,7 +284,8 @@ reed-solomon-erasure = { package = "rustfs-erasure-codec", version = "8.0.2" }
|
||||
reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
|
||||
redis = { version = "1.4.1" }
|
||||
redis = { version = "1.5.0" }
|
||||
rustify = { version = "0.7", default-features = false }
|
||||
rustix = { version = "1.1.4" }
|
||||
rust-embed = { version = "8.12.0" }
|
||||
rustc-hash = { version = "2.1.3" }
|
||||
@@ -346,7 +348,7 @@ dav-server = "0.11.0"
|
||||
|
||||
# Performance Analysis and Memory Profiling
|
||||
mimalloc = "0.1.52"
|
||||
hotpath = "0.22.0"
|
||||
hotpath = { version = "0.22.0", default-features = false }
|
||||
# Snapshot testing for output format regression detection
|
||||
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
|
||||
|
||||
# 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.11
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.12
|
||||
```
|
||||
|
||||
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:1.0.0-beta.11
|
||||
docker run -d -p 9000:9000 -p 9001:9001 -v $(pwd)/data:/data -v $(pwd)/logs:/logs rustfs/rustfs:1.0.0-beta.12
|
||||
```
|
||||
|
||||
如果您通过绑定挂载启用 TLS 证书目录,也请用同样方式准备该目录:
|
||||
|
||||
@@ -66,6 +66,10 @@ 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.
|
||||
|
||||
## 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
|
||||
|
||||
- `RUSTFS_SCANNER_SPEED` (canonical, also accepts `MINIO_SCANNER_SPEED`)
|
||||
|
||||
@@ -131,6 +131,10 @@ pub const ENV_RUSTFS_ADDRESS: &str = "RUSTFS_ADDRESS";
|
||||
/// Environment variable for server 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.
|
||||
pub const ENV_UNSAFE_BYPASS_DISK_CHECK: &str = "RUSTFS_UNSAFE_BYPASS_DISK_CHECK";
|
||||
|
||||
|
||||
@@ -2136,11 +2136,20 @@ async fn four_node_manual_transition_distributed_admission_conflict_reports_stat
|
||||
assert_eq!(terminal["bucket"].as_str(), Some(bucket.as_str()));
|
||||
assert_eq!(terminal["prefix"].as_str(), Some(prefix));
|
||||
assert_eq!(terminal["dry_run"].as_bool(), Some(false));
|
||||
assert_eq!(
|
||||
terminal["status"].as_str(),
|
||||
Some("partial"),
|
||||
let terminal_status = terminal["status"].as_str();
|
||||
assert!(
|
||||
matches!(terminal_status, Some("partial" | "unknown")),
|
||||
"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"]
|
||||
.as_u64()
|
||||
.ok_or_else(|| format!("terminal status omitted report.skipped_queue_full: {terminal}"))?;
|
||||
|
||||
@@ -33,14 +33,28 @@ workspace = true
|
||||
[features]
|
||||
default = []
|
||||
rio-v2 = ["dep:rustfs-rio-v2"]
|
||||
hotpath = ["dep:hotpath", "hotpath/hotpath", "rustfs-filemeta/hotpath", "rustfs-rio/hotpath"]
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
"hotpath/futures",
|
||||
"hotpath/async-channel",
|
||||
"hotpath/parking_lot",
|
||||
"hotpath/reqwest-0-13",
|
||||
"rustfs-filemeta/hotpath",
|
||||
"rustfs-rio/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-filemeta/hotpath-alloc",
|
||||
"rustfs-rio/hotpath-alloc",
|
||||
]
|
||||
# Exposes shared lifecycle/tier test utilities (MockWarmBackend, fault
|
||||
# injection, xl.meta transition assertions) via `api::tier::test_util`.
|
||||
# Enable only from `[dev-dependencies]` (rustfs/backlog#1148 ilm-6).
|
||||
test-util = []
|
||||
|
||||
[dependencies]
|
||||
hotpath = { workspace = true, optional = true }
|
||||
hotpath.workspace = true
|
||||
rustfs-filemeta.workspace = true
|
||||
rustfs-utils = { workspace = true, features = ["full"] }
|
||||
rustfs-rio.workspace = true
|
||||
@@ -57,7 +71,6 @@ rustfs-policy.workspace = true
|
||||
rustfs-protos.workspace = true
|
||||
rustfs-replication.workspace = true
|
||||
rustfs-lifecycle.workspace = true
|
||||
rustfs-kms.workspace = true
|
||||
rustfs-s3-types = { workspace = true }
|
||||
rustfs-data-usage.workspace = true
|
||||
rustfs-object-capacity.workspace = true
|
||||
@@ -105,6 +118,7 @@ tempfile.workspace = true
|
||||
hyper = { workspace = true, features = ["http2", "http1", "server"] }
|
||||
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"] }
|
||||
hostname.workspace = true
|
||||
rustls = { workspace = true, default-features = false, features = ["aws-lc-rs", "logging", "tls12", "prefer-post-quantum", "std"] }
|
||||
rustls-pki-types.workspace = true
|
||||
tokio = { workspace = true, features = ["io-util", "sync", "signal", "fs", "rt-multi-thread"] }
|
||||
@@ -124,8 +138,6 @@ libc.workspace = true
|
||||
rustix = { workspace = true, features = ["process", "fs"] }
|
||||
rustfs-madmin.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"] }
|
||||
urlencoding = { workspace = true }
|
||||
smallvec = { workspace = true, features = ["serde"] }
|
||||
|
||||
@@ -391,10 +391,12 @@ pub mod notification {
|
||||
|
||||
pub mod object {
|
||||
pub use crate::object_api::{
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, GetObjectBodyCacheHook, GetObjectBodyCacheHookLookup, GetObjectBodySource,
|
||||
GetObjectReader, ObjectInfo, ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, StreamConsumer,
|
||||
get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook, register_get_object_body_cache_hook,
|
||||
register_object_mutation_hook, unregister_get_object_body_cache_hook, unregister_object_mutation_hook,
|
||||
BLOCK_SIZE_V2, ERASURE_ALGORITHM, EncryptionResolutionError, EncryptionResolutionErrorKind, GetObjectBodyCacheHook,
|
||||
GetObjectBodyCacheHookLookup, GetObjectBodySource, GetObjectReader, ObjectEncryptionResolver, ObjectInfo,
|
||||
ObjectMutationHook, ObjectOptions, PutObjReader, RangedDecompressReader, ReadEncryptionMaterial, ReadEncryptionMode,
|
||||
ReadEncryptionRequest, StreamConsumer, get_object_body_cache_plaintext_len, lookup_get_object_body_cache_hook,
|
||||
register_get_object_body_cache_hook, register_object_mutation_hook, unregister_get_object_body_cache_hook,
|
||||
unregister_object_mutation_hook,
|
||||
};
|
||||
pub use crate::store::PreparedGetObjectReader;
|
||||
}
|
||||
|
||||
@@ -46,16 +46,6 @@ lazy_static! {
|
||||
m.insert("x-amz-replication-status".to_string(), true);
|
||||
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 {
|
||||
@@ -70,16 +60,12 @@ pub fn is_standard_header(header_key: &str) -> bool {
|
||||
*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 {
|
||||
let key = header_key.to_lowercase();
|
||||
key.starts_with("x-amz-meta-")
|
||||
|| key.starts_with("x-amz-grant-")
|
||||
|| key == "x-amz-acl"
|
||||
|| is_sse_header(header_key)
|
||||
|| rustfs_utils::http::is_sse_header(header_key)
|
||||
|| key.starts_with("x-amz-checksum-")
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ use std::{
|
||||
collections::HashMap,
|
||||
io::Cursor,
|
||||
sync::{
|
||||
Arc,
|
||||
Arc, Weak,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::SystemTime,
|
||||
@@ -350,6 +350,44 @@ impl PeerRestClient {
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
slots: Vec<(String, Option<String>, bool)>,
|
||||
) -> (Vec<Option<Self>>, Vec<Option<Self>>, Vec<String>) {
|
||||
@@ -363,14 +401,14 @@ impl PeerRestClient {
|
||||
}
|
||||
|
||||
let client = match grid_host {
|
||||
Some(grid_host) => match XHost::try_from(peer_host_port.clone()) {
|
||||
Some(grid_host) => match Self::parse_topology_host(&peer_host_port, &grid_host) {
|
||||
Ok(host) => {
|
||||
let mut client = PeerRestClient::new(host, grid_host);
|
||||
client.topology_member = peer_host_port.clone();
|
||||
Some(client)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(peer = %peer_host_port, "Xhost parse failed while constructing peer client: {err:?}");
|
||||
warn!(peer = %peer_host_port, "peer topology host parse failed while constructing peer client: {err:?}");
|
||||
None
|
||||
}
|
||||
},
|
||||
@@ -519,9 +557,8 @@ impl PeerRestClient {
|
||||
}
|
||||
|
||||
let grid_host = self.grid_host.clone();
|
||||
let offline = Arc::clone(&self.offline);
|
||||
let recovery_running = Arc::clone(&self.recovery_running);
|
||||
let span = Self::recovery_monitor_span(&grid_host);
|
||||
let offline = Arc::downgrade(&self.offline);
|
||||
let recovery_running = Arc::downgrade(&self.recovery_running);
|
||||
// The offline flag and its recovery are the silent half of
|
||||
// rustfs/backlog#888: log the monitor's start and its success so an
|
||||
// "offline then back" episode leaves a trace on the observing node.
|
||||
@@ -530,13 +567,34 @@ impl PeerRestClient {
|
||||
grid_host = %self.grid_host,
|
||||
"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 {
|
||||
let mut delay = get_drive_active_check_interval();
|
||||
let connect_timeout = get_drive_active_check_timeout();
|
||||
|
||||
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;
|
||||
if offline.strong_count() == 0 || recovery_running.strong_count() == 0 {
|
||||
return;
|
||||
}
|
||||
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);
|
||||
recovery_running.store(false, Ordering::Release);
|
||||
info!(
|
||||
@@ -556,8 +614,10 @@ impl PeerRestClient {
|
||||
attempts = PEER_REST_RECOVERY_MAX_ATTEMPTS,
|
||||
"peer recovery monitor reached max attempts; will retry on next request"
|
||||
);
|
||||
recovery_running.store(false, Ordering::Release);
|
||||
});
|
||||
if let Some(recovery_running) = recovery_running.upgrade() {
|
||||
recovery_running.store(false, Ordering::Release);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1807,9 +1867,13 @@ fn tier_config_reload_status_outcome(status: tonic::Status) -> TierConfigReloadO
|
||||
mod tests {
|
||||
use super::*;
|
||||
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 serial_test::serial;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use temp_env::async_with_vars;
|
||||
use tracing_subscriber::{Registry, fmt::MakeWriter, layer::SubscriberExt};
|
||||
|
||||
#[test]
|
||||
@@ -1927,30 +1991,115 @@ mod tests {
|
||||
fn build_clients_from_slots_preserves_missing_remote_topology_slots() {
|
||||
let slots = vec![
|
||||
("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:9003".to_string(), None, false),
|
||||
];
|
||||
|
||||
let (remote, all, remote_topology_hosts) = PeerRestClient::build_clients_from_slots(slots);
|
||||
|
||||
assert_eq!(remote.len(), 3, "local node is excluded but remote slots are not compacted away");
|
||||
assert_eq!(all.len(), 4, "all slots preserve the sorted cluster topology shape");
|
||||
assert_eq!(remote.len(), 4, "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!(
|
||||
remote_topology_hosts,
|
||||
vec![
|
||||
"127.0.0.1:9001".to_string(),
|
||||
"rustfs-1.invalid:9001".to_string(),
|
||||
"rustfs-2.invalid".to_string(),
|
||||
"127.0.0.1:notaport".to_string(),
|
||||
"127.0.0.1:9003".to_string()
|
||||
]
|
||||
);
|
||||
assert!(remote[0].is_some(), "valid remote peer should get a client");
|
||||
assert!(remote[1].is_none(), "unparseable remote peer should remain observable as a missing slot");
|
||||
assert!(remote[2].is_none(), "missing grid host should remain observable as a missing slot");
|
||||
let unresolved = remote[0]
|
||||
.as_ref()
|
||||
.expect("temporarily unresolved remote peer should retain a client");
|
||||
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[1].is_some());
|
||||
assert!(all[2].is_none());
|
||||
assert!(all[2].is_some());
|
||||
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]
|
||||
@@ -2740,6 +2889,31 @@ mod tests {
|
||||
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]
|
||||
async fn peer_rest_client_finalize_result_keeps_online_for_app_errors_mentioning_unavailable() {
|
||||
// Regression: application error text containing "unavailable" (a
|
||||
|
||||
@@ -4814,9 +4814,11 @@ mod tests {
|
||||
async fn test_remote_disk_endpoints_with_different_schemes() {
|
||||
let test_cases = vec![
|
||||
("http://server:9000", "server:9000"),
|
||||
("https://secure-server:443", "secure-server"), // Default HTTPS port is omitted
|
||||
("http://plain-server:80", "plain-server"),
|
||||
("http://plain-server", "plain-server"),
|
||||
("https://secure-server:443", "secure-server"),
|
||||
("http://192.168.1.100:8080", "192.168.1.100:8080"),
|
||||
("https://secure-server", "secure-server"), // No port specified
|
||||
("https://secure-server", "secure-server"),
|
||||
];
|
||||
|
||||
for (url_str, expected_hostname) in test_cases {
|
||||
|
||||
+199
-20
@@ -37,7 +37,9 @@ use crate::{
|
||||
runtime::instance::{InstanceContext, bootstrap_ctx},
|
||||
runtime::sources as runtime_sources,
|
||||
set_disk::{PreparedGetObjectMetadata, SetDisks},
|
||||
store::init_format::{check_format_erasure_values, get_format_erasure_in_quorum, load_format_erasure_all, save_format_file},
|
||||
store::init_format::{
|
||||
check_format_erasure_values, load_format_erasure_all, save_format_file, select_format_erasure_in_quorum,
|
||||
},
|
||||
};
|
||||
use futures::{
|
||||
future::join_all,
|
||||
@@ -947,7 +949,7 @@ impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
||||
|
||||
#[tracing::instrument(skip(self))]
|
||||
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||
let (disks, _) = init_storage_disks_with_errors(
|
||||
let (disks, init_errs) = init_storage_disks_with_errors(
|
||||
&self.endpoints.endpoints,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
@@ -955,15 +957,36 @@ impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let (formats, errs) = load_format_erasure_all(&disks, true).await;
|
||||
let (formats, mut 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) {
|
||||
info!("failed to check formats erasure values: {}", err);
|
||||
return Ok((HealResultItem::default(), Some(err)));
|
||||
}
|
||||
let ref_format = match get_format_erasure_in_quorum(&formats) {
|
||||
Ok(format) => format,
|
||||
let (ref_format, quorum_members) = match select_format_erasure_in_quorum(&formats, 0) {
|
||||
Ok((format, members)) if format.shared_identity() == self.format.shared_identity() => (format, members),
|
||||
Ok(_) => return Ok((HealResultItem::default(), Some(StorageError::CorruptedFormat))),
|
||||
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 {
|
||||
heal_item_type: HealItemType::Metadata.to_string(),
|
||||
detail: "disk-format".to_string(),
|
||||
@@ -985,11 +1008,6 @@ impl crate::storage_api_contracts::heal::HealOperations for Sets {
|
||||
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);
|
||||
if !dry_run {
|
||||
let mut tmp_new_formats = vec![None; self.set_count * self.set_drive_count];
|
||||
@@ -1298,7 +1316,7 @@ mod tests {
|
||||
assert_eq!(result, (Some(3), Some(1), Some(0)));
|
||||
}
|
||||
|
||||
async fn multipart_listing_test_sets() -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
||||
async fn two_set_test_sets() -> (Vec<tempfile::TempDir>, Arc<Sets>) {
|
||||
let format = FormatV3::new(2, 2);
|
||||
let mut temp_dirs = Vec::new();
|
||||
let mut all_endpoints = Vec::new();
|
||||
@@ -1339,8 +1357,8 @@ mod tests {
|
||||
Arc::new(RwLock::new(disks)),
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
set_index,
|
||||
0,
|
||||
endpoints,
|
||||
format.clone(),
|
||||
vec![Arc::new(LocalClient::new()), Arc::new(LocalClient::new())],
|
||||
@@ -1373,11 +1391,114 @@ mod tests {
|
||||
(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")]
|
||||
#[serial]
|
||||
async fn list_multipart_uploads_merges_all_sets_without_pagination_loss() {
|
||||
let _setup_type_guard = SetupTypeGuard::switch_to(SetupType::Erasure).await;
|
||||
let (_temp_dirs, sets) = multipart_listing_test_sets().await;
|
||||
let (_temp_dirs, sets) = two_set_test_sets().await;
|
||||
let bucket = format!("multipart-list-{}", Uuid::new_v4().simple());
|
||||
sets.make_bucket(&bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
@@ -1616,11 +1737,15 @@ mod tests {
|
||||
// formatting the first `num_formatted` of them against a shared reference
|
||||
// format and leaving the rest unformatted. Returns the live TempDir handles
|
||||
// (must be kept alive), the reference format, and the assembled `Sets`.
|
||||
// `disk_set` is intentionally empty: these tests only drive `heal_format`
|
||||
// with `dry_run == true`, which never touches `disk_set`.
|
||||
async fn setup_heal_format_sets(num_formatted: usize) -> (Vec<tempfile::TempDir>, FormatV3, Sets) {
|
||||
// `disk_set` is intentionally empty: these tests only exercise paths that
|
||||
// return before pool-level healing delegates into a set.
|
||||
async fn setup_heal_format_sets(num_formatted: usize, foreign_identity: bool) -> (Vec<tempfile::TempDir>, FormatV3, Sets) {
|
||||
const SET_DRIVE_COUNT: usize = 3;
|
||||
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 endpoints = Vec::with_capacity(SET_DRIVE_COUNT);
|
||||
@@ -1645,8 +1770,8 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("disk should be created");
|
||||
let mut disk_format = ref_format.clone();
|
||||
disk_format.erasure.this = ref_format.erasure.sets[0][i];
|
||||
let mut disk_format = stored_format.clone();
|
||||
disk_format.erasure.this = stored_format.erasure.sets[0][i];
|
||||
save_format_file(&Some(disk), &Some(disk_format))
|
||||
.await
|
||||
.expect("format should be saved");
|
||||
@@ -1677,6 +1802,60 @@ mod tests {
|
||||
(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
|
||||
// formatted, `heal_format` reports exactly one drive record per disk
|
||||
// (N = set_count * set_drive_count), each carrying a real endpoint. Before
|
||||
@@ -1685,7 +1864,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn heal_format_no_heal_required_reports_one_record_per_disk() {
|
||||
let (_dirs, _ref_format, sets) = setup_heal_format_sets(3).await;
|
||||
let (_dirs, _ref_format, sets) = setup_heal_format_sets(3, false).await;
|
||||
|
||||
let (res, err) = sets.heal_format(true).await.expect("heal_format should succeed");
|
||||
// All disks formatted -> NoHealRequired early return, still returns `res`.
|
||||
@@ -1715,7 +1894,7 @@ mod tests {
|
||||
#[serial]
|
||||
async fn heal_format_heal_path_reports_one_record_per_disk_aligned() {
|
||||
// Disks 0 and 1 formatted (quorum), disk 2 unformatted.
|
||||
let (_dirs, _ref_format, sets) = setup_heal_format_sets(2).await;
|
||||
let (_dirs, _ref_format, sets) = setup_heal_format_sets(2, false).await;
|
||||
|
||||
let (res, err) = sets.heal_format(true).await.expect("heal_format should succeed");
|
||||
// Unformatted disk present -> heal path, not NoHealRequired.
|
||||
|
||||
@@ -1049,6 +1049,10 @@ impl LocalDiskWrapper {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn set_disk_id_state(&self, id: Option<Uuid>) {
|
||||
*self.disk_id.write().await = id;
|
||||
}
|
||||
|
||||
/// Get the current disk ID
|
||||
pub async fn get_current_disk_id(&self) -> Option<Uuid> {
|
||||
*self.disk_id.read().await
|
||||
|
||||
@@ -5078,7 +5078,7 @@ impl LocalDisk {
|
||||
Ok((buf, mtime))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
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())?;
|
||||
|
||||
@@ -5121,7 +5121,7 @@ impl LocalDisk {
|
||||
Ok((data, modtime))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
async fn read_all_data(&self, volume: &str, volume_dir: impl AsRef<Path>, file_path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||
// TODO: timeout support
|
||||
let (data, _) = self.read_all_data_with_dmtime(volume, volume_dir, file_path).await?;
|
||||
|
||||
@@ -132,6 +132,18 @@ pub enum Disk {
|
||||
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]
|
||||
impl DiskAPI for Disk {
|
||||
fn to_string(&self) -> String {
|
||||
@@ -1552,6 +1564,72 @@ mod tests {
|
||||
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]
|
||||
async fn reset_health_for_store_init_retry_delegates_to_disk_variants() {
|
||||
let local_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -103,7 +103,7 @@ where
|
||||
/// 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
|
||||
/// happens only after verification.
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub async fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
|
||||
let want = out.len();
|
||||
self.begin_read(want)?;
|
||||
@@ -303,7 +303,7 @@ where
|
||||
|
||||
/// 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.
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(label = "BitrotWriter::write"))]
|
||||
#[hotpath::measure(label = "BitrotWriter::write")]
|
||||
pub async fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
if buf.is_empty() {
|
||||
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
|
||||
/// on the next line would reject a genuinely healthy part. Reading legacy V1
|
||||
/// whole-file-bitrot objects would need a separate verification path.
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub async fn bitrot_verify<R: AsyncRead + Unpin + Send>(
|
||||
mut r: R,
|
||||
want_size: usize,
|
||||
|
||||
@@ -691,7 +691,7 @@ impl<R> ParallelReader<R>
|
||||
where
|
||||
R: crate::erasure::coding::ShardSource,
|
||||
{
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub async fn read(&mut self) -> (Vec<Option<Vec<u8>>>, Vec<Option<Error>>) {
|
||||
// On the reconstruction-verifying GET path, read every live shard reader
|
||||
// in lockstep so all readers advance one block per stripe and stay
|
||||
@@ -1505,7 +1505,7 @@ where
|
||||
}
|
||||
|
||||
impl Erasure {
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub async fn decode<W, R>(
|
||||
&self,
|
||||
writer: &mut W,
|
||||
|
||||
@@ -504,7 +504,7 @@ impl Erasure {
|
||||
Ok((reader, total))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub async fn encode<R>(
|
||||
self: Arc<Self>,
|
||||
reader: R,
|
||||
@@ -670,7 +670,7 @@ impl Erasure {
|
||||
Ok((reader, total))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub async fn encode_batched<R>(
|
||||
self: Arc<Self>,
|
||||
mut reader: R,
|
||||
@@ -798,7 +798,7 @@ impl Erasure {
|
||||
|
||||
/// Fast path for small inline objects: skip tokio::spawn + mpsc channel.
|
||||
/// Reads all data, encodes directly, writes shards sequentially.
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub async fn encode_inline_small<R>(
|
||||
self: Arc<Self>,
|
||||
reader: R,
|
||||
@@ -813,7 +813,7 @@ impl Erasure {
|
||||
|
||||
/// Fast path for single-block non-inline objects: avoids the producer/consumer
|
||||
/// pipeline in `encode()` while keeping the same writer/quorum/shutdown semantics.
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub async fn encode_single_block_non_inline<R>(
|
||||
self: Arc<Self>,
|
||||
reader: R,
|
||||
|
||||
@@ -640,7 +640,7 @@ impl Erasure {
|
||||
/// # Returns
|
||||
/// A vector of encoded shards as `Bytes`.
|
||||
#[tracing::instrument(level = "debug", skip_all, fields(data_len=data.len()))]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub fn encode_data(&self, data: &[u8]) -> io::Result<Vec<Bytes>> {
|
||||
let shard_size_fn = if self.uses_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.
|
||||
/// Falls back to copying into a new buffer if zero-copy conversion fails.
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub fn encode_data_owned(&self, data: Vec<u8>) -> io::Result<Vec<Bytes>> {
|
||||
let shard_size_fn = if self.uses_legacy {
|
||||
calc_shard_size_legacy
|
||||
@@ -752,7 +752,7 @@ impl Erasure {
|
||||
/// 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` — so this function never reallocates the buffer.
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
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 {
|
||||
calc_shard_size_legacy
|
||||
@@ -805,7 +805,7 @@ impl Erasure {
|
||||
///
|
||||
/// # Returns
|
||||
/// Ok if reconstruction succeeds, error otherwise.
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub fn decode_data(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
||||
if self.parity_shards > 0 {
|
||||
if self.uses_legacy {
|
||||
@@ -825,7 +825,7 @@ impl Erasure {
|
||||
}
|
||||
|
||||
/// Decode and reconstruct missing data shards, then regenerate parity shards.
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub fn decode_data_and_parity(&self, shards: &mut [Option<Vec<u8>>]) -> io::Result<()> {
|
||||
if self.parity_shards > 0 {
|
||||
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 arg in args {
|
||||
if unique_args.contains(arg) {
|
||||
return Err(Error::other(format!("Input args {arg} has duplicate ellipses")));
|
||||
return Err(Error::other("input arguments contain a duplicate endpoint after ellipsis expansion"));
|
||||
}
|
||||
unique_args.insert(arg);
|
||||
}
|
||||
@@ -924,4 +924,15 @@ 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,6 +88,7 @@ impl TryFrom<&str> for Endpoint {
|
||||
// - All field should be empty except Host and Path.
|
||||
if !((url.scheme() == "http" || url.scheme() == "https")
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& url.fragment().is_none()
|
||||
&& url.query().is_none())
|
||||
{
|
||||
@@ -366,6 +367,12 @@ mod test {
|
||||
expected_type: None,
|
||||
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 {
|
||||
arg: "http://:/path",
|
||||
expected_endpoint: None,
|
||||
@@ -505,8 +512,18 @@ mod test {
|
||||
let endpoint = Endpoint::try_from("http://example.com:9000/path").unwrap();
|
||||
assert_eq!(endpoint.host_port(), "example.com:9000");
|
||||
|
||||
let endpoint_no_port = Endpoint::try_from("https://example.com/path").unwrap();
|
||||
assert_eq!(endpoint_no_port.host_port(), "example.com");
|
||||
for endpoint in [
|
||||
Endpoint::try_from("http://example.com/path").unwrap(),
|
||||
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();
|
||||
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 uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum FormatMetaVersion {
|
||||
#[serde(rename = "1")]
|
||||
V1,
|
||||
@@ -27,7 +27,7 @@ pub enum FormatMetaVersion {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum FormatBackend {
|
||||
#[serde(rename = "xl")]
|
||||
Erasure,
|
||||
@@ -64,7 +64,7 @@ pub struct FormatErasureV3 {
|
||||
pub distribution_algo: DistributionAlgoVersion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum FormatErasureVersion {
|
||||
#[serde(rename = "1")]
|
||||
V1,
|
||||
@@ -77,7 +77,7 @@ pub enum FormatErasureVersion {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum DistributionAlgoVersion {
|
||||
#[serde(rename = "CRCMOD")]
|
||||
V1,
|
||||
@@ -121,6 +121,15 @@ pub struct FormatV3 {
|
||||
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 {
|
||||
type Error = JsonError;
|
||||
|
||||
@@ -198,52 +207,24 @@ impl FormatV3 {
|
||||
}
|
||||
|
||||
pub fn check_other(&self, other: &FormatV3) -> Result<()> {
|
||||
let mut tmp = other.clone();
|
||||
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()
|
||||
)));
|
||||
if self.shared_identity() != other.shared_identity() {
|
||||
return Err(Error::other("storage formats do not match"));
|
||||
}
|
||||
|
||||
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()
|
||||
)));
|
||||
}
|
||||
self.find_disk_index_by_disk_id(other.erasure.this).map(|_| ())
|
||||
}
|
||||
|
||||
for j in 0..self.erasure.sets[i].len() {
|
||||
if self.erasure.sets[i][j] != other.erasure.sets[i][j] {
|
||||
return Err(Error::other(format!(
|
||||
"UUID on positions {}:{} do not match with, expected {:?} got {:?}: (%w)",
|
||||
i,
|
||||
j,
|
||||
self.erasure.sets[i][j].to_string(),
|
||||
other.erasure.sets[i][j].to_string(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)))
|
||||
/// Fields that must agree across every disk in one erasure format,
|
||||
/// excluding the disk-specific `this` UUID and runtime-only `disk_info`.
|
||||
pub(crate) fn shared_identity(&self) -> SharedFormatIdentity<'_> {
|
||||
(
|
||||
&self.version,
|
||||
&self.format,
|
||||
&self.id,
|
||||
&self.erasure.version,
|
||||
&self.erasure.sets,
|
||||
&self.erasure.distribution_algo,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +418,30 @@ mod test {
|
||||
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]
|
||||
fn test_check_other_different_set_count() {
|
||||
let format1 = FormatV3::new(2, 4);
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
/// Scope-based hotpath measurement for `#[async_trait]` methods, where
|
||||
/// `#[cfg_attr(feature = "hotpath", hotpath::measure)]` would only time the boxed-future construction.
|
||||
/// `#[hotpath::measure]` would only time the boxed-future construction.
|
||||
/// The guard records wall time from this statement until the enclosing
|
||||
/// (desugared) async block completes, including early returns via `?`.
|
||||
#[cfg(feature = "hotpath")]
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// 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,6 +84,7 @@ pub(crate) fn legacy_encrypted_range_seek_enabled() -> bool {
|
||||
}
|
||||
|
||||
mod body_cache_hook;
|
||||
mod encryption;
|
||||
mod hook_slot;
|
||||
mod object_mutation_hook;
|
||||
mod readers;
|
||||
@@ -98,6 +99,10 @@ pub 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,
|
||||
};
|
||||
pub use encryption::{
|
||||
EncryptionResolutionError, EncryptionResolutionErrorKind, ObjectEncryptionResolver, ReadEncryptionMaterial,
|
||||
ReadEncryptionMode, ReadEncryptionRequest,
|
||||
};
|
||||
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 readers::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -273,29 +273,9 @@ impl ObjectInfo {
|
||||
}
|
||||
|
||||
pub fn is_encrypted(&self) -> bool {
|
||||
// Corresponding to the logic in rustfs/src/sse.rs/encryption_material_to_metadata function
|
||||
use rustfs_utils::http::{SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER};
|
||||
|
||||
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"
|
||||
)
|
||||
})
|
||||
self.user_defined
|
||||
.keys()
|
||||
.any(|key| rustfs_utils::http::is_object_encryption_marker(key))
|
||||
}
|
||||
|
||||
/// Maximum inline size for non-versioned objects (128 KiB).
|
||||
@@ -339,26 +319,7 @@ impl ObjectInfo {
|
||||
}
|
||||
|
||||
pub fn encryption_original_size(&self) -> std::io::Result<Option<i64>> {
|
||||
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)
|
||||
rustfs_utils::http::get_object_encryption_original_size(&self.user_defined)
|
||||
}
|
||||
|
||||
pub fn decrypted_size(&self) -> std::io::Result<i64> {
|
||||
@@ -388,9 +349,6 @@ impl ObjectInfo {
|
||||
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()? {
|
||||
return Ok(size);
|
||||
}
|
||||
@@ -881,6 +839,19 @@ mod tests {
|
||||
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]
|
||||
fn versions_after_marker_handles_null_version_marker() {
|
||||
let first_version = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
|
||||
|
||||
@@ -46,6 +46,7 @@ use crate::bucket::metadata_sys::BucketMetadataSys;
|
||||
use crate::bucket::replication::{DynReplicationPool, ReplicationStats};
|
||||
use crate::disk::DiskStore;
|
||||
use crate::layout::endpoints::{EndpointServerPools, SetupType};
|
||||
use crate::object_api::ObjectEncryptionResolver;
|
||||
use crate::services::event_notification::EventNotifier;
|
||||
use crate::services::tier::tier::TierConfigMgr;
|
||||
use rustfs_lock::{GlobalLockManager, get_global_lock_manager};
|
||||
@@ -159,6 +160,8 @@ pub struct InstanceContext {
|
||||
/// workers (scanner/heal/tier/lifecycle) without touching another instance.
|
||||
/// Replaces the process-global cancel-token static.
|
||||
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>>,
|
||||
transition_transaction_recovery_stores: std::sync::Mutex<HashSet<Uuid>>,
|
||||
#[cfg(test)]
|
||||
@@ -197,6 +200,7 @@ impl InstanceContext {
|
||||
local_disk_set_drives: Arc::new(RwLock::new(Vec::new())),
|
||||
bucket_metadata_sys: std::sync::Mutex::new(None),
|
||||
background_cancel_token: OnceLock::new(),
|
||||
object_encryption_resolver: OnceLock::new(),
|
||||
tier_delete_journal_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||
transition_transaction_recovery_stores: std::sync::Mutex::new(HashSet::new()),
|
||||
#[cfg(test)]
|
||||
@@ -209,6 +213,19 @@ impl InstanceContext {
|
||||
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.
|
||||
///
|
||||
/// Write-once: panics on a second write, preserving the startup fail-fast
|
||||
|
||||
@@ -27,7 +27,7 @@ use crate::{
|
||||
bucket::replication::{DynReplicationPool, ReplicationStats},
|
||||
config::{get_global_storage_class, get_global_storage_class_snapshot, set_global_storage_class, storageclass},
|
||||
disk::{DiskAPI, DiskOption, DiskStore, new_disk},
|
||||
error::Result,
|
||||
error::{Error, Result},
|
||||
layout::endpoints::{EndpointServerPools, SetupType},
|
||||
runtime::global::{
|
||||
GLOBAL_BOOT_TIME, GLOBAL_LIFECYCLE_SYS, GLOBAL_LOCAL_NODE_NAME_FALLBACK, GLOBAL_ROOT_DISK_THRESHOLD,
|
||||
@@ -46,7 +46,6 @@ use crate::{
|
||||
use rustfs_concurrency::WorkloadAdmissionSnapshotProvider;
|
||||
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_kms::{ObjectEncryptionService, get_global_encryption_service};
|
||||
use rustfs_lock::client::LockClient;
|
||||
use s3s::dto::BucketLifecycleConfiguration;
|
||||
use s3s::region::Region;
|
||||
@@ -105,10 +104,6 @@ pub(crate) fn record_erasure_write_quorum_failure(stage: &'static str, dominant_
|
||||
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>> {
|
||||
resolve_object_store_handle()
|
||||
}
|
||||
@@ -417,14 +412,14 @@ pub(crate) async fn clear_local_disk_id_map_for_test() {
|
||||
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) {
|
||||
let id_map = local_disk_id_map_handle();
|
||||
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);
|
||||
}
|
||||
if let Some(current_id) = current {
|
||||
@@ -432,6 +427,53 @@ 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>) {
|
||||
let map = instance_ctx.local_disk_map();
|
||||
let mut global_local_disk_map = map.write().await;
|
||||
@@ -558,10 +600,14 @@ pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LockRegistry, local_node_name, set_local_node_name};
|
||||
use super::{
|
||||
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 rustfs_lock::{LocalClient, LockClient};
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn url_endpoint(raw: &str) -> Endpoint {
|
||||
Endpoint {
|
||||
@@ -607,4 +653,78 @@ mod tests {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2044,15 +2044,10 @@ fn synthesized_disks(host: &str, endpoints: &EndpointServerPools, state: ItemSta
|
||||
/// Whether `peer_host` refers to the same node as an endpoint whose
|
||||
/// `host_port()` is `ep_host_port`.
|
||||
///
|
||||
/// `PeerRestClient::host` is an `XHost`, which resolves names to an address on
|
||||
/// construction (`hosts_sorted` -> `XHost::try_from` -> `to_socket_addrs`), so
|
||||
/// `peer_host` is the resolved `IP:port`. An endpoint's `host_port()`, however,
|
||||
/// 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.
|
||||
/// Current topology clients preserve the endpoint `hostname:port`, so the
|
||||
/// direct comparison is the normal path. The resolution fallback keeps
|
||||
/// compatibility with older or manually constructed clients whose `XHost`
|
||||
/// contains a resolved `IP:port` (rustfs/rustfs#4607 follow-up).
|
||||
fn endpoint_host_matches(peer_host: &str, ep_host_port: &str) -> bool {
|
||||
if peer_host == ep_host_port {
|
||||
return true;
|
||||
|
||||
@@ -72,6 +72,7 @@ use crate::{
|
||||
cluster::rpc::peer_rest_client::{PeerRestClient, PeerTierMutationState},
|
||||
config::com::{CONFIG_PREFIX, read_config, read_config_with_metadata},
|
||||
disk::{MIGRATING_META_BUCKET, RUSTFS_META_BUCKET},
|
||||
layout::endpoints::EndpointServerPools,
|
||||
object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader},
|
||||
runtime::sources as runtime_sources,
|
||||
set_disk::get_lock_acquire_timeout,
|
||||
@@ -904,14 +905,17 @@ async fn remote_tier_mutation_peers() -> io::Result<Vec<Arc<dyn TierMutationPeer
|
||||
let Some(endpoints) = runtime_sources::endpoint_pools() else {
|
||||
return Err(tier_mutation_replay_error("cluster endpoint topology is not initialized"));
|
||||
};
|
||||
let remote_host_count = endpoints.hosts_sorted().iter().flatten().count();
|
||||
let (peers, _) = PeerRestClient::new_clients(endpoints).await;
|
||||
remote_tier_mutation_peers_from_topology(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
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|peer| Arc::new(peer) as Arc<dyn TierMutationPeer>)
|
||||
.collect::<Vec<_>>();
|
||||
ensure_complete_tier_mutation_commit_peer_set(peers.len(), remote_host_count)?;
|
||||
ensure_complete_tier_mutation_commit_peer_set(peers.len(), remote_topology_hosts.len())?;
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
@@ -4307,6 +4311,34 @@ fn tier_config_not_initialized_error(operation: &str) -> std::io::Error {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 {
|
||||
TierConfig {
|
||||
@@ -6354,6 +6386,42 @@ mod tests {
|
||||
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]
|
||||
async fn coordinator_fanout_prepare_failure_aborts_prepared_peers_without_cas() {
|
||||
let manager = TierConfigMgr::new();
|
||||
|
||||
@@ -505,9 +505,7 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
fn file_info_has_encryption_metadata(meta: &FileInfo) -> bool {
|
||||
meta.metadata
|
||||
.keys()
|
||||
.any(|name| http::is_encryption_metadata_key(name) || http::is_sse_header(name))
|
||||
meta.metadata.keys().any(|name| http::is_object_encryption_marker(name))
|
||||
}
|
||||
|
||||
fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool {
|
||||
|
||||
@@ -110,7 +110,10 @@ use crate::{
|
||||
object_api::{GetObjectReader, ObjectInfo, PutObjReader},
|
||||
// event::name::EventName,
|
||||
services::event_notification::{EventArgs, send_event},
|
||||
store::init_format::{get_format_erasure_in_quorum, load_format_erasure, load_format_erasure_all, save_format_file},
|
||||
store::init_format::{
|
||||
formats_match_reference_slots, get_format_erasure_in_quorum, load_format_erasure, load_format_erasure_all,
|
||||
save_format_file,
|
||||
},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use bytesize::ByteSize;
|
||||
@@ -144,15 +147,17 @@ use rustfs_object_capacity::capacity_scope::{
|
||||
CapacityScope, CapacityScopeDisk, current_dirty_generation, record_capacity_scope, record_global_dirty_scope,
|
||||
};
|
||||
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_STORAGE_CLASS;
|
||||
use rustfs_utils::http::headers::{
|
||||
CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_ENCODING, CONTENT_LANGUAGE, CONTENT_TYPE, EXPIRES, HeaderExt as _,
|
||||
};
|
||||
use rustfs_utils::http::{
|
||||
SSEC_ALGORITHM_HEADER, SSEC_KEY_HEADER, SSEC_KEY_MD5_HEADER, SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE,
|
||||
SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC, SUFFIX_RESTORE_OPERATION_ID, contains_key_str,
|
||||
get_header_map, get_str, insert_str, is_encryption_metadata_key, remove_header_map,
|
||||
SUFFIX_ACTUAL_OBJECT_SIZE_CAP, SUFFIX_ACTUAL_SIZE, SUFFIX_COMPRESSION, SUFFIX_COMPRESSION_SIZE, SUFFIX_REPLICATION_SSEC_CRC,
|
||||
SUFFIX_RESTORE_OPERATION_ID, contains_key_str, get_header_map, get_str, insert_str, is_object_encryption_marker,
|
||||
remove_header_map,
|
||||
};
|
||||
use rustfs_utils::{
|
||||
HashAlgorithm,
|
||||
@@ -667,10 +672,7 @@ pub(crate) fn strip_internal_multipart_metadata(metadata: &mut HashMap<String, S
|
||||
}
|
||||
|
||||
fn should_persist_encryption_original_size(metadata: &HashMap<String, String>) -> bool {
|
||||
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)
|
||||
metadata.keys().any(|key| is_object_encryption_marker(key))
|
||||
}
|
||||
|
||||
/// Per-set memoized capacity dirty scope.
|
||||
@@ -5849,23 +5851,27 @@ mod tests {
|
||||
crate::disk::DataDirDeleteStatus::Deleted
|
||||
);
|
||||
release_slow_candidate.notify_one();
|
||||
for _ in 0..10 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
assert_eq!(
|
||||
disk2
|
||||
.delete_data_dir(
|
||||
bucket,
|
||||
data_dir,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a token acquired after the deadline must be released"),
|
||||
crate::disk::DataDirDeleteStatus::Deleted
|
||||
);
|
||||
timeout(Duration::from_secs(1), async {
|
||||
loop {
|
||||
match disk2
|
||||
.delete_data_dir(
|
||||
bucket,
|
||||
data_dir,
|
||||
DeleteOptions {
|
||||
recursive: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a token acquired after the deadline must be released")
|
||||
{
|
||||
crate::disk::DataDirDeleteStatus::Deleted => return,
|
||||
crate::disk::DataDirDeleteStatus::Deferred => tokio::time::sleep(Duration::from_millis(1)).await,
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("late snapshot lease cleanup must finish within the bounded wait");
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
|
||||
@@ -1373,11 +1373,24 @@ impl crate::storage_api_contracts::heal::HealOperations for SetDisks {
|
||||
async fn heal_format(&self, dry_run: bool) -> Result<(HealResultItem, Option<Error>)> {
|
||||
let disks = self.disks.read().await.clone();
|
||||
let (formats, errs) = load_format_erasure_all(&disks, true).await;
|
||||
let ref_format = match get_format_erasure_in_quorum(&formats) {
|
||||
Ok(format) => format,
|
||||
if errs.iter().any(|err| {
|
||||
matches!(
|
||||
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) => {
|
||||
let can_use_cached_layout = count_errs(&errs, &DiskError::UnformattedDisk) > 0
|
||||
&& formats.iter().flatten().all(|format| self.format.check_other(format).is_ok())
|
||||
&& formats_match_reference_slots(&formats, &self.format, slot_offset)
|
||||
&& errs
|
||||
.iter()
|
||||
.all(|err| err.is_none() || matches!(err, Some(DiskError::UnformattedDisk)));
|
||||
@@ -1388,6 +1401,9 @@ 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 before_drives = crate::layout::set_heal::formats_to_drives_info(&endpoints, &formats, &errs);
|
||||
@@ -1543,11 +1559,16 @@ mod heal_result_report_tests {
|
||||
use crate::disk::error::DiskError;
|
||||
use crate::disk::format::FormatV3;
|
||||
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::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::heal::HealOperations as _;
|
||||
use crate::storage_api_contracts::object::{ObjectIO as _, ObjectOperations as _};
|
||||
use crate::{config::storageclass, store::init_format::save_format_file};
|
||||
use crate::{
|
||||
config::storageclass,
|
||||
store::init_format::{load_format_erasure, save_format_file},
|
||||
};
|
||||
use rustfs_common::heal_channel::{DriveState, HealOpts, HealScanMode};
|
||||
use rustfs_filemeta::{BLOCK_SIZE_V2, FileInfo, ObjectPartInfo, TRANSITION_COMPLETE};
|
||||
use std::sync::Arc;
|
||||
@@ -1863,6 +1884,44 @@ 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
|
||||
// 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
|
||||
|
||||
@@ -343,20 +343,23 @@ impl SetDisks {
|
||||
}
|
||||
};
|
||||
|
||||
// The drive's format may place it in a different erasure set than this
|
||||
// one. Claiming a misplaced drive into `self.disks` would let two sets
|
||||
// manage the same drive and degrade together, so reject it here
|
||||
// (backlog#799 B19).
|
||||
if set_idx != self.set_index {
|
||||
// Claiming a misplaced drive into `self.disks` would let two slots or
|
||||
// sets manage the same drive and degrade together (backlog#799 B19).
|
||||
if set_idx != self.set_index || self.set_endpoints.get(disk_idx) != Some(ep) {
|
||||
warn!(
|
||||
"renew_disk: drive {:?} belongs to set {} but is being renewed on set {}; skipping",
|
||||
ep, set_idx, self.set_index
|
||||
endpoint = %ep,
|
||||
format_set_index = set_idx,
|
||||
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;
|
||||
}
|
||||
|
||||
// Check that the endpoint matches
|
||||
|
||||
let _ = new_disk.set_disk_id(Some(fm.erasure.this)).await;
|
||||
new_disk.enable_health_check();
|
||||
|
||||
@@ -715,6 +718,108 @@ mod tests {
|
||||
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
|
||||
// the List operation family must run identically through it.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -42,6 +42,7 @@ use crate::object_api::{GetObjectBodySource, get_object_body_cache_hook_suppress
|
||||
use crate::services::tier::tier::{TierConfigMgr, TierOperationLease};
|
||||
use crate::store::ECStore;
|
||||
use futures::FutureExt as _;
|
||||
use http::HeaderValue;
|
||||
use std::future::Future;
|
||||
|
||||
fn erasure_from_file_info(fi: &FileInfo, uses_legacy: bool) -> Result<coding::Erasure> {
|
||||
@@ -49,6 +50,17 @@ fn erasure_from_file_info(fi: &FileInfo, uses_legacy: bool) -> Result<coding::Er
|
||||
.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
|
||||
/// is exactly the object's complete plaintext, so the app-layer body cache may
|
||||
/// serve it in place of the erasure read.
|
||||
@@ -713,7 +725,8 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
size_bucket,
|
||||
);
|
||||
record_get_object_reader_path_observation(GET_OBJECT_PATH_CODEC_STREAMING, object_class, size_bucket);
|
||||
let (mut reader, _offset, _length) = GetObjectReader::new(stream, range, &object_info, opts, &h).await?;
|
||||
let (mut reader, _offset, _length) =
|
||||
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
|
||||
// now-redundant lookup on the streaming miss path (ODC-16).
|
||||
reader.body_source = body_source;
|
||||
@@ -745,7 +758,8 @@ impl crate::storage_api_contracts::object::ObjectIO for SetDisks {
|
||||
let (rd, wd) = tokio::io::duplex(duplex_buffer_size);
|
||||
debug!(bucket, object, duplex_buffer_size, "Created duplex pipe for object data transfer");
|
||||
|
||||
let (mut reader, offset, length) = GetObjectReader::new(Box::new(rd), range, &object_info, opts, &h).await?;
|
||||
let (mut reader, offset, length) =
|
||||
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
|
||||
// lookup on the streaming miss path (ODC-16).
|
||||
reader.body_source = body_source;
|
||||
@@ -4536,6 +4550,61 @@ 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)]
|
||||
pub(in crate::set_disk::ops) mod hermetic_set_disks_support {
|
||||
//! Shared hermetic `SetDisks` construction for the ops tests below: the
|
||||
|
||||
@@ -199,7 +199,7 @@ impl SetDisks {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub async fn read_version_optimized(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -238,7 +238,7 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip(self))]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub(super) async fn get_object_fileinfo(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -410,7 +410,7 @@ impl SetDisks {
|
||||
Ok((fi, parts_metadata, op_online_disks))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub(super) async fn get_object_info_and_quorum(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -605,7 +605,7 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub(super) async fn get_object_with_fileinfo<W>(
|
||||
// &self,
|
||||
bucket: &str,
|
||||
@@ -1140,7 +1140,7 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub(super) async fn get_object_decode_reader_with_fileinfo(
|
||||
bucket: &str,
|
||||
object: &str,
|
||||
@@ -1296,7 +1296,7 @@ impl SetDisks {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
async fn build_codec_streaming_part_reader(
|
||||
bucket: &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
|
||||
/// stage-duration semantics.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
async fn setup_multipart_part_readers(
|
||||
files: &[FileInfo],
|
||||
disks: &[Option<DiskStore>],
|
||||
|
||||
@@ -310,12 +310,13 @@ impl ECStore {
|
||||
meta.set_created(opts.created_at);
|
||||
|
||||
if opts.lock_enabled {
|
||||
meta.object_lock_config_xml = crate::bucket::utils::serialize::<ObjectLockConfiguration>(&enableObjcetLockConfig)?;
|
||||
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&enableVersioningConfig)?;
|
||||
meta.object_lock_config_xml =
|
||||
crate::bucket::utils::serialize::<ObjectLockConfiguration>(&ENABLED_OBJECT_LOCK_CONFIG)?;
|
||||
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
|
||||
}
|
||||
|
||||
if opts.versioning_enabled {
|
||||
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&enableVersioningConfig)?;
|
||||
meta.versioning_config_xml = crate::bucket::utils::serialize::<VersioningConfiguration>(&ENABLED_VERSIONING_CONFIG)?;
|
||||
}
|
||||
|
||||
await_bucket_namespace_operation(
|
||||
|
||||
@@ -30,6 +30,7 @@ impl ECStore {
|
||||
};
|
||||
|
||||
let mut count_no_heal = 0;
|
||||
let mut first_error = None;
|
||||
for pool in self.pools.iter() {
|
||||
let (mut result, err) = pool.heal_format(dry_run).await?;
|
||||
if let Some(err) = err {
|
||||
@@ -37,8 +38,8 @@ impl ECStore {
|
||||
StorageError::NoHealRequired => {
|
||||
count_no_heal += 1;
|
||||
}
|
||||
_ => {
|
||||
continue;
|
||||
err => {
|
||||
first_error.get_or_insert(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +48,9 @@ impl ECStore {
|
||||
r.before.drives.append(&mut result.before.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() {
|
||||
info!(
|
||||
event = EVENT_HEAL_FORMAT_COMPLETED,
|
||||
@@ -165,3 +169,134 @@ impl ECStore {
|
||||
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,6 +101,10 @@ fn should_retry_local_decommission_resume(err: &Error, attempt: usize) -> bool {
|
||||
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 {
|
||||
rebalance_meta_loaded && !decommission_running
|
||||
}
|
||||
@@ -294,7 +298,7 @@ impl ECStore {
|
||||
// periodic monitoring until format loading succeeds. Startup RPC
|
||||
// failures can still spawn recovery probes for peers that come up
|
||||
// after this node.
|
||||
let (disks, errs) = init_format::init_disks(
|
||||
let (mut disks, errs) = init_format::init_disks(
|
||||
&pool_eps.endpoints,
|
||||
&DiskOption {
|
||||
cleanup: true,
|
||||
@@ -309,9 +313,10 @@ impl ECStore {
|
||||
let mut times = 0;
|
||||
let mut interval = 1;
|
||||
loop {
|
||||
match init_format::connect_load_init_formats(
|
||||
match init_format::connect_load_init_formats_with_instance_ctx(
|
||||
&instance_ctx,
|
||||
pool_first_is_local,
|
||||
&disks,
|
||||
&mut disks,
|
||||
pool_eps.set_count,
|
||||
pool_eps.drives_per_set,
|
||||
deployment_id,
|
||||
@@ -319,6 +324,7 @@ impl ECStore {
|
||||
.await
|
||||
{
|
||||
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
|
||||
Err(e) if times >= 10 => {
|
||||
break Err(Error::other(format!("store init failed to load formats after {times} retries: {e}")));
|
||||
@@ -551,7 +557,7 @@ mod tests {
|
||||
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,
|
||||
resolve_store_init_stage_result, save_validated_pool_meta_for_startup, should_auto_start_rebalance_after_init,
|
||||
should_retry_local_decommission_resume, wait_for_local_decommission_resume_delay,
|
||||
should_retry_format_load, should_retry_local_decommission_resume, wait_for_local_decommission_resume_delay,
|
||||
};
|
||||
#[cfg(feature = "test-util")]
|
||||
use crate::{
|
||||
@@ -773,6 +779,13 @@ mod tests {
|
||||
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]
|
||||
fn test_should_auto_start_rebalance_after_init_allows_loaded_rebalance_without_decommission() {
|
||||
assert!(should_auto_start_rebalance_after_init(false, true));
|
||||
@@ -1247,6 +1260,16 @@ mod tests {
|
||||
|
||||
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");
|
||||
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();
|
||||
assert_ne!(
|
||||
bootstrap.deployment_id(),
|
||||
@@ -1261,6 +1284,15 @@ mod tests {
|
||||
"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]
|
||||
|
||||
+1254
-148
File diff suppressed because it is too large
Load Diff
@@ -428,11 +428,11 @@ impl ECStore {
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref enableObjcetLockConfig: ObjectLockConfiguration = ObjectLockConfiguration {
|
||||
static ref ENABLED_OBJECT_LOCK_CONFIG: ObjectLockConfiguration = ObjectLockConfiguration {
|
||||
object_lock_enabled: Some(ObjectLockEnabled::from_static(ObjectLockEnabled::ENABLED)),
|
||||
..Default::default()
|
||||
};
|
||||
static ref enableVersioningConfig: VersioningConfiguration = VersioningConfiguration {
|
||||
static ref ENABLED_VERSIONING_CONFIG: VersioningConfiguration = VersioningConfiguration {
|
||||
status: Some(BucketVersioningStatus::from_static(BucketVersioningStatus::ENABLED)),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -989,7 +989,7 @@ mod tests {
|
||||
|
||||
init_local_disks(endpoint_pools.clone()).await.expect("init local disks");
|
||||
|
||||
let (disks, errs) = init_disks(
|
||||
let (mut disks, errs) = init_disks(
|
||||
&endpoint_pools.as_ref().first().expect("pool endpoints").endpoints,
|
||||
&DiskOption {
|
||||
cleanup: true,
|
||||
@@ -999,7 +999,7 @@ mod tests {
|
||||
.await;
|
||||
|
||||
assert!(errs.iter().all(|err| err.is_none()), "disk init should succeed: {errs:?}");
|
||||
connect_load_init_formats(true, &disks, 1, 4, None)
|
||||
connect_load_init_formats(true, &mut disks, 1, 4, None)
|
||||
.await
|
||||
.expect("initialize format metadata");
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[instrument(skip(self, data))]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub(super) async fn handle_put_object_part(
|
||||
&self,
|
||||
bucket: &str,
|
||||
|
||||
@@ -815,7 +815,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self))]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub(super) async fn handle_get_object_reader(
|
||||
&self,
|
||||
bucket: &str,
|
||||
@@ -849,7 +849,7 @@ impl ECStore {
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip(self, data))]
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure)]
|
||||
#[hotpath::measure]
|
||||
pub(super) async fn handle_put_object(
|
||||
&self,
|
||||
bucket: &str,
|
||||
|
||||
@@ -28,8 +28,26 @@ 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> {
|
||||
let disk_id = disk.get_disk_id().await.ok().flatten()?;
|
||||
runtime_sources::record_local_disk_id(instance_ctx, disk_id, disk.endpoint().to_string()).await;
|
||||
Some(disk_id)
|
||||
record_local_disk_id_if_active(instance_ctx, disk, disk_id)
|
||||
.await
|
||||
.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> {
|
||||
@@ -228,6 +246,7 @@ pub async fn get_disk_infos(disks: &[Option<DiskStore>]) -> Vec<Option<DiskInfo>
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::disk::new_disk;
|
||||
use crate::layout::endpoints::{Endpoints, PoolEndpoints};
|
||||
|
||||
fn single_local_disk_pools(dir: &std::path::Path) -> EndpointServerPools {
|
||||
@@ -314,4 +333,56 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## MinIO-generated encrypted fixtures
|
||||
|
||||
`minio_generated_read_test.rs` validates the `bitrot -> GetObjectReader` path against raw MinIO backend data captured by
|
||||
`rustfs/src/storage/minio_generated_read_test.rs` validates the `bitrot -> GetObjectReader` path against raw MinIO backend data captured by
|
||||
`.\rustfs\scripts\minio_fixture_lab\lab.py`.
|
||||
|
||||
It currently covers multipart fixtures for:
|
||||
@@ -20,5 +20,5 @@ Example:
|
||||
```powershell
|
||||
$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>'
|
||||
cargo +1.97.1 test -p rustfs-ecstore --features rio-v2 --test minio_generated_read_test -- --ignored
|
||||
cargo +1.97.1 test -p rustfs --features rio-v2 storage::minio_generated_read_test --lib -- --ignored
|
||||
```
|
||||
|
||||
@@ -27,10 +27,11 @@ documentation = "https://docs.rs/rustfs-filemeta/latest/rustfs_filemeta/"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = ["dep:hotpath", "hotpath/hotpath"]
|
||||
hotpath = ["hotpath/hotpath", "hotpath/tokio"]
|
||||
hotpath-alloc = ["hotpath/hotpath-alloc"]
|
||||
|
||||
[dependencies]
|
||||
hotpath = { workspace = true, optional = true }
|
||||
hotpath.workspace = true
|
||||
crc-fast = { workspace = true }
|
||||
rmp.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
|
||||
@@ -19,7 +19,7 @@ impl FileMeta {
|
||||
!matches!(Self::check_xl2_v1(buf), Err(_e))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "FileMeta"))]
|
||||
#[hotpath::measure(impl_type = "FileMeta")]
|
||||
pub fn load(buf: &[u8]) -> Result<FileMeta> {
|
||||
let mut xl = FileMeta::default();
|
||||
xl.unmarshal_msg(buf)?;
|
||||
@@ -112,7 +112,7 @@ impl FileMeta {
|
||||
Ok((bin_len, remaining))
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "FileMeta"))]
|
||||
#[hotpath::measure(impl_type = "FileMeta")]
|
||||
pub fn unmarshal_msg(&mut self, buf: &[u8]) -> Result<u64> {
|
||||
let i = buf.len() as u64;
|
||||
|
||||
@@ -326,7 +326,7 @@ impl FileMeta {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "hotpath", hotpath::measure(impl_type = "FileMeta"))]
|
||||
#[hotpath::measure(impl_type = "FileMeta")]
|
||||
pub fn marshal_msg(&self) -> Result<Vec<u8>> {
|
||||
let mut wr = Vec::new();
|
||||
|
||||
|
||||
@@ -556,7 +556,7 @@ where
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
pub async fn list_polices(&self, bucket_name: &str) -> Result<HashMap<String, Policy>> {
|
||||
pub async fn list_policies(&self, bucket_name: &str) -> Result<HashMap<String, Policy>> {
|
||||
let mut m = HashMap::new();
|
||||
|
||||
self.api.load_policy_docs(&mut m).await?;
|
||||
@@ -588,6 +588,15 @@ where
|
||||
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) {
|
||||
let mut policies = Vec::new();
|
||||
let mut to_merge = Vec::new();
|
||||
@@ -2184,7 +2193,7 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_default_policyes() -> HashMap<String, PolicyDoc> {
|
||||
pub fn get_default_policies() -> HashMap<String, PolicyDoc> {
|
||||
let default_policies = &DEFAULT_POLICIES;
|
||||
default_policies
|
||||
.iter()
|
||||
@@ -2201,6 +2210,15 @@ pub fn get_default_policyes() -> HashMap<String, PolicyDoc> {
|
||||
.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>) {
|
||||
let default_policies = &DEFAULT_POLICIES;
|
||||
for (k, v) in default_policies.iter() {
|
||||
@@ -2900,7 +2918,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_default_policies() {
|
||||
let policies = get_default_policyes();
|
||||
let policies = get_default_policies();
|
||||
|
||||
// Should contain some default policies
|
||||
assert!(!policies.is_empty());
|
||||
@@ -2913,6 +2931,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn deprecated_get_default_policyes_matches_current_api() {
|
||||
assert_eq!(get_default_policyes().len(), get_default_policies().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_token_signing_key() {
|
||||
// This function returns the global action credential's secret key
|
||||
|
||||
@@ -22,7 +22,7 @@ use crate::{
|
||||
cache::{Cache, CacheEntity},
|
||||
error::{is_err_no_such_policy, is_err_no_such_user},
|
||||
keyring,
|
||||
manager::{extract_jwt_claims, extract_jwt_claims_allow_missing_exp, get_default_policyes},
|
||||
manager::{extract_jwt_claims, extract_jwt_claims_allow_missing_exp, get_default_policies},
|
||||
root_credentials,
|
||||
};
|
||||
use futures::future::join_all;
|
||||
@@ -1127,7 +1127,7 @@ impl Store for ObjectStore {
|
||||
let cache_snapshot = cache.snapshot();
|
||||
let listed_config_items = self.list_all_iamconfig_items().await?;
|
||||
|
||||
let mut policy_docs_cache = CacheEntity::new(get_default_policyes());
|
||||
let mut policy_docs_cache = CacheEntity::new(get_default_policies());
|
||||
|
||||
if let Some(policies_list) = listed_config_items.get(POLICIES_LIST_KEY) {
|
||||
// Load in fixed-size chunks so each policy is fetched exactly once.
|
||||
|
||||
+20
-5
@@ -18,7 +18,7 @@ use crate::error::is_err_no_such_temp_account;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::federation::OIDC_VIRTUAL_PARENT_CLAIM;
|
||||
use crate::manager::extract_jwt_claims;
|
||||
use crate::manager::get_default_policyes;
|
||||
use crate::manager::get_default_policies;
|
||||
use crate::manager::{IamCache, IamSyncMetricsSnapshot};
|
||||
use crate::store::GroupInfo;
|
||||
use crate::store::MappedPolicy;
|
||||
@@ -249,7 +249,7 @@ impl<T: Store> IamSys<T> {
|
||||
}
|
||||
|
||||
pub async fn delete_policy(&self, name: &str, notify: bool) -> Result<()> {
|
||||
for k in get_default_policyes().keys() {
|
||||
for k in get_default_policies().keys() {
|
||||
if k == name {
|
||||
return Err(Error::other("system policy can not be deleted"));
|
||||
}
|
||||
@@ -291,8 +291,17 @@ impl<T: Store> IamSys<T> {
|
||||
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>> {
|
||||
self.store.list_polices(bucket_name).await
|
||||
self.list_policies(bucket_name).await
|
||||
}
|
||||
|
||||
pub async fn list_policy_docs(&self, bucket_name: &str) -> Result<HashMap<String, PolicyDoc>> {
|
||||
@@ -1683,11 +1692,17 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::cache::{Cache, CacheEntity};
|
||||
use crate::error::Error;
|
||||
use crate::manager::get_default_policyes;
|
||||
use crate::manager::get_default_policies;
|
||||
use crate::store::{GroupInfo, MappedPolicy, Store, UserType};
|
||||
use rustfs_credentials::{Credentials, init_global_action_credentials};
|
||||
use rustfs_policy::auth::{UserIdentity, get_new_credentials_with_metadata};
|
||||
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::policy_uses_existing_object_tag_conditions;
|
||||
use serde_json::Value;
|
||||
@@ -1925,7 +1940,7 @@ mod tests {
|
||||
}
|
||||
|
||||
async fn load_all(&self, cache: &Cache) -> Result<()> {
|
||||
let mut policy_docs = get_default_policyes();
|
||||
let mut policy_docs = get_default_policies();
|
||||
let custom_claim_policy =
|
||||
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));
|
||||
|
||||
@@ -30,7 +30,9 @@ harness = false
|
||||
|
||||
[dependencies]
|
||||
metrics = { workspace = true }
|
||||
rustfs-common = { workspace = true }
|
||||
rustfs-s3-ops = { workspace = true }
|
||||
rustfs-utils = { workspace = true, features = ["ip"] }
|
||||
num_cpus = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "fs", "rt-multi-thread"] }
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use metrics::{counter, gauge};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{
|
||||
Arc, LazyLock, RwLock,
|
||||
Arc, LazyLock, OnceLock, RwLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
@@ -40,6 +40,7 @@ pub const INTERNODE_MSGPACK_CODEC_JSON: &str = "json";
|
||||
|
||||
const OPERATION_LABEL: &str = "operation";
|
||||
const BACKEND_LABEL: &str = "backend";
|
||||
const SERVER_LABEL: &str = "server";
|
||||
const CLASSIFICATION_LABEL: &str = "classification";
|
||||
const STAGE_LABEL: &str = "stage";
|
||||
const DOMINANT_ERROR_LABEL: &str = "dominant_error";
|
||||
@@ -77,74 +78,93 @@ pub struct InternodeOperationMetricDescriptor {
|
||||
pub labels: &'static [&'static str],
|
||||
}
|
||||
|
||||
const OPERATION_BACKEND_LABELS: &[&str] = &[OPERATION_LABEL, BACKEND_LABEL];
|
||||
const OPERATION_BACKEND_CLASSIFICATION_LABELS: &[&str] = &[OPERATION_LABEL, BACKEND_LABEL, CLASSIFICATION_LABEL];
|
||||
const OPERATION_BACKEND_HTTP_VERSION_LABELS: &[&str] = &[OPERATION_LABEL, BACKEND_LABEL, HTTP_VERSION_LABEL];
|
||||
const QUORUM_FAILURE_LABELS: &[&str] = &[STAGE_LABEL, DOMINANT_ERROR_LABEL];
|
||||
const SERVER_OPERATION_BACKEND_LABELS: &[&str] = &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL];
|
||||
const SERVER_OPERATION_BACKEND_CLASSIFICATION_LABELS: &[&str] =
|
||||
&[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, CLASSIFICATION_LABEL];
|
||||
const SERVER_OPERATION_BACKEND_HTTP_VERSION_LABELS: &[&str] = &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, HTTP_VERSION_LABEL];
|
||||
const SERVER_QUORUM_FAILURE_LABELS: &[&str] = &[SERVER_LABEL, STAGE_LABEL, DOMINANT_ERROR_LABEL];
|
||||
|
||||
pub const INTERNODE_OPERATION_METRICS: &[InternodeOperationMetricDescriptor] = &[
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_SENT_BYTES_TOTAL,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_RECV_BYTES_TOTAL,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_REQUESTS_OUTGOING_TOTAL,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_REQUESTS_INCOMING_TOTAL,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_ERRORS_TOTAL,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_DURATION_MS,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_CLASSIFIED_ERRORS_TOTAL,
|
||||
labels: OPERATION_BACKEND_CLASSIFICATION_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_CLASSIFICATION_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_RETRIES_TOTAL,
|
||||
labels: OPERATION_BACKEND_CLASSIFICATION_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_CLASSIFICATION_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_RETRY_SUCCESSES_TOTAL,
|
||||
labels: OPERATION_BACKEND_CLASSIFICATION_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_CLASSIFICATION_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_HTTP_VERSIONS_TOTAL,
|
||||
labels: OPERATION_BACKEND_HTTP_VERSION_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_HTTP_VERSION_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_STALL_TIMEOUTS_TOTAL,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_WRITE_SHUTDOWN_ERRORS_TOTAL,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: ERASURE_WRITE_QUORUM_FAILURES_TOTAL,
|
||||
labels: QUORUM_FAILURE_LABELS,
|
||||
labels: SERVER_QUORUM_FAILURE_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_PAYLOAD_BYTES,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
InternodeOperationMetricDescriptor {
|
||||
name: INTERNODE_OPERATION_LARGE_PAYLOADS_TOTAL,
|
||||
labels: OPERATION_BACKEND_LABELS,
|
||||
labels: SERVER_OPERATION_BACKEND_LABELS,
|
||||
},
|
||||
];
|
||||
|
||||
fn current_server_label() -> &'static str {
|
||||
static STABLE_SERVER_LABEL: OnceLock<String> = OnceLock::new();
|
||||
static FALLBACK_SERVER_LABEL: LazyLock<String> = LazyLock::new(rustfs_utils::get_local_ip_with_default);
|
||||
|
||||
if let Some(server) = STABLE_SERVER_LABEL.get() {
|
||||
return server.as_str();
|
||||
}
|
||||
|
||||
if let Some(server) = rustfs_common::try_get_global_local_node_name() {
|
||||
let _ = STABLE_SERVER_LABEL.set(server);
|
||||
if let Some(server) = STABLE_SERVER_LABEL.get() {
|
||||
return server.as_str();
|
||||
}
|
||||
}
|
||||
|
||||
FALLBACK_SERVER_LABEL.as_str()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct InternodeMetricsSnapshot {
|
||||
pub sent_bytes_total: u64,
|
||||
@@ -193,7 +213,7 @@ impl InternodeMetrics {
|
||||
return;
|
||||
}
|
||||
self.sent_bytes_total.fetch_add(bytes, Ordering::Relaxed);
|
||||
counter!("rustfs_system_network_internode_sent_bytes_total").increment(bytes);
|
||||
counter!("rustfs_system_network_internode_sent_bytes_total", SERVER_LABEL => current_server_label()).increment(bytes);
|
||||
}
|
||||
|
||||
pub fn record_sent_bytes_for_operation(&self, operation: &'static str, bytes: usize) {
|
||||
@@ -207,7 +227,13 @@ impl InternodeMetrics {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
counter!(INTERNODE_OPERATION_SENT_BYTES_TOTAL, OPERATION_LABEL => operation, BACKEND_LABEL => backend).increment(bytes);
|
||||
counter!(
|
||||
INTERNODE_OPERATION_SENT_BYTES_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.increment(bytes);
|
||||
}
|
||||
|
||||
pub fn record_recv_bytes(&self, bytes: usize) {
|
||||
@@ -216,7 +242,7 @@ impl InternodeMetrics {
|
||||
return;
|
||||
}
|
||||
self.recv_bytes_total.fetch_add(bytes, Ordering::Relaxed);
|
||||
counter!("rustfs_system_network_internode_recv_bytes_total").increment(bytes);
|
||||
counter!("rustfs_system_network_internode_recv_bytes_total", SERVER_LABEL => current_server_label()).increment(bytes);
|
||||
}
|
||||
|
||||
pub fn record_recv_bytes_for_operation(&self, operation: &'static str, bytes: usize) {
|
||||
@@ -230,12 +256,18 @@ impl InternodeMetrics {
|
||||
if bytes == 0 {
|
||||
return;
|
||||
}
|
||||
counter!(INTERNODE_OPERATION_RECV_BYTES_TOTAL, OPERATION_LABEL => operation, BACKEND_LABEL => backend).increment(bytes);
|
||||
counter!(
|
||||
INTERNODE_OPERATION_RECV_BYTES_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.increment(bytes);
|
||||
}
|
||||
|
||||
pub fn record_outgoing_request(&self) {
|
||||
self.outgoing_requests_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!("rustfs_system_network_internode_requests_outgoing_total").increment(1);
|
||||
counter!("rustfs_system_network_internode_requests_outgoing_total", SERVER_LABEL => current_server_label()).increment(1);
|
||||
}
|
||||
|
||||
pub fn record_outgoing_request_for_operation(&self, operation: &'static str) {
|
||||
@@ -244,13 +276,18 @@ impl InternodeMetrics {
|
||||
|
||||
pub fn record_outgoing_request_for_operation_and_backend(&self, operation: &'static str, backend: &'static str) {
|
||||
self.record_outgoing_request();
|
||||
counter!(INTERNODE_OPERATION_REQUESTS_OUTGOING_TOTAL, OPERATION_LABEL => operation, BACKEND_LABEL => backend)
|
||||
.increment(1);
|
||||
counter!(
|
||||
INTERNODE_OPERATION_REQUESTS_OUTGOING_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn record_incoming_request(&self) {
|
||||
self.incoming_requests_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!("rustfs_system_network_internode_requests_incoming_total").increment(1);
|
||||
counter!("rustfs_system_network_internode_requests_incoming_total", SERVER_LABEL => current_server_label()).increment(1);
|
||||
}
|
||||
|
||||
pub fn record_incoming_request_for_operation(&self, operation: &'static str) {
|
||||
@@ -259,13 +296,18 @@ impl InternodeMetrics {
|
||||
|
||||
pub fn record_incoming_request_for_operation_and_backend(&self, operation: &'static str, backend: &'static str) {
|
||||
self.record_incoming_request();
|
||||
counter!(INTERNODE_OPERATION_REQUESTS_INCOMING_TOTAL, OPERATION_LABEL => operation, BACKEND_LABEL => backend)
|
||||
.increment(1);
|
||||
counter!(
|
||||
INTERNODE_OPERATION_REQUESTS_INCOMING_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn record_error(&self) {
|
||||
self.errors_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!("rustfs_system_network_internode_errors_total").increment(1);
|
||||
counter!("rustfs_system_network_internode_errors_total", SERVER_LABEL => current_server_label()).increment(1);
|
||||
}
|
||||
|
||||
pub fn record_error_for_operation(&self, operation: &'static str) {
|
||||
@@ -274,13 +316,24 @@ impl InternodeMetrics {
|
||||
|
||||
pub fn record_error_for_operation_and_backend(&self, operation: &'static str, backend: &'static str) {
|
||||
self.record_error();
|
||||
counter!(INTERNODE_OPERATION_ERRORS_TOTAL, OPERATION_LABEL => operation, BACKEND_LABEL => backend).increment(1);
|
||||
counter!(
|
||||
INTERNODE_OPERATION_ERRORS_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn record_duration_for_operation_and_backend(&self, operation: &'static str, backend: &'static str, duration: Duration) {
|
||||
let duration_ms = duration.as_secs_f64() * 1000.0;
|
||||
metrics::histogram!(INTERNODE_OPERATION_DURATION_MS, OPERATION_LABEL => operation, BACKEND_LABEL => backend)
|
||||
.record(duration_ms);
|
||||
metrics::histogram!(
|
||||
INTERNODE_OPERATION_DURATION_MS,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.record(duration_ms);
|
||||
}
|
||||
|
||||
pub fn record_classified_error_for_operation_and_backend(
|
||||
@@ -291,6 +344,7 @@ impl InternodeMetrics {
|
||||
) {
|
||||
counter!(
|
||||
INTERNODE_OPERATION_CLASSIFIED_ERRORS_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend,
|
||||
CLASSIFICATION_LABEL => classification
|
||||
@@ -306,6 +360,7 @@ impl InternodeMetrics {
|
||||
) {
|
||||
counter!(
|
||||
INTERNODE_OPERATION_RETRIES_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend,
|
||||
CLASSIFICATION_LABEL => classification
|
||||
@@ -321,6 +376,7 @@ impl InternodeMetrics {
|
||||
) {
|
||||
counter!(
|
||||
INTERNODE_OPERATION_RETRY_SUCCESSES_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend,
|
||||
CLASSIFICATION_LABEL => classification
|
||||
@@ -337,6 +393,7 @@ impl InternodeMetrics {
|
||||
self.operation_http_versions_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!(
|
||||
INTERNODE_OPERATION_HTTP_VERSIONS_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend,
|
||||
HTTP_VERSION_LABEL => http_version
|
||||
@@ -346,13 +403,24 @@ impl InternodeMetrics {
|
||||
|
||||
pub fn record_stall_timeout_for_operation_and_backend(&self, operation: &'static str, backend: &'static str) {
|
||||
self.operation_stall_timeouts_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!(INTERNODE_OPERATION_STALL_TIMEOUTS_TOTAL, OPERATION_LABEL => operation, BACKEND_LABEL => backend).increment(1);
|
||||
counter!(
|
||||
INTERNODE_OPERATION_STALL_TIMEOUTS_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn record_write_shutdown_error_for_operation_and_backend(&self, operation: &'static str, backend: &'static str) {
|
||||
self.operation_write_shutdown_errors_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!(INTERNODE_OPERATION_WRITE_SHUTDOWN_ERRORS_TOTAL, OPERATION_LABEL => operation, BACKEND_LABEL => backend)
|
||||
.increment(1);
|
||||
counter!(
|
||||
INTERNODE_OPERATION_WRITE_SHUTDOWN_ERRORS_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
/// Record the payload size (bytes) of a completed internode operation into a histogram
|
||||
@@ -360,15 +428,26 @@ impl InternodeMetrics {
|
||||
/// (`ReadAll`/`ReadMultiple`/`WriteAll`) would benefit from being moved off the shared
|
||||
/// control-plane channel (see docs/grpc-optimization P1).
|
||||
pub fn record_operation_payload_bytes(&self, operation: &'static str, backend: &'static str, bytes: usize) {
|
||||
metrics::histogram!(INTERNODE_OPERATION_PAYLOAD_BYTES, OPERATION_LABEL => operation, BACKEND_LABEL => backend)
|
||||
.record(bytes as f64);
|
||||
metrics::histogram!(
|
||||
INTERNODE_OPERATION_PAYLOAD_BYTES,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.record(bytes as f64);
|
||||
}
|
||||
|
||||
/// Increment the large-payload counter for an operation+backend whose payload exceeded the
|
||||
/// caller-configured warning threshold. Feeds alerting on large unary RPCs that contend with
|
||||
/// latency-sensitive control-plane traffic on the shared connection.
|
||||
pub fn record_large_operation_payload(&self, operation: &'static str, backend: &'static str) {
|
||||
counter!(INTERNODE_OPERATION_LARGE_PAYLOADS_TOTAL, OPERATION_LABEL => operation, BACKEND_LABEL => backend).increment(1);
|
||||
counter!(
|
||||
INTERNODE_OPERATION_LARGE_PAYLOADS_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
OPERATION_LABEL => operation,
|
||||
BACKEND_LABEL => backend
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
/// Count a decode that fell back to the JSON compatibility field because the msgpack `_bin`
|
||||
@@ -377,13 +456,20 @@ impl InternodeMetrics {
|
||||
/// dropped (grpc-optimization P2). `direction` is [`INTERNODE_MSGPACK_DIRECTION_REQUEST`] or
|
||||
/// [`INTERNODE_MSGPACK_DIRECTION_RESPONSE`]; `message` is the low-cardinality value name.
|
||||
pub fn record_msgpack_json_fallback(&self, direction: &'static str, message: &'static str) {
|
||||
counter!(INTERNODE_MSGPACK_JSON_FALLBACK_TOTAL, DIRECTION_LABEL => direction, MESSAGE_LABEL => message).increment(1);
|
||||
counter!(
|
||||
INTERNODE_MSGPACK_JSON_FALLBACK_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
DIRECTION_LABEL => direction,
|
||||
MESSAGE_LABEL => message
|
||||
)
|
||||
.increment(1);
|
||||
}
|
||||
|
||||
pub fn record_msgpack_json_decode(&self, direction: &'static str, message: &'static str, codec: &'static str) {
|
||||
self.msgpack_json_decode_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!(
|
||||
INTERNODE_MSGPACK_JSON_DECODE_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
DIRECTION_LABEL => direction,
|
||||
MESSAGE_LABEL => message,
|
||||
CODEC_LABEL => codec
|
||||
@@ -395,6 +481,7 @@ impl InternodeMetrics {
|
||||
self.msgpack_json_decode_error_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!(
|
||||
INTERNODE_MSGPACK_JSON_DECODE_ERROR_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
DIRECTION_LABEL => direction,
|
||||
MESSAGE_LABEL => message,
|
||||
CODEC_LABEL => codec
|
||||
@@ -420,7 +507,7 @@ impl InternodeMetrics {
|
||||
/// enabled; after the strict flip the legacy fallback path is closed and the counter stays flat.
|
||||
pub fn record_signature_v1_fallback(&self) {
|
||||
self.signature_v1_fallback_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!(INTERNODE_SIGNATURE_V1_FALLBACK_TOTAL).increment(1);
|
||||
counter!(INTERNODE_SIGNATURE_V1_FALLBACK_TOTAL, SERVER_LABEL => current_server_label()).increment(1);
|
||||
}
|
||||
|
||||
/// Count a mutating internode disk RPC that was accepted without a signature-bound canonical
|
||||
@@ -431,14 +518,14 @@ impl InternodeMetrics {
|
||||
/// mutations are rejected and the counter stays flat.
|
||||
pub fn record_body_digest_fallback(&self) {
|
||||
self.body_digest_fallback_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!(INTERNODE_BODY_DIGEST_FALLBACK_TOTAL).increment(1);
|
||||
counter!(INTERNODE_BODY_DIGEST_FALLBACK_TOTAL, SERVER_LABEL => current_server_label()).increment(1);
|
||||
}
|
||||
|
||||
/// Count an accepted v1/v2 request that does not carry the replay-scoped signature. This is
|
||||
/// the convergence signal for `RUSTFS_INTERNODE_RPC_REPLAY_SCOPE_STRICT`.
|
||||
pub fn record_replay_scope_fallback(&self) {
|
||||
self.replay_scope_fallback_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!(INTERNODE_REPLAY_SCOPE_FALLBACK_TOTAL).increment(1);
|
||||
counter!(INTERNODE_REPLAY_SCOPE_FALLBACK_TOTAL, SERVER_LABEL => current_server_label()).increment(1);
|
||||
}
|
||||
|
||||
/// Count a body-bound internode RPC rejected because the replay-protection nonce cache was
|
||||
@@ -447,12 +534,13 @@ impl InternodeMetrics {
|
||||
/// mutation rate and writes are being refused — alert on this counter.
|
||||
pub fn record_replay_cache_overflow(&self) {
|
||||
self.replay_cache_overflow_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!(INTERNODE_REPLAY_CACHE_OVERFLOW_TOTAL).increment(1);
|
||||
counter!(INTERNODE_REPLAY_CACHE_OVERFLOW_TOTAL, SERVER_LABEL => current_server_label()).increment(1);
|
||||
}
|
||||
|
||||
pub fn record_erasure_write_quorum_failure(&self, stage: &'static str, dominant_error: &'static str) {
|
||||
counter!(
|
||||
ERASURE_WRITE_QUORUM_FAILURES_TOTAL,
|
||||
SERVER_LABEL => current_server_label(),
|
||||
STAGE_LABEL => stage,
|
||||
DOMINANT_ERROR_LABEL => dominant_error
|
||||
)
|
||||
@@ -464,11 +552,12 @@ impl InternodeMetrics {
|
||||
self.dial_total_time_nanos.fetch_add(elapsed_nanos, Ordering::Relaxed);
|
||||
let samples = self.dial_samples_total.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
let total = self.dial_total_time_nanos.load(Ordering::Relaxed);
|
||||
gauge!("rustfs_system_network_internode_dial_avg_time_nanos").set(total as f64 / samples as f64);
|
||||
gauge!("rustfs_system_network_internode_dial_avg_time_nanos", SERVER_LABEL => current_server_label())
|
||||
.set(total as f64 / samples as f64);
|
||||
|
||||
if !success {
|
||||
self.dial_errors_total.fetch_add(1, Ordering::Relaxed);
|
||||
counter!("rustfs_system_network_internode_dial_errors_total").increment(1);
|
||||
counter!("rustfs_system_network_internode_dial_errors_total", SERVER_LABEL => current_server_label()).increment(1);
|
||||
}
|
||||
|
||||
let now_ms = SystemTime::now()
|
||||
@@ -687,6 +776,9 @@ fn cluster_peer_health_keys() -> Vec<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use metrics::with_local_recorder;
|
||||
use metrics_util::debugging::DebuggingRecorder;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[test]
|
||||
fn snapshot_reports_recorded_values() {
|
||||
@@ -750,22 +842,22 @@ mod tests {
|
||||
fn operation_metric_descriptors_include_backend_and_operation_labels() {
|
||||
assert_eq!(INTERNODE_OPERATION_METRICS.len(), 15);
|
||||
for metric in &INTERNODE_OPERATION_METRICS[..6] {
|
||||
assert_eq!(metric.labels, &[OPERATION_LABEL, BACKEND_LABEL]);
|
||||
assert_eq!(metric.labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL]);
|
||||
}
|
||||
for metric in &INTERNODE_OPERATION_METRICS[6..9] {
|
||||
assert_eq!(metric.labels, &[OPERATION_LABEL, BACKEND_LABEL, CLASSIFICATION_LABEL]);
|
||||
assert_eq!(metric.labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, CLASSIFICATION_LABEL]);
|
||||
}
|
||||
assert_eq!(
|
||||
INTERNODE_OPERATION_METRICS[9].labels,
|
||||
&[OPERATION_LABEL, BACKEND_LABEL, HTTP_VERSION_LABEL]
|
||||
&[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL, HTTP_VERSION_LABEL]
|
||||
);
|
||||
for metric in &INTERNODE_OPERATION_METRICS[10..12] {
|
||||
assert_eq!(metric.labels, &[OPERATION_LABEL, BACKEND_LABEL]);
|
||||
assert_eq!(metric.labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL]);
|
||||
}
|
||||
assert_eq!(INTERNODE_OPERATION_METRICS[12].labels, &[STAGE_LABEL, DOMINANT_ERROR_LABEL]);
|
||||
assert_eq!(INTERNODE_OPERATION_METRICS[12].labels, &[SERVER_LABEL, STAGE_LABEL, DOMINANT_ERROR_LABEL]);
|
||||
// Payload histogram + large-payload counter carry operation+backend labels.
|
||||
assert_eq!(INTERNODE_OPERATION_METRICS[13].labels, &[OPERATION_LABEL, BACKEND_LABEL]);
|
||||
assert_eq!(INTERNODE_OPERATION_METRICS[14].labels, &[OPERATION_LABEL, BACKEND_LABEL]);
|
||||
assert_eq!(INTERNODE_OPERATION_METRICS[13].labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL]);
|
||||
assert_eq!(INTERNODE_OPERATION_METRICS[14].labels, &[SERVER_LABEL, OPERATION_LABEL, BACKEND_LABEL]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -843,6 +935,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_internode_metrics_emit_stable_server_label() {
|
||||
let recorder = DebuggingRecorder::new();
|
||||
let snapshotter = recorder.snapshotter();
|
||||
let metrics = InternodeMetrics::default();
|
||||
|
||||
with_local_recorder(&recorder, || {
|
||||
metrics.record_sent_bytes_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_READ_FILE_STREAM,
|
||||
INTERNODE_TRANSPORT_BACKEND_TCP_HTTP,
|
||||
128,
|
||||
);
|
||||
metrics.record_recv_bytes_for_operation_and_backend(
|
||||
INTERNODE_OPERATION_PUT_FILE_STREAM,
|
||||
INTERNODE_TRANSPORT_BACKEND_TCP_HTTP,
|
||||
256,
|
||||
);
|
||||
metrics.record_dial_result(Duration::from_millis(3), false);
|
||||
});
|
||||
|
||||
let observed: Vec<(String, HashSet<String>, Option<String>)> = snapshotter
|
||||
.snapshot()
|
||||
.into_vec()
|
||||
.into_iter()
|
||||
.filter(|(composite, _, _, _)| {
|
||||
matches!(
|
||||
composite.key().name(),
|
||||
"rustfs_system_network_internode_sent_bytes_total"
|
||||
| "rustfs_system_network_internode_recv_bytes_total"
|
||||
| INTERNODE_OPERATION_SENT_BYTES_TOTAL
|
||||
| INTERNODE_OPERATION_RECV_BYTES_TOTAL
|
||||
| "rustfs_system_network_internode_dial_avg_time_nanos"
|
||||
| "rustfs_system_network_internode_dial_errors_total"
|
||||
)
|
||||
})
|
||||
.map(|(composite, _, _, _)| {
|
||||
let labels = composite.key().labels();
|
||||
let keys = labels.clone().map(|label| label.key().to_string()).collect();
|
||||
let server = labels
|
||||
.filter(|label| label.key() == SERVER_LABEL)
|
||||
.map(|label| label.value().to_string())
|
||||
.next();
|
||||
(composite.key().name().to_string(), keys, server)
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(observed.len(), 6);
|
||||
for (name, keys, server) in observed {
|
||||
assert!(keys.contains(SERVER_LABEL), "{name} must carry the server label");
|
||||
assert!(server.is_some_and(|value| !value.is_empty()), "{name} server label must not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msgpack_json_fallback_counter_records_without_panicking() {
|
||||
// Smoke test: the counter accepts both directions and a static message label.
|
||||
|
||||
@@ -25,3 +25,25 @@ For local KMS end-to-end tests, keep proxy bypass settings:
|
||||
NO_PROXY=127.0.0.1,localhost HTTP_PROXY= HTTPS_PROXY= http_proxy= https_proxy= \
|
||||
cargo test --package e2e_test test_local_kms_end_to_end -- --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
## Local Key Export for SSE-S3 Migration Tests
|
||||
|
||||
Use the read-only `local_kms_key_decrypt` example to export an AES-256 Local
|
||||
KMS key as the base64 value expected by `RUSTFS_SSE_S3_MASTER_KEY`:
|
||||
|
||||
```bash
|
||||
export RUSTFS_KMS_LOCAL_MASTER_KEY='<local-kms-at-rest-master-key>'
|
||||
export RUSTFS_SSE_S3_MASTER_KEY="$(
|
||||
cargo run -q -p rustfs-kms --example local_kms_key_decrypt -- \
|
||||
/absolute/path/to/<key-id>.key
|
||||
)"
|
||||
```
|
||||
|
||||
For a `plaintext-dev-only` Local KMS key file,
|
||||
`RUSTFS_KMS_LOCAL_MASTER_KEY` is not required.
|
||||
|
||||
The example writes only the base64-encoded 32-byte key to stdout. Diagnostics
|
||||
go to stderr. Never paste its output into logs, shell history, issue comments,
|
||||
or committed configuration. The export path must remain read-only and must
|
||||
reuse `LocalKmsClient` decoding so current Argon2id and legacy key-file
|
||||
compatibility stay aligned with the backend.
|
||||
|
||||
@@ -65,11 +65,17 @@ rustfs-security-governance = { workspace = true }
|
||||
# HTTP client for Vault
|
||||
reqwest = { workspace = true }
|
||||
vaultrs = { workspace = true }
|
||||
# vaultrs surfaces transport-level failures as wrapped rustify errors; the
|
||||
# operation policy needs the concrete type to classify them for retry decisions.
|
||||
rustify = { workspace = true }
|
||||
tokio-util = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
anyhow = { workspace = true }
|
||||
insta = { workspace = true, features = ["yaml", "json"] }
|
||||
tempfile = { workspace = true }
|
||||
temp-env = { workspace = true }
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// 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 base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use rustfs_kms::{LocalConfig, backends::local::LocalKmsClient};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const LOCAL_KMS_MASTER_KEY_ENV: &str = "RUSTFS_KMS_LOCAL_MASTER_KEY";
|
||||
|
||||
fn usage(program: &str) -> String {
|
||||
format!(
|
||||
"Usage: {program} <local-kms-key-file>\n\
|
||||
Reads {LOCAL_KMS_MASTER_KEY_ENV} when the key file is encrypted.\n\
|
||||
Writes only the base64-encoded 32-byte key to stdout."
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_key_file(path: &Path) -> Result<(PathBuf, String), String> {
|
||||
let canonical = std::fs::canonicalize(path).map_err(|error| format!("cannot open Local KMS key file: {error}"))?;
|
||||
if canonical.extension().and_then(|extension| extension.to_str()) != Some("key") {
|
||||
return Err("Local KMS key file must have a .key extension".to_string());
|
||||
}
|
||||
let key_dir = canonical
|
||||
.parent()
|
||||
.ok_or_else(|| "Local KMS key file must have a parent directory".to_string())?
|
||||
.to_path_buf();
|
||||
let key_id = canonical
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.filter(|stem| !stem.is_empty())
|
||||
.ok_or_else(|| "Local KMS key file name must contain a valid UTF-8 key ID".to_string())?
|
||||
.to_string();
|
||||
Ok((key_dir, key_id))
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), String> {
|
||||
let mut args = std::env::args();
|
||||
let program = args.next().unwrap_or_else(|| "local_kms_key_decrypt".to_string());
|
||||
let Some(key_file) = args.next() else {
|
||||
return Err(usage(&program));
|
||||
};
|
||||
if args.next().is_some() {
|
||||
return Err(usage(&program));
|
||||
}
|
||||
|
||||
let (key_dir, key_id) = resolve_key_file(Path::new(&key_file))?;
|
||||
let master_key = std::env::var(LOCAL_KMS_MASTER_KEY_ENV).ok().filter(|value| !value.is_empty());
|
||||
let client = LocalKmsClient::new_for_key_export(LocalConfig {
|
||||
key_dir,
|
||||
master_key,
|
||||
file_permissions: Some(0o600),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let key_material = client
|
||||
.decrypt_key_material_for_export(&key_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
let encoded = Zeroizing::new(BASE64_STANDARD.encode(key_material.as_ref()));
|
||||
|
||||
let mut stdout = io::stdout().lock();
|
||||
writeln!(stdout, "{}", encoded.as_str()).map_err(|error| format!("failed to write decrypted key: {error}"))
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(error) = run().await {
|
||||
let _ = writeln!(io::stderr().lock(), "local_kms_key_decrypt: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolve_key_file_extracts_directory_and_key_id() {
|
||||
let directory = tempfile::tempdir().expect("create temporary directory");
|
||||
let key_file = directory.path().join("migration-key.key");
|
||||
std::fs::write(&key_file, b"{}").expect("create key file");
|
||||
|
||||
let (key_dir, key_id) = resolve_key_file(&key_file).expect("resolve key file");
|
||||
|
||||
assert_eq!(key_dir, directory.path().canonicalize().expect("canonical directory"));
|
||||
assert_eq!(key_id, "migration-key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_key_file_rejects_non_key_extension() {
|
||||
let directory = tempfile::tempdir().expect("create temporary directory");
|
||||
let key_file = directory.path().join("migration-key.json");
|
||||
std::fs::write(&key_file, b"{}").expect("create key file");
|
||||
|
||||
let error = resolve_key_file(&key_file).expect_err("non-key file must be rejected");
|
||||
|
||||
assert!(error.contains(".key"));
|
||||
}
|
||||
}
|
||||
+102
-12
@@ -71,7 +71,10 @@ impl fmt::Debug for ConfigureLocalKmsRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Request to configure KMS with Vault KV v2 + Transit backend
|
||||
/// Request to configure KMS with the Vault KV v2 storage backend.
|
||||
///
|
||||
/// This backend stores master key material directly in KV v2; confidentiality relies on
|
||||
/// Vault ACLs and KV v2 at-rest encryption, with no Transit wrapping involved.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigureVaultKmsRequest {
|
||||
@@ -82,7 +85,8 @@ pub struct ConfigureVaultKmsRequest {
|
||||
pub auth_method: VaultAuthMethod,
|
||||
/// Vault namespace (Vault Enterprise, optional)
|
||||
pub namespace: Option<String>,
|
||||
/// Transit engine mount path
|
||||
/// Deprecated: legacy Transit engine mount path. Still accepted so older clients keep
|
||||
/// working, but the Vault KV2 backend never uses it.
|
||||
pub mount_path: Option<String>,
|
||||
/// KV engine mount path for storing keys
|
||||
pub kv_mount: Option<String>,
|
||||
@@ -192,7 +196,7 @@ pub enum ConfigureKmsRequest {
|
||||
/// Configure with Local backend
|
||||
#[serde(alias = "local", alias = "Local")]
|
||||
Local(ConfigureLocalKmsRequest),
|
||||
/// Configure with Vault KV v2 + Transit backend
|
||||
/// Configure with the Vault KV v2 storage backend
|
||||
#[serde(
|
||||
rename = "VaultKV2",
|
||||
alias = "Vault",
|
||||
@@ -231,15 +235,55 @@ pub struct StartKmsRequest {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
enum StrictVaultAuthMethod {
|
||||
Token { token: String },
|
||||
AppRole { role_id: String, secret_id: String },
|
||||
Token {
|
||||
token: String,
|
||||
},
|
||||
AppRole {
|
||||
role_id: String,
|
||||
#[serde(default)]
|
||||
secret_id: String,
|
||||
#[serde(default)]
|
||||
secret_id_file: Option<std::path::PathBuf>,
|
||||
#[serde(default)]
|
||||
mount: Option<String>,
|
||||
#[serde(default)]
|
||||
refresh_safety_window_secs: Option<u64>,
|
||||
},
|
||||
TokenFile {
|
||||
path: std::path::PathBuf,
|
||||
#[serde(default)]
|
||||
poll_interval_secs: Option<u64>,
|
||||
#[serde(default)]
|
||||
refresh_safety_window_secs: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<StrictVaultAuthMethod> for VaultAuthMethod {
|
||||
fn from(value: StrictVaultAuthMethod) -> Self {
|
||||
match value {
|
||||
StrictVaultAuthMethod::Token { token } => Self::Token { token },
|
||||
StrictVaultAuthMethod::AppRole { role_id, secret_id } => Self::AppRole { role_id, secret_id },
|
||||
StrictVaultAuthMethod::AppRole {
|
||||
role_id,
|
||||
secret_id,
|
||||
secret_id_file,
|
||||
mount,
|
||||
refresh_safety_window_secs,
|
||||
} => Self::AppRole {
|
||||
role_id,
|
||||
secret_id,
|
||||
secret_id_file,
|
||||
mount: mount.unwrap_or_else(|| crate::config::DEFAULT_VAULT_APPROLE_MOUNT.to_string()),
|
||||
refresh_safety_window_secs,
|
||||
},
|
||||
StrictVaultAuthMethod::TokenFile {
|
||||
path,
|
||||
poll_interval_secs,
|
||||
refresh_safety_window_secs,
|
||||
} => Self::TokenFile {
|
||||
path,
|
||||
poll_interval_secs,
|
||||
refresh_safety_window_secs,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,7 +377,7 @@ pub enum BackendSummary {
|
||||
/// File permissions (octal)
|
||||
file_permissions: Option<u32>,
|
||||
},
|
||||
/// Vault KV v2 + Transit backend summary
|
||||
/// Vault KV v2 storage backend summary
|
||||
#[serde(alias = "vault")]
|
||||
VaultKv2 {
|
||||
/// Vault server address
|
||||
@@ -344,7 +388,8 @@ pub enum BackendSummary {
|
||||
has_stored_credentials: bool,
|
||||
/// Namespace (if configured)
|
||||
namespace: Option<String>,
|
||||
/// Transit engine mount path
|
||||
/// Deprecated: legacy Transit mount path. Unused by the backend; kept only so the
|
||||
/// serialized response shape stays stable for existing consumers.
|
||||
mount_path: String,
|
||||
/// KV engine mount path
|
||||
kv_mount: String,
|
||||
@@ -398,6 +443,7 @@ impl From<&KmsConfig> for KmsConfigSummary {
|
||||
auth_method_type: match &vault_config.auth_method {
|
||||
VaultAuthMethod::Token { .. } => "token".to_string(),
|
||||
VaultAuthMethod::AppRole { .. } => "approle".to_string(),
|
||||
VaultAuthMethod::TokenFile { .. } => "token_file".to_string(),
|
||||
},
|
||||
has_stored_credentials: true,
|
||||
namespace: vault_config.namespace.clone(),
|
||||
@@ -411,6 +457,7 @@ impl From<&KmsConfig> for KmsConfigSummary {
|
||||
auth_method_type: match &vault_config.auth_method {
|
||||
VaultAuthMethod::Token { .. } => "token".to_string(),
|
||||
VaultAuthMethod::AppRole { .. } => "approle".to_string(),
|
||||
VaultAuthMethod::TokenFile { .. } => "token_file".to_string(),
|
||||
},
|
||||
has_stored_credentials: true,
|
||||
namespace: vault_config.namespace.clone(),
|
||||
@@ -621,6 +668,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_vault_kv2_configure_request_mount_path_optional_but_accepted() {
|
||||
// deny_unknown_fields regression guard: mount_path is deprecated but must remain
|
||||
// accepted so older clients that still send it do not get a 400.
|
||||
let with_mount_path = serde_json::json!({
|
||||
"backend_type": "VaultKV2",
|
||||
"address": "http://127.0.0.1:8200",
|
||||
"auth_method": { "Token": { "token": "dev-root-token" } },
|
||||
"mount_path": "transit"
|
||||
});
|
||||
let request: ConfigureKmsRequest =
|
||||
serde_json::from_value(with_mount_path).expect("request with deprecated mount_path should deserialize");
|
||||
let config = request.to_kms_config();
|
||||
assert_eq!(config.vault_config().expect("vault-kv2 config").mount_path, "transit");
|
||||
|
||||
let without_mount_path = serde_json::json!({
|
||||
"backend_type": "VaultKV2",
|
||||
"address": "http://127.0.0.1:8200",
|
||||
"auth_method": { "Token": { "token": "dev-root-token" } }
|
||||
});
|
||||
let request: ConfigureKmsRequest =
|
||||
serde_json::from_value(without_mount_path).expect("request without mount_path should deserialize");
|
||||
let config = request.to_kms_config();
|
||||
assert_eq!(config.vault_config().expect("vault-kv2 config").mount_path, "transit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_kv2_status_summary_does_not_mention_transit() {
|
||||
let config = KmsConfig::vault(
|
||||
url::Url::parse("https://vault.example.com:8200").expect("vault URL"),
|
||||
"summary-token".to_string(),
|
||||
);
|
||||
let response = KmsStatusResponse {
|
||||
status: KmsServiceStatus::Running,
|
||||
backend_type: Some(config.backend.clone()),
|
||||
healthy: Some(true),
|
||||
config_summary: Some(KmsConfigSummary::from(&config)),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&response).expect("kms status response should serialize");
|
||||
assert!(
|
||||
!json.contains("Transit"),
|
||||
"vault-kv2 status output must not describe the backend as Transit: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_vault_transit_configure_request() {
|
||||
let cases = ["VaultTransit", "vault-transit", "vault_transit"];
|
||||
@@ -821,10 +914,7 @@ mod tests {
|
||||
});
|
||||
let approle = ConfigureKmsRequest::VaultKv2(ConfigureVaultKmsRequest {
|
||||
address: "https://vault.example.com:8200".to_string(),
|
||||
auth_method: VaultAuthMethod::AppRole {
|
||||
role_id: "configure-role-id".to_string(),
|
||||
secret_id: "configure-approle-secret-id".to_string(),
|
||||
},
|
||||
auth_method: VaultAuthMethod::approle("configure-role-id".to_string(), "configure-approle-secret-id".to_string()),
|
||||
namespace: None,
|
||||
mount_path: Some("transit".to_string()),
|
||||
kv_mount: Some("secret".to_string()),
|
||||
|
||||
+1140
-69
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ use std::collections::HashMap;
|
||||
pub mod local;
|
||||
pub mod static_kms;
|
||||
pub mod vault;
|
||||
pub(crate) mod vault_credentials;
|
||||
pub mod vault_transit;
|
||||
|
||||
/// Abstract KMS client interface that all backends must implement
|
||||
|
||||
@@ -137,6 +137,8 @@ impl KmsClient for StaticKmsBackend {
|
||||
nonce: nonce_bytes.to_vec(),
|
||||
encryption_context: request.encryption_context.clone(),
|
||||
created_at: Zoned::now(),
|
||||
// The static backend has a single fixed key with no rotation.
|
||||
master_key_version: None,
|
||||
};
|
||||
let ciphertext = serde_json::to_vec(&envelope)?;
|
||||
|
||||
@@ -181,6 +183,8 @@ impl KmsClient for StaticKmsBackend {
|
||||
nonce: nonce_bytes.to_vec(),
|
||||
encryption_context: request.encryption_context.clone(),
|
||||
created_at: Zoned::now(),
|
||||
// The static backend has a single fixed key with no rotation.
|
||||
master_key_version: None,
|
||||
};
|
||||
let ciphertext = serde_json::to_vec(&envelope)?;
|
||||
|
||||
|
||||
+786
-129
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,10 @@
|
||||
|
||||
//! Vault Transit-based KMS backend.
|
||||
|
||||
use crate::backends::vault_credentials::{
|
||||
CredentialTaskHandle, VaultClientHandle, VaultConnectionSettings, VaultCredentialPolicy, VaultCredentialProvider,
|
||||
token_source_for,
|
||||
};
|
||||
use crate::backends::{BackendInfo, KmsBackend, KmsClient};
|
||||
use crate::config::{KmsConfig, VaultTransitConfig};
|
||||
use crate::encryption::{DataKeyEnvelope, generate_key_material};
|
||||
@@ -24,6 +28,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use jiff::Zoned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
use vaultrs::{
|
||||
@@ -33,7 +38,6 @@ use vaultrs::{
|
||||
CreateKeyRequestBuilder, DecryptDataRequestBuilder, EncryptDataRequestBuilder, UpdateKeyConfigurationRequestBuilder,
|
||||
},
|
||||
},
|
||||
client::{VaultClient, VaultClientSettingsBuilder},
|
||||
kv2,
|
||||
transit::{data, key},
|
||||
};
|
||||
@@ -128,7 +132,7 @@ impl From<TransitKeyMetadataPersisted> for TransitKeyMetadata {
|
||||
}
|
||||
|
||||
pub struct VaultTransitKmsClient {
|
||||
client: VaultClient,
|
||||
credentials: Arc<VaultCredentialProvider>,
|
||||
config: VaultTransitConfig,
|
||||
/// KV v2 mount path for persisting transit key metadata
|
||||
metadata_kv_mount: String,
|
||||
@@ -138,34 +142,23 @@ pub struct VaultTransitKmsClient {
|
||||
}
|
||||
|
||||
impl VaultTransitKmsClient {
|
||||
pub async fn new(config: VaultTransitConfig) -> Result<Self> {
|
||||
let mut settings_builder = VaultClientSettingsBuilder::default();
|
||||
settings_builder.address(&config.address);
|
||||
|
||||
let token = match &config.auth_method {
|
||||
crate::config::VaultAuthMethod::Token { token } => token.clone(),
|
||||
crate::config::VaultAuthMethod::AppRole { .. } => {
|
||||
return Err(KmsError::backend_error(
|
||||
"AppRole authentication not yet implemented. Please use token authentication.",
|
||||
));
|
||||
}
|
||||
/// Create a new Vault Transit KMS client
|
||||
///
|
||||
/// `kms_config` supplies the per-attempt timeout that caps every HTTP
|
||||
/// request issued through this client, plus the retry and fail-closed
|
||||
/// budgets for credential refresh.
|
||||
pub async fn new(config: VaultTransitConfig, kms_config: &KmsConfig) -> Result<Self> {
|
||||
let settings = VaultConnectionSettings {
|
||||
address: config.address.clone(),
|
||||
namespace: config.namespace.clone(),
|
||||
attempt_timeout: kms_config.effective_timeout(),
|
||||
};
|
||||
|
||||
settings_builder.token(&token);
|
||||
|
||||
if let Some(namespace) = &config.namespace {
|
||||
settings_builder.namespace(Some(namespace.clone()));
|
||||
}
|
||||
|
||||
let settings = settings_builder
|
||||
.build()
|
||||
.map_err(|e| KmsError::backend_error(format!("Failed to build Vault client settings: {e}")))?;
|
||||
|
||||
let client =
|
||||
VaultClient::new(settings).map_err(|e| KmsError::backend_error(format!("Failed to create Vault client: {e}")))?;
|
||||
let source = token_source_for(&config.auth_method, &settings)?;
|
||||
let policy = VaultCredentialPolicy::from_kms_config(kms_config, &config.auth_method);
|
||||
let credentials = Arc::new(VaultCredentialProvider::new(settings, source, policy).await?);
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
credentials,
|
||||
metadata_kv_mount: config.metadata_kv_mount.clone(),
|
||||
metadata_key_prefix: config.metadata_key_prefix.clone(),
|
||||
config,
|
||||
@@ -173,6 +166,15 @@ impl VaultTransitKmsClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Snapshot the authenticated Vault client for a single request.
|
||||
///
|
||||
/// Every Vault call takes its own snapshot so a credential rotation
|
||||
/// applies to subsequent calls without interrupting in-flight ones. Fails
|
||||
/// closed when the credentials could not be refreshed in time.
|
||||
fn vault(&self) -> Result<Arc<VaultClientHandle>> {
|
||||
self.credentials.current()
|
||||
}
|
||||
|
||||
fn canonicalize_context(encryption_context: &HashMap<String, String>) -> Result<Option<String>> {
|
||||
if encryption_context.is_empty() {
|
||||
return Ok(None);
|
||||
@@ -197,7 +199,7 @@ impl VaultTransitKmsClient {
|
||||
}
|
||||
|
||||
async fn read_transit_key(&self, key_id: &str) -> Result<vaultrs::api::transit::responses::ReadKeyResponse> {
|
||||
key::read(&self.client, &self.config.mount_path, key_id)
|
||||
key::read(&self.vault()?.client, &self.config.mount_path, key_id)
|
||||
.await
|
||||
.or_else(|e| Self::map_vault_error(key_id, e, "read"))
|
||||
}
|
||||
@@ -205,7 +207,7 @@ impl VaultTransitKmsClient {
|
||||
async fn create_transit_key(&self, key_id: &str) -> Result<()> {
|
||||
let mut builder = CreateKeyRequestBuilder::default();
|
||||
builder.key_type(KeyType::Aes256Gcm96);
|
||||
key::create(&self.client, &self.config.mount_path, key_id, Some(&mut builder))
|
||||
key::create(&self.vault()?.client, &self.config.mount_path, key_id, Some(&mut builder))
|
||||
.await
|
||||
.map_err(|e| KmsError::backend_error(format!("Failed to create Vault Transit key {key_id}: {e}")))
|
||||
}
|
||||
@@ -222,7 +224,7 @@ impl VaultTransitKmsClient {
|
||||
builder.associated_data(aad);
|
||||
}
|
||||
|
||||
let response = data::encrypt(&self.client, &self.config.mount_path, key_id, &plaintext_b64, Some(&mut builder))
|
||||
let response = data::encrypt(&self.vault()?.client, &self.config.mount_path, key_id, &plaintext_b64, Some(&mut builder))
|
||||
.await
|
||||
.map_err(|e| KmsError::backend_error(format!("Failed to encrypt data with Vault Transit key {key_id}: {e}")))?;
|
||||
|
||||
@@ -240,7 +242,7 @@ impl VaultTransitKmsClient {
|
||||
builder.associated_data(aad);
|
||||
}
|
||||
|
||||
let response = data::decrypt(&self.client, &self.config.mount_path, key_id, ciphertext, Some(&mut builder))
|
||||
let response = data::decrypt(&self.vault()?.client, &self.config.mount_path, key_id, ciphertext, Some(&mut builder))
|
||||
.await
|
||||
.map_err(|e| KmsError::backend_error(format!("Failed to decrypt data with Vault Transit key {key_id}: {e}")))?;
|
||||
|
||||
@@ -255,7 +257,7 @@ impl VaultTransitKmsClient {
|
||||
|
||||
async fn read_metadata_from_kv(&self, key_id: &str) -> Result<Option<TransitKeyMetadata>> {
|
||||
let path = self.metadata_key_path(key_id);
|
||||
match kv2::read::<TransitKeyMetadataPersisted>(&self.client, &self.metadata_kv_mount, &path).await {
|
||||
match kv2::read::<TransitKeyMetadataPersisted>(&self.vault()?.client, &self.metadata_kv_mount, &path).await {
|
||||
Ok(persisted) => Ok(Some(persisted.into())),
|
||||
Err(vaultrs::error::ClientError::ResponseWrapError)
|
||||
| Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(None),
|
||||
@@ -266,7 +268,7 @@ impl VaultTransitKmsClient {
|
||||
async fn write_metadata_to_kv(&self, key_id: &str, metadata: &TransitKeyMetadata) -> Result<()> {
|
||||
let path = self.metadata_key_path(key_id);
|
||||
let persisted: TransitKeyMetadataPersisted = metadata.clone().into();
|
||||
kv2::set(&self.client, &self.metadata_kv_mount, &path, &persisted)
|
||||
kv2::set(&self.vault()?.client, &self.metadata_kv_mount, &path, &persisted)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| KmsError::backend_error(format!("Failed to write transit key metadata to Vault KV: {e}")))
|
||||
@@ -274,7 +276,7 @@ impl VaultTransitKmsClient {
|
||||
|
||||
async fn delete_metadata_from_kv(&self, key_id: &str) -> Result<()> {
|
||||
let path = self.metadata_key_path(key_id);
|
||||
match kv2::delete_metadata(&self.client, &self.metadata_kv_mount, &path).await {
|
||||
match kv2::delete_metadata(&self.vault()?.client, &self.metadata_kv_mount, &path).await {
|
||||
Ok(_) => Ok(()),
|
||||
Err(vaultrs::error::ClientError::ResponseWrapError)
|
||||
| Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(()),
|
||||
@@ -299,10 +301,17 @@ impl VaultTransitKmsClient {
|
||||
return Ok(persisted);
|
||||
}
|
||||
|
||||
// Deliberate exemption from the "read paths never write" rule (rustfs#4256 /
|
||||
// rustfs#4262): transit keys created before metadata persistence existed have no
|
||||
// KV record at all, so failing closed here would brick every pre-existing transit
|
||||
// key. The synthesised record only describes metadata — key material lives solely
|
||||
// inside Vault's transit engine and is never generated or written by this path.
|
||||
//
|
||||
// Verify the transit key actually exists in Vault before synthesising.
|
||||
self.read_transit_key(key_id).await?;
|
||||
let metadata = TransitKeyMetadata::synthesized();
|
||||
// Persist the synthesised metadata so future cache misses pick it up.
|
||||
// Persist the synthesised metadata so future cache misses pick it up (best
|
||||
// effort: the KV write failing must not fail the read).
|
||||
let _ = self.write_metadata_to_kv(key_id, &metadata).await;
|
||||
self.metadata_cache.write().await.insert(key_id.to_string(), metadata.clone());
|
||||
Ok(metadata)
|
||||
@@ -391,6 +400,9 @@ impl KmsClient for VaultTransitKmsClient {
|
||||
nonce: Vec::new(),
|
||||
encryption_context: request.encryption_context.clone(),
|
||||
created_at: Zoned::now(),
|
||||
// Transit ciphertext already self-describes its key version
|
||||
// ("vault:vN:..."), so the envelope never carries one.
|
||||
master_key_version: None,
|
||||
};
|
||||
|
||||
let ciphertext = serde_json::to_vec(&envelope)?;
|
||||
@@ -478,7 +490,7 @@ impl KmsClient for VaultTransitKmsClient {
|
||||
}
|
||||
|
||||
async fn list_keys(&self, request: &ListKeysRequest, _context: Option<&OperationContext>) -> Result<ListKeysResponse> {
|
||||
let all_keys = key::list(&self.client, &self.config.mount_path)
|
||||
let all_keys = key::list(&self.vault()?.client, &self.config.mount_path)
|
||||
.await
|
||||
.map_err(|e| KmsError::backend_error(format!("Failed to list Vault Transit keys: {e}")))?
|
||||
.keys;
|
||||
@@ -548,7 +560,7 @@ impl KmsClient for VaultTransitKmsClient {
|
||||
}
|
||||
|
||||
async fn rotate_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<MasterKeyInfo> {
|
||||
key::rotate(&self.client, &self.config.mount_path, key_id)
|
||||
key::rotate(&self.vault()?.client, &self.config.mount_path, key_id)
|
||||
.await
|
||||
.map_err(|e| KmsError::backend_error(format!("Failed to rotate Vault Transit key {key_id}: {e}")))?;
|
||||
|
||||
@@ -571,7 +583,7 @@ impl KmsClient for VaultTransitKmsClient {
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<()> {
|
||||
key::list(&self.client, &self.config.mount_path)
|
||||
key::list(&self.vault()?.client, &self.config.mount_path)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| KmsError::backend_error(format!("Vault Transit health check failed: {e}")))
|
||||
@@ -607,9 +619,16 @@ impl VaultTransitKmsBackend {
|
||||
}
|
||||
};
|
||||
|
||||
let client = VaultTransitKmsClient::new(vault_config).await?;
|
||||
let client = VaultTransitKmsClient::new(vault_config, &config).await?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
|
||||
/// Spawn the background credential renewal task for this backend, if its
|
||||
/// auth method issues lease-bound tokens. The caller owns the returned
|
||||
/// handle; dropping it cancels the task.
|
||||
pub(crate) fn spawn_credential_renewal(&self) -> Option<CredentialTaskHandle> {
|
||||
self.client.credentials.spawn_renewal_task()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -692,13 +711,18 @@ impl KmsBackend for VaultTransitKmsBackend {
|
||||
if !self.client.read_transit_key(&key_id).await?.deletion_allowed {
|
||||
let mut update_builder = UpdateKeyConfigurationRequestBuilder::default();
|
||||
update_builder.deletion_allowed(true);
|
||||
key::update(&self.client.client, &self.client.config.mount_path, &key_id, Some(&mut update_builder))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
KmsError::backend_error(format!("Failed to allow deletion of Vault Transit key {key_id}: {e}"))
|
||||
})?;
|
||||
key::update(
|
||||
&self.client.vault()?.client,
|
||||
&self.client.config.mount_path,
|
||||
&key_id,
|
||||
Some(&mut update_builder),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
KmsError::backend_error(format!("Failed to allow deletion of Vault Transit key {key_id}: {e}"))
|
||||
})?;
|
||||
}
|
||||
key::delete(&self.client.client, &self.client.config.mount_path, &key_id)
|
||||
key::delete(&self.client.vault()?.client, &self.client.config.mount_path, &key_id)
|
||||
.await
|
||||
.map_err(|e| KmsError::backend_error(format!("Failed to delete Vault Transit key {key_id}: {e}")))?;
|
||||
self.client.delete_key_metadata(&key_id).await?;
|
||||
@@ -788,7 +812,7 @@ mod tests {
|
||||
let config = test_vault_transit_config();
|
||||
|
||||
// --- First "process": create a key and disable it ---
|
||||
let client1 = VaultTransitKmsClient::new(config.clone())
|
||||
let client1 = VaultTransitKmsClient::new(config.clone(), &KmsConfig::default())
|
||||
.await
|
||||
.expect("Failed to create VaultTransit client");
|
||||
|
||||
@@ -811,7 +835,7 @@ mod tests {
|
||||
assert_eq!(info_after_disable.status, KeyStatus::Disabled, "key must be Disabled after disable_key");
|
||||
|
||||
// --- Simulate restart: create a brand new client with empty cache ---
|
||||
let client2 = VaultTransitKmsClient::new(config)
|
||||
let client2 = VaultTransitKmsClient::new(config, &KmsConfig::default())
|
||||
.await
|
||||
.expect("Failed to create second VaultTransit client (restart simulation)");
|
||||
|
||||
@@ -841,7 +865,7 @@ mod tests {
|
||||
async fn test_transit_pending_deletion_survives_restart_simulation() {
|
||||
let config = test_vault_transit_config();
|
||||
|
||||
let client1 = VaultTransitKmsClient::new(config.clone())
|
||||
let client1 = VaultTransitKmsClient::new(config.clone(), &KmsConfig::default())
|
||||
.await
|
||||
.expect("Failed to create VaultTransit client");
|
||||
|
||||
@@ -865,7 +889,7 @@ mod tests {
|
||||
"key must be PendingDeletion after schedule_key_deletion"
|
||||
);
|
||||
|
||||
let client2 = VaultTransitKmsClient::new(config)
|
||||
let client2 = VaultTransitKmsClient::new(config, &KmsConfig::default())
|
||||
.await
|
||||
.expect("Failed to create second VaultTransit client (restart simulation)");
|
||||
|
||||
@@ -897,4 +921,70 @@ mod tests {
|
||||
"after restart, a pending-deletion key must not be usable for new data keys"
|
||||
);
|
||||
}
|
||||
|
||||
/// Contract regression for rustfs/backlog#1565.
|
||||
///
|
||||
/// Transit rotation is delegated entirely to Vault's own key versioning: the
|
||||
/// ciphertext self-describes the wrapping version ("vault:vN:..."), so historical
|
||||
/// ciphertext must keep decrypting after rotation without any RustFS-side
|
||||
/// version bookkeeping in the envelope.
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires a running Vault instance with transit engine enabled
|
||||
async fn test_transit_old_ciphertext_decrypts_after_rotate() {
|
||||
let client = VaultTransitKmsClient::new(test_vault_transit_config(), &KmsConfig::default())
|
||||
.await
|
||||
.expect("Failed to create VaultTransit client");
|
||||
|
||||
let key_id = format!("regression-1565-rotate-{}", uuid::Uuid::new_v4());
|
||||
client.create_key(&key_id, "AES_256", None).await.expect("create_key");
|
||||
|
||||
let request = GenerateKeyRequest {
|
||||
master_key_id: key_id.clone(),
|
||||
key_spec: "AES_256".to_string(),
|
||||
key_length: Some(32),
|
||||
encryption_context: HashMap::new(),
|
||||
grant_tokens: Vec::new(),
|
||||
};
|
||||
|
||||
let dk_v1 = client.generate_data_key(&request, None).await.expect("generate under v1");
|
||||
let env_v1: DataKeyEnvelope = serde_json::from_slice(&dk_v1.ciphertext).expect("parse v1 envelope");
|
||||
assert!(
|
||||
env_v1.encrypted_key.starts_with(b"vault:v1:"),
|
||||
"first-version Transit ciphertext must carry the vault:v1: prefix"
|
||||
);
|
||||
assert_eq!(
|
||||
env_v1.master_key_version, None,
|
||||
"Transit envelopes must not carry a RustFS-side master key version"
|
||||
);
|
||||
|
||||
let rotated = client.rotate_key(&key_id, None).await.expect("rotate_key");
|
||||
assert_eq!(rotated.version, 2, "rotation must advance the Transit key version");
|
||||
|
||||
let dk_v2 = client.generate_data_key(&request, None).await.expect("generate under v2");
|
||||
let env_v2: DataKeyEnvelope = serde_json::from_slice(&dk_v2.ciphertext).expect("parse v2 envelope");
|
||||
assert!(
|
||||
env_v2.encrypted_key.starts_with(b"vault:v2:"),
|
||||
"post-rotation Transit ciphertext must carry the vault:v2: prefix"
|
||||
);
|
||||
|
||||
// Historical ciphertext keeps decrypting per Vault's version semantics,
|
||||
// interleaved with post-rotation ciphertext.
|
||||
for (data_key, label) in [(&dk_v1, "v1"), (&dk_v2, "v2"), (&dk_v1, "v1 again")] {
|
||||
let plaintext = client
|
||||
.decrypt(
|
||||
&DecryptRequest {
|
||||
ciphertext: data_key.ciphertext.clone(),
|
||||
encryption_context: HashMap::new(),
|
||||
grant_tokens: Vec::new(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{label} ciphertext must stay decryptable after rotation: {error}"));
|
||||
assert_eq!(Some(plaintext), data_key.plaintext, "{label} plaintext must round-trip");
|
||||
}
|
||||
|
||||
// Cleanup so repeated runs against the same Vault do not accumulate keys.
|
||||
let _ = client.schedule_key_deletion(&key_id, 7, None).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// 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.
|
||||
|
||||
//! Backup responsibility matrix: what a RustFS backup bundle owns per backend.
|
||||
//!
|
||||
//! The matrix is two-dimensional on purpose: responsibility is a function of
|
||||
//! the backend *and* its at-rest protection state, not of the backend alone.
|
||||
//! This keeps the schema stable when a backend changes protection direction —
|
||||
//! switching Vault KV2 between storage-only and Transit-wrapped operation
|
||||
//! selects a different existing row instead of changing the contract.
|
||||
//!
|
||||
//! These enums are backup-domain contract types. Once the backlog#1571
|
||||
//! capability-discovery contract lands, the discovery surface is expected to
|
||||
//! converge on (or map onto) the states defined here.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Backend discriminant recorded in a backup manifest.
|
||||
///
|
||||
/// Wire names are aligned with [`crate::config::BackendConfig`] and
|
||||
/// [`crate::config::KmsBackend`] (including the legacy `Vault` alias) so that
|
||||
/// a manifest and a persisted KMS configuration never disagree about how the
|
||||
/// same backend is spelled.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BackupBackendKind {
|
||||
/// Local file-based backend.
|
||||
Local,
|
||||
/// Vault KV v2 storage backend.
|
||||
#[serde(rename = "VaultKV2", alias = "Vault")]
|
||||
VaultKv2,
|
||||
/// Vault Transit backend.
|
||||
VaultTransit,
|
||||
/// Static single-key backend.
|
||||
Static,
|
||||
}
|
||||
|
||||
/// At-rest protection state of master key material, as observed at snapshot
|
||||
/// time.
|
||||
///
|
||||
/// The first three states mirror the Local backend's on-disk protection
|
||||
/// marker (`StoredKeyProtection` in `backends/local.rs`, kebab-case wire
|
||||
/// names). The remaining states describe the non-local backends.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum AtRestProtection {
|
||||
/// Local key files AEAD-encrypted under the Argon2id-derived master key.
|
||||
EncryptedMasterKey,
|
||||
/// Local development-only plaintext key files. Such files must never
|
||||
/// enter a bundle as-is; bundle artifacts are always re-wrapped under the
|
||||
/// backup KEK.
|
||||
PlaintextDevOnly,
|
||||
/// Pre-beta.9 local key files without a protection marker; the effective
|
||||
/// mode is resolved at read time. Treated like [`Self::PlaintextDevOnly`]
|
||||
/// for bundling purposes: re-wrap is mandatory.
|
||||
LegacyUnspecified,
|
||||
/// Vault KV2 as currently shipped: material confidentiality relies on
|
||||
/// Vault ACLs, KV2 at-rest encryption, and TLS only (the backend reports
|
||||
/// `at_rest_protection = "vault-kv2-acl"`); RustFS applies no
|
||||
/// cryptographic wrapping of its own.
|
||||
StorageOnly,
|
||||
/// Vault KV2 with material wrapped by Vault Transit before storage. Not
|
||||
/// produced by any current backend; the row exists so a future direction
|
||||
/// change selects a state instead of changing the schema.
|
||||
TransitWrapped,
|
||||
/// Vault Transit: the cryptographic root lives in Vault and is not
|
||||
/// exportable. RustFS can only ever own metadata and references.
|
||||
ExternalNonExportable,
|
||||
/// Static backend: the secret is delivered externally at startup and
|
||||
/// RustFS persists no key material at all.
|
||||
ExternalSecretDelivery,
|
||||
}
|
||||
|
||||
/// What a RustFS backup bundle is responsible for, per (backend, protection)
|
||||
/// combination.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum BackupResponsibility {
|
||||
/// The bundle carries the complete recoverable state: encrypted key
|
||||
/// material for every version, salt, metadata, and configuration.
|
||||
///
|
||||
/// Restore precondition for the Local backend: the operator re-supplies
|
||||
/// the master key out of band. The master key itself is outside the
|
||||
/// backup domain; the manifest stores at most an opaque verifier.
|
||||
FullMaterial,
|
||||
/// The bundle carries only non-sensitive references and verification
|
||||
/// information. The source of truth is external secret delivery and the
|
||||
/// operator re-provides the secret during restore. Embedding the secret
|
||||
/// itself in a bundle is forbidden.
|
||||
ReferenceOnly,
|
||||
/// The bundle carries RustFS-side metadata, configuration references and
|
||||
/// verification data, while the cryptographic root is protected by the
|
||||
/// external system's native snapshot/disaster-recovery flow (Vault/HSM).
|
||||
/// Restore must re-establish the external trust root first.
|
||||
MetadataPlusExternalRoot,
|
||||
}
|
||||
|
||||
impl BackupResponsibility {
|
||||
/// Resolve the responsibility matrix for one (backend, protection) cell.
|
||||
///
|
||||
/// Returns `None` for combinations that no supported deployment can
|
||||
/// produce; manifests declaring such a combination are rejected as
|
||||
/// corrupted. This function is total and the unit tests anchor every
|
||||
/// cell, so any change to the matrix is a deliberate contract change.
|
||||
pub fn for_backend(backend: BackupBackendKind, protection: AtRestProtection) -> Option<Self> {
|
||||
use AtRestProtection::*;
|
||||
use BackupBackendKind::*;
|
||||
|
||||
match (backend, protection) {
|
||||
(Local, EncryptedMasterKey | PlaintextDevOnly | LegacyUnspecified) => Some(Self::FullMaterial),
|
||||
(Local, _) => None,
|
||||
// Storage-only KV2 offers no external cryptographic root, so the
|
||||
// bundle must own the material (re-wrapped under the backup KEK).
|
||||
(VaultKv2, StorageOnly) => Some(Self::FullMaterial),
|
||||
(VaultKv2, TransitWrapped) => Some(Self::MetadataPlusExternalRoot),
|
||||
(VaultKv2, _) => None,
|
||||
(VaultTransit, ExternalNonExportable) => Some(Self::MetadataPlusExternalRoot),
|
||||
(VaultTransit, _) => None,
|
||||
(Static, ExternalSecretDelivery) => Some(Self::ReferenceOnly),
|
||||
(Static, _) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::KmsBackend;
|
||||
|
||||
fn json<T: Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value).expect("serialization should succeed")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_kind_wire_names_match_kms_backend() {
|
||||
let pairs = [
|
||||
(BackupBackendKind::Local, KmsBackend::Local),
|
||||
(BackupBackendKind::VaultKv2, KmsBackend::VaultKv2),
|
||||
(BackupBackendKind::VaultTransit, KmsBackend::VaultTransit),
|
||||
(BackupBackendKind::Static, KmsBackend::Static),
|
||||
];
|
||||
for (backup_kind, config_kind) in pairs {
|
||||
assert_eq!(json(&backup_kind), json(&config_kind), "wire name drifted for {backup_kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_kind_accepts_legacy_vault_alias() {
|
||||
let decoded: BackupBackendKind = serde_json::from_str("\"Vault\"").expect("legacy alias should decode");
|
||||
assert_eq!(decoded, BackupBackendKind::VaultKv2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responsibility_matrix_is_anchored_cell_by_cell() {
|
||||
use AtRestProtection::*;
|
||||
use BackupBackendKind::*;
|
||||
use BackupResponsibility::*;
|
||||
|
||||
// Every (backend, protection) cell, exhaustively. Changing any row is
|
||||
// a contract change and must be made here consciously.
|
||||
let matrix = [
|
||||
(Local, EncryptedMasterKey, Some(FullMaterial)),
|
||||
(Local, PlaintextDevOnly, Some(FullMaterial)),
|
||||
(Local, LegacyUnspecified, Some(FullMaterial)),
|
||||
(Local, StorageOnly, None),
|
||||
(Local, TransitWrapped, None),
|
||||
(Local, ExternalNonExportable, None),
|
||||
(Local, ExternalSecretDelivery, None),
|
||||
(VaultKv2, EncryptedMasterKey, None),
|
||||
(VaultKv2, PlaintextDevOnly, None),
|
||||
(VaultKv2, LegacyUnspecified, None),
|
||||
(VaultKv2, StorageOnly, Some(FullMaterial)),
|
||||
(VaultKv2, TransitWrapped, Some(MetadataPlusExternalRoot)),
|
||||
(VaultKv2, ExternalNonExportable, None),
|
||||
(VaultKv2, ExternalSecretDelivery, None),
|
||||
(VaultTransit, EncryptedMasterKey, None),
|
||||
(VaultTransit, PlaintextDevOnly, None),
|
||||
(VaultTransit, LegacyUnspecified, None),
|
||||
(VaultTransit, StorageOnly, None),
|
||||
(VaultTransit, TransitWrapped, None),
|
||||
(VaultTransit, ExternalNonExportable, Some(MetadataPlusExternalRoot)),
|
||||
(VaultTransit, ExternalSecretDelivery, None),
|
||||
(Static, EncryptedMasterKey, None),
|
||||
(Static, PlaintextDevOnly, None),
|
||||
(Static, LegacyUnspecified, None),
|
||||
(Static, StorageOnly, None),
|
||||
(Static, TransitWrapped, None),
|
||||
(Static, ExternalNonExportable, None),
|
||||
(Static, ExternalSecretDelivery, Some(ReferenceOnly)),
|
||||
];
|
||||
assert_eq!(matrix.len(), 28, "matrix must stay exhaustive: 4 backends x 7 protection states");
|
||||
for (backend, protection, expected) in matrix {
|
||||
assert_eq!(
|
||||
BackupResponsibility::for_backend(backend, protection),
|
||||
expected,
|
||||
"matrix cell drifted for ({backend:?}, {protection:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_protection_wire_names_match_stored_key_protection() {
|
||||
use crate::backends::local::StoredKeyProtection;
|
||||
|
||||
// The manifest must record exactly the marker values the Local
|
||||
// backend writes to disk, or a restore could misread protection.
|
||||
let pairs = [
|
||||
(AtRestProtection::EncryptedMasterKey, StoredKeyProtection::EncryptedMasterKey),
|
||||
(AtRestProtection::PlaintextDevOnly, StoredKeyProtection::PlaintextDevOnly),
|
||||
(AtRestProtection::LegacyUnspecified, StoredKeyProtection::LegacyUnspecified),
|
||||
];
|
||||
for (backup_state, stored_state) in pairs {
|
||||
assert_eq!(json(&backup_state), json(&stored_state), "wire name drifted for {backup_state:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responsibility_wire_names_are_frozen() {
|
||||
assert_eq!(json(&BackupResponsibility::FullMaterial), "\"full-material\"");
|
||||
assert_eq!(json(&BackupResponsibility::ReferenceOnly), "\"reference-only\"");
|
||||
assert_eq!(json(&BackupResponsibility::MetadataPlusExternalRoot), "\"metadata-plus-external-root\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
// 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.
|
||||
|
||||
//! Restore dry-run report contract.
|
||||
//!
|
||||
//! A restore dry-run is a zero-write preflight: it evaluates a bundle against
|
||||
//! a target and reports every blocker, conflict, and external dependency
|
||||
//! mismatch without modifying the target in any way. The report itself is
|
||||
//! plain data — producing, serializing, or discarding it has no side effects,
|
||||
//! and an implementation that writes anything during a dry-run violates this
|
||||
//! contract. All values in a report are identifiers and references; secrets,
|
||||
//! tokens, and key material never appear in it.
|
||||
|
||||
use crate::backup::error::BackupError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Machine-readable category of a restore blocker.
|
||||
///
|
||||
/// The first six codes mirror the [`BackupError`] variants; the remaining
|
||||
/// codes cover preflight conditions that are not bundle defects.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum RestoreBlockerCode {
|
||||
/// The bundle failed structural or integrity validation.
|
||||
BundleCorrupted,
|
||||
/// The manifest input ended prematurely.
|
||||
BundleTruncated,
|
||||
/// The manifest format version is unknown to this build.
|
||||
UnknownFormatVersion,
|
||||
/// The supplied backup KEK does not match the bundle's KEK.
|
||||
WrongBackupKek,
|
||||
/// A required artifact is absent from the bundle.
|
||||
MissingArtifact,
|
||||
/// The bundle has no completeness marker or is marked in-progress.
|
||||
IncompleteBundle,
|
||||
/// The target backend cannot satisfy the bundle's responsibility model.
|
||||
UnsupportedBackend,
|
||||
/// The bundle was produced by a different deployment than the target and
|
||||
/// no explicit cross-deployment authorization applies.
|
||||
DeploymentMismatch,
|
||||
/// An external dependency (Vault cluster, mount, Transit key, ...) that
|
||||
/// the bundle references is unreachable or missing.
|
||||
ExternalDependencyUnavailable,
|
||||
}
|
||||
|
||||
/// One condition that forbids the restore outright.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RestoreBlocker {
|
||||
/// Machine-readable category.
|
||||
pub code: RestoreBlockerCode,
|
||||
/// Human-readable detail. Identifiers only; never secrets or material.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
impl From<&BackupError> for RestoreBlocker {
|
||||
fn from(error: &BackupError) -> Self {
|
||||
let code = match error {
|
||||
BackupError::Corrupted { .. } => RestoreBlockerCode::BundleCorrupted,
|
||||
BackupError::Truncated { .. } => RestoreBlockerCode::BundleTruncated,
|
||||
BackupError::UnknownVersion { .. } => RestoreBlockerCode::UnknownFormatVersion,
|
||||
BackupError::WrongKek { .. } => RestoreBlockerCode::WrongBackupKek,
|
||||
BackupError::MissingArtifact { .. } => RestoreBlockerCode::MissingArtifact,
|
||||
BackupError::IncompleteBundle { .. } => RestoreBlockerCode::IncompleteBundle,
|
||||
};
|
||||
Self {
|
||||
code,
|
||||
detail: error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kind of a conflict between bundle state and existing target state.
|
||||
///
|
||||
/// Restore is non-destructive by default: every conflict blocks the restore
|
||||
/// unless an explicit, audited conflict policy resolves it. Silent overwrite
|
||||
/// or merge is never an option.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum RestoreConflictKind {
|
||||
/// The target already has a key with this stable id.
|
||||
KeyAlreadyExists,
|
||||
/// Restoring would lower a key version the target has already observed.
|
||||
VersionRegression,
|
||||
/// Restoring would lower the snapshot generation the target has already
|
||||
/// observed.
|
||||
GenerationRegression,
|
||||
/// Restoring would revive a key the target has deleted or scheduled for
|
||||
/// deletion.
|
||||
StateRegression,
|
||||
}
|
||||
|
||||
/// One conflict with existing target state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RestoreConflict {
|
||||
/// Stable key id the conflict concerns.
|
||||
pub key_id: String,
|
||||
/// Machine-readable category.
|
||||
pub kind: RestoreConflictKind,
|
||||
/// Human-readable detail. Identifiers only; never secrets or material.
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// A mismatch between an external dependency reference recorded in the bundle
|
||||
/// and what the target environment observes.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalDependencyMismatch {
|
||||
/// Which dependency is affected (for example a Vault mount or Transit
|
||||
/// key name). References only; never credentials.
|
||||
pub dependency: String,
|
||||
/// The value the bundle recorded.
|
||||
pub expected: String,
|
||||
/// The value the target environment reports.
|
||||
pub observed: String,
|
||||
}
|
||||
|
||||
/// Result of a restore dry-run preflight.
|
||||
///
|
||||
/// # Zero-write contract
|
||||
///
|
||||
/// A dry-run must not write to the target: no staging directories, no
|
||||
/// repaired records, no metadata fixes triggered along the read path. The
|
||||
/// report is pure data over values already known to the caller.
|
||||
///
|
||||
/// ```
|
||||
/// use rustfs_kms::backup::{RestoreBlocker, RestoreBlockerCode, RestoreDryRunReport};
|
||||
///
|
||||
/// let clean = RestoreDryRunReport {
|
||||
/// backup_id: "backup-0001".to_string(),
|
||||
/// target_deployment_identity: "deployment-a".to_string(),
|
||||
/// blockers: Vec::new(),
|
||||
/// conflicts: Vec::new(),
|
||||
/// external_mismatches: Vec::new(),
|
||||
/// };
|
||||
/// assert!(clean.restore_permitted());
|
||||
///
|
||||
/// let blocked = RestoreDryRunReport {
|
||||
/// blockers: vec![RestoreBlocker {
|
||||
/// code: RestoreBlockerCode::IncompleteBundle,
|
||||
/// detail: "manifest has no completeness marker".to_string(),
|
||||
/// }],
|
||||
/// ..clean
|
||||
/// };
|
||||
/// assert!(!blocked.restore_permitted());
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RestoreDryRunReport {
|
||||
/// Identifier of the evaluated bundle.
|
||||
pub backup_id: String,
|
||||
/// Identity of the restore target the bundle was evaluated against.
|
||||
pub target_deployment_identity: String,
|
||||
/// Conditions that forbid the restore outright.
|
||||
pub blockers: Vec<RestoreBlocker>,
|
||||
/// Conflicts with existing target state.
|
||||
pub conflicts: Vec<RestoreConflict>,
|
||||
/// External dependency mismatches.
|
||||
pub external_mismatches: Vec<ExternalDependencyMismatch>,
|
||||
}
|
||||
|
||||
impl RestoreDryRunReport {
|
||||
/// Whether the restore may proceed: true only when the preflight found
|
||||
/// no blockers, no conflicts, and no external dependency mismatches.
|
||||
pub fn restore_permitted(&self) -> bool {
|
||||
self.blockers.is_empty() && self.conflicts.is_empty() && self.external_mismatches.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_report() -> RestoreDryRunReport {
|
||||
RestoreDryRunReport {
|
||||
backup_id: "backup-0001".to_string(),
|
||||
target_deployment_identity: "deployment-b".to_string(),
|
||||
blockers: vec![RestoreBlocker {
|
||||
code: RestoreBlockerCode::DeploymentMismatch,
|
||||
detail: "bundle was produced by deployment-a".to_string(),
|
||||
}],
|
||||
conflicts: vec![RestoreConflict {
|
||||
key_id: "object-key".to_string(),
|
||||
kind: RestoreConflictKind::VersionRegression,
|
||||
detail: "target observed version 5, bundle carries version 3".to_string(),
|
||||
}],
|
||||
external_mismatches: vec![ExternalDependencyMismatch {
|
||||
dependency: "vault transit key rustfs-master".to_string(),
|
||||
expected: "min_version=2".to_string(),
|
||||
observed: "min_version=4".to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_round_trips_through_json() {
|
||||
let report = sample_report();
|
||||
let json = serde_json::to_string(&report).expect("serialization should succeed");
|
||||
let decoded: RestoreDryRunReport = serde_json::from_str(&json).expect("deserialization should succeed");
|
||||
assert_eq!(decoded, report);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_permitted_requires_every_section_empty() {
|
||||
assert!(!sample_report().restore_permitted());
|
||||
|
||||
let clean = RestoreDryRunReport {
|
||||
blockers: Vec::new(),
|
||||
conflicts: Vec::new(),
|
||||
external_mismatches: Vec::new(),
|
||||
..sample_report()
|
||||
};
|
||||
assert!(clean.restore_permitted());
|
||||
|
||||
for section in 0..3 {
|
||||
let mut report = clean.clone();
|
||||
match section {
|
||||
0 => report.blockers = sample_report().blockers,
|
||||
1 => report.conflicts = sample_report().conflicts,
|
||||
_ => report.external_mismatches = sample_report().external_mismatches,
|
||||
}
|
||||
assert!(!report.restore_permitted(), "section {section} alone must block the restore");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_backup_error_maps_to_a_blocker_code() {
|
||||
let cases = [
|
||||
(BackupError::corrupted("x"), RestoreBlockerCode::BundleCorrupted),
|
||||
(BackupError::truncated("x"), RestoreBlockerCode::BundleTruncated),
|
||||
(
|
||||
BackupError::UnknownVersion { found: 2, supported: 1 },
|
||||
RestoreBlockerCode::UnknownFormatVersion,
|
||||
),
|
||||
(
|
||||
BackupError::WrongKek {
|
||||
required_kek_id: "a".to_string(),
|
||||
required_kek_version: 1,
|
||||
supplied_kek_id: "b".to_string(),
|
||||
supplied_kek_version: 1,
|
||||
},
|
||||
RestoreBlockerCode::WrongBackupKek,
|
||||
),
|
||||
(BackupError::missing_artifact("key-material"), RestoreBlockerCode::MissingArtifact),
|
||||
(BackupError::incomplete_bundle("x"), RestoreBlockerCode::IncompleteBundle),
|
||||
];
|
||||
for (error, expected_code) in cases {
|
||||
let blocker = RestoreBlocker::from(&error);
|
||||
assert_eq!(blocker.code, expected_code, "wrong code for {error:?}");
|
||||
assert_eq!(blocker.detail, error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The zero-write contract in practice: a report is plain serializable
|
||||
/// data with no handles, no I/O, and no drop side effects.
|
||||
#[test]
|
||||
fn report_types_are_plain_data() {
|
||||
fn assert_plain_data<T>()
|
||||
where
|
||||
T: serde::Serialize + serde::de::DeserializeOwned + Clone + PartialEq + std::fmt::Debug + Send + Sync + 'static,
|
||||
{
|
||||
}
|
||||
assert_plain_data::<RestoreDryRunReport>();
|
||||
assert_plain_data::<RestoreBlocker>();
|
||||
assert_plain_data::<RestoreConflict>();
|
||||
assert_plain_data::<ExternalDependencyMismatch>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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.
|
||||
|
||||
//! Typed failures for the backup/restore bundle contract.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Typed failures raised while decoding or validating a backup bundle.
|
||||
///
|
||||
/// Every variant is a fail-closed condition: a restore surface observing any
|
||||
/// of them must abort before touching target state. Messages carry only
|
||||
/// identifiers (backup ids, KEK ids, artifact kinds and paths) — never key
|
||||
/// material, bundle plaintext, or credentials.
|
||||
#[derive(Error, Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BackupError {
|
||||
/// Manifest or bundle content fails structural, schema, or integrity
|
||||
/// validation (unknown fields, duplicate fields, digest mismatch,
|
||||
/// contradictory responsibility declarations, ...).
|
||||
#[error("backup bundle corrupted: {reason}")]
|
||||
Corrupted { reason: String },
|
||||
|
||||
/// Input ended before a complete manifest could be decoded.
|
||||
#[error("backup manifest truncated: {reason}")]
|
||||
Truncated { reason: String },
|
||||
|
||||
/// Manifest declares a format version this build does not understand.
|
||||
/// Unknown versions are always rejected; there is no best-effort read.
|
||||
#[error("unknown backup manifest format version {found} (this build supports version {supported})")]
|
||||
UnknownVersion { found: u32, supported: u32 },
|
||||
|
||||
/// Bundle is protected by a different backup KEK than the one supplied.
|
||||
#[error(
|
||||
"backup bundle requires KEK '{required_kek_id}' version {required_kek_version}; \
|
||||
supplied KEK '{supplied_kek_id}' version {supplied_kek_version} cannot open it"
|
||||
)]
|
||||
WrongKek {
|
||||
required_kek_id: String,
|
||||
required_kek_version: u32,
|
||||
supplied_kek_id: String,
|
||||
supplied_kek_version: u32,
|
||||
},
|
||||
|
||||
/// Manifest requires an artifact that is not present in the bundle.
|
||||
#[error("backup bundle is missing a required artifact: {artifact}")]
|
||||
MissingArtifact { artifact: String },
|
||||
|
||||
/// Bundle has no completeness marker or records an in-progress state.
|
||||
/// A bundle that never reached its completeness marker must never be
|
||||
/// restored, regardless of how much of it is readable.
|
||||
#[error("backup bundle is incomplete ({reason}); incomplete bundles must never be restored")]
|
||||
IncompleteBundle { reason: String },
|
||||
}
|
||||
|
||||
impl BackupError {
|
||||
/// Create a corrupted-bundle error.
|
||||
pub fn corrupted<S: Into<String>>(reason: S) -> Self {
|
||||
Self::Corrupted { reason: reason.into() }
|
||||
}
|
||||
|
||||
/// Create a truncated-manifest error.
|
||||
pub fn truncated<S: Into<String>>(reason: S) -> Self {
|
||||
Self::Truncated { reason: reason.into() }
|
||||
}
|
||||
|
||||
/// Create an incomplete-bundle error.
|
||||
pub fn incomplete_bundle<S: Into<String>>(reason: S) -> Self {
|
||||
Self::IncompleteBundle { reason: reason.into() }
|
||||
}
|
||||
|
||||
/// Create a missing-artifact error.
|
||||
pub fn missing_artifact<S: Into<String>>(artifact: S) -> Self {
|
||||
Self::MissingArtifact {
|
||||
artifact: artifact.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::KmsError;
|
||||
|
||||
#[test]
|
||||
fn backup_errors_convert_into_kms_error_transparently() {
|
||||
let error = BackupError::corrupted("manifest digest mismatch");
|
||||
let kms_error: KmsError = error.clone().into();
|
||||
assert_eq!(kms_error.to_string(), error.to_string());
|
||||
assert!(matches!(kms_error, KmsError::Backup(inner) if inner == error));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_messages_carry_identifiers_only() {
|
||||
// The display strings must stay descriptive without ever embedding
|
||||
// material or bundle plaintext; each variant only interpolates the
|
||||
// identifiers below.
|
||||
let wrong_kek = BackupError::WrongKek {
|
||||
required_kek_id: "backup-kek-1".to_string(),
|
||||
required_kek_version: 3,
|
||||
supplied_kek_id: "backup-kek-2".to_string(),
|
||||
supplied_kek_version: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
wrong_kek.to_string(),
|
||||
"backup bundle requires KEK 'backup-kek-1' version 3; supplied KEK 'backup-kek-2' version 1 cannot open it"
|
||||
);
|
||||
|
||||
let unknown = BackupError::UnknownVersion { found: 9, supported: 1 };
|
||||
assert_eq!(
|
||||
unknown.to_string(),
|
||||
"unknown backup manifest format version 9 (this build supports version 1)"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
BackupError::missing_artifact("key-material").to_string(),
|
||||
"backup bundle is missing a required artifact: key-material"
|
||||
);
|
||||
assert_eq!(
|
||||
BackupError::incomplete_bundle("manifest has no completeness marker").to_string(),
|
||||
"backup bundle is incomplete (manifest has no completeness marker); incomplete bundles must never be restored"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
// 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.
|
||||
|
||||
//! Backup/restore contract types for KMS state.
|
||||
//!
|
||||
//! This module is contract-only: it defines the versioned backup manifest,
|
||||
//! the per-backend responsibility matrix, typed failure modes, and the
|
||||
//! restore dry-run report. Nothing here is wired into handlers or backends;
|
||||
//! backup export, restore orchestration, and the admin API build on these
|
||||
//! types in follow-up changes.
|
||||
//!
|
||||
//! # Bundle model
|
||||
//!
|
||||
//! A backup bundle is a set of AEAD-encrypted artifacts described by a single
|
||||
//! [`BackupManifest`]. All state in a bundle belongs to one snapshot
|
||||
//! generation — there is no partially consistent bundle. The bundle is
|
||||
//! protected by a backup KEK that is deliberately outside the business KMS
|
||||
//! trust hierarchy, and the manifest is sealed with a completeness marker and
|
||||
//! a final digest; a bundle that never reached its marker is permanently
|
||||
//! non-restorable.
|
||||
//!
|
||||
//! # Restore ordering
|
||||
//!
|
||||
//! Restore implementations must follow this order: re-establish the external
|
||||
//! trust root first (Vault/HSM native restore where one exists), then
|
||||
//! material and version records into staging, then metadata and
|
||||
//! configuration, then verification, and only then an explicit atomic
|
||||
//! cutover. A dry-run ([`RestoreDryRunReport`]) performs zero writes.
|
||||
//!
|
||||
//! # Deliberately unfrozen
|
||||
//!
|
||||
//! Fields whose shape depends on contracts still in flight are reserved
|
||||
//! rather than guessed (see [`ReservedSlot`]): the per-key version inventory
|
||||
//! (backlog#1565) and capability discovery (backlog#1571). Alias and policy
|
||||
//! artifacts are reserved names for features that do not exist yet. Reserved
|
||||
//! slots reject data in format version 1 and become real types in a later
|
||||
//! format version.
|
||||
|
||||
mod capability;
|
||||
mod dry_run;
|
||||
mod error;
|
||||
mod manifest;
|
||||
|
||||
pub use capability::{AtRestProtection, BackupBackendKind, BackupResponsibility};
|
||||
pub use dry_run::{
|
||||
ExternalDependencyMismatch, RestoreBlocker, RestoreBlockerCode, RestoreConflict, RestoreConflictKind, RestoreDryRunReport,
|
||||
};
|
||||
pub use error::BackupError;
|
||||
pub use manifest::{
|
||||
AeadAlgorithm, ArtifactDescriptor, ArtifactKind, BackupKekDescriptor, BackupManifest, CompletenessState, ContentDigest,
|
||||
DigestAlgorithm, LocalKdfDescriptor, LocalKeyDerivation, ReservedSlot,
|
||||
};
|
||||
+577
-20
@@ -29,8 +29,23 @@ pub const ENV_KMS_VAULT_TRANSIT_METADATA_KV_MOUNT: &str = "RUSTFS_KMS_VAULT_TRAN
|
||||
pub const ENV_KMS_VAULT_TRANSIT_METADATA_PREFIX: &str = "RUSTFS_KMS_VAULT_TRANSIT_METADATA_PREFIX";
|
||||
pub const ENV_KMS_STATIC_SECRET_KEY: &str = "RUSTFS_KMS_STATIC_SECRET_KEY";
|
||||
pub const ENV_KMS_STATIC_SECRET_KEY_FILE: &str = "RUSTFS_KMS_STATIC_SECRET_KEY_FILE";
|
||||
pub const ENV_KMS_VAULT_APPROLE_ROLE_ID: &str = "RUSTFS_KMS_VAULT_APPROLE_ROLE_ID";
|
||||
pub const ENV_KMS_VAULT_APPROLE_SECRET_ID: &str = "RUSTFS_KMS_VAULT_APPROLE_SECRET_ID";
|
||||
pub const ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE: &str = "RUSTFS_KMS_VAULT_APPROLE_SECRET_ID_FILE";
|
||||
pub const ENV_KMS_VAULT_APPROLE_MOUNT: &str = "RUSTFS_KMS_VAULT_APPROLE_MOUNT";
|
||||
pub const ENV_KMS_VAULT_TOKEN_FILE: &str = "RUSTFS_KMS_VAULT_TOKEN_FILE";
|
||||
pub const DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT: &str = "secret";
|
||||
pub const DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX: &str = "rustfs/kms/transit-metadata";
|
||||
pub const DEFAULT_VAULT_APPROLE_MOUNT: &str = "approle";
|
||||
|
||||
/// Upper bound applied to `KmsConfig::timeout` when deriving backend behavior.
|
||||
///
|
||||
/// Out-of-range values are clamped at use rather than rejected so existing
|
||||
/// deployments with oversized settings keep starting after an upgrade.
|
||||
pub(crate) const MAX_OPERATION_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Upper bound applied to `KmsConfig::retry_attempts` when deriving backend behavior.
|
||||
pub(crate) const MAX_RETRY_ATTEMPTS: u32 = 10;
|
||||
|
||||
fn default_vault_transit_metadata_kv_mount() -> String {
|
||||
DEFAULT_VAULT_TRANSIT_METADATA_KV_MOUNT.to_string()
|
||||
@@ -40,6 +55,14 @@ fn default_vault_transit_metadata_key_prefix() -> String {
|
||||
DEFAULT_VAULT_TRANSIT_METADATA_KEY_PREFIX.to_string()
|
||||
}
|
||||
|
||||
fn default_vault_kv2_mount_path() -> String {
|
||||
"transit".to_string()
|
||||
}
|
||||
|
||||
fn default_vault_approle_mount() -> String {
|
||||
DEFAULT_VAULT_APPROLE_MOUNT.to_string()
|
||||
}
|
||||
|
||||
pub const KMS_CONFIG_REDACTION_RULES: &[RedactionRule] = &[
|
||||
RedactionRule::new("kms.local.master_key", RedactionLevel::Secret, "local backend key encryption material"),
|
||||
RedactionRule::new("kms.vault.token", RedactionLevel::Secret, "vault authentication token"),
|
||||
@@ -91,7 +114,9 @@ pub(crate) fn redacted_secret_option(value: Option<&str>) -> Option<&'static str
|
||||
/// KMS backend types
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum KmsBackend {
|
||||
/// Vault KV v2 + Transit backend (key metadata in KV, wrapping via Transit)
|
||||
/// Vault KV v2 storage backend: master key material is stored directly in KV v2.
|
||||
/// Confidentiality relies on Vault ACLs, KV v2 at-rest encryption, and TLS; the
|
||||
/// backend performs no Transit wrapping of key material.
|
||||
#[serde(rename = "VaultKV2", alias = "Vault")]
|
||||
VaultKv2,
|
||||
/// Vault Transit backend using Vault as the cryptographic source of truth
|
||||
@@ -117,9 +142,15 @@ pub struct KmsConfig {
|
||||
/// Allow development-only insecure defaults such as plaintext local keys or HTTP Vault.
|
||||
#[serde(default)]
|
||||
pub allow_insecure_dev_defaults: bool,
|
||||
/// Operation timeout
|
||||
/// Timeout for a single backend attempt.
|
||||
///
|
||||
/// This bounds one outbound request, not the whole operation: the operation
|
||||
/// policy owns the total deadline across retries. Values above 300 seconds
|
||||
/// are clamped at use (see `KmsConfig::effective_timeout`).
|
||||
pub timeout: Duration,
|
||||
/// Number of retry attempts
|
||||
/// Number of retry attempts.
|
||||
///
|
||||
/// Values above 10 are clamped at use (see `KmsConfig::effective_retry_attempts`).
|
||||
pub retry_attempts: u32,
|
||||
/// Enable caching
|
||||
pub enable_cache: bool,
|
||||
@@ -147,7 +178,7 @@ impl Default for KmsConfig {
|
||||
pub enum BackendConfig {
|
||||
/// Local backend configuration
|
||||
Local(LocalConfig),
|
||||
/// Vault KV v2 + Transit backend configuration
|
||||
/// Vault KV v2 storage backend configuration
|
||||
#[serde(rename = "VaultKV2", alias = "Vault")]
|
||||
VaultKv2(Box<VaultConfig>),
|
||||
/// Vault Transit backend configuration
|
||||
@@ -255,7 +286,11 @@ impl StaticConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Vault KV v2 + Transit backend configuration (metadata in KV, key wrapping via Transit)
|
||||
/// Vault KV v2 backend configuration.
|
||||
///
|
||||
/// Key material and metadata are stored directly in KV v2; any identity with KV read
|
||||
/// access to the key path can recover plaintext master key material. Use the Vault
|
||||
/// Transit backend when cryptographic isolation of key material is required.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct VaultConfig {
|
||||
/// Vault server URL
|
||||
@@ -264,7 +299,10 @@ pub struct VaultConfig {
|
||||
pub auth_method: VaultAuthMethod,
|
||||
/// Vault namespace (Vault Enterprise)
|
||||
pub namespace: Option<String>,
|
||||
/// Transit engine mount path
|
||||
/// Deprecated: legacy Transit engine mount path. The Vault KV2 backend never calls
|
||||
/// the Transit engine, so this value is unused; the field is retained (and
|
||||
/// defaulted) only so previously persisted configurations keep deserializing.
|
||||
#[serde(default = "default_vault_kv2_mount_path")]
|
||||
pub mount_path: String,
|
||||
/// KV engine mount path for storing keys
|
||||
pub kv_mount: String,
|
||||
@@ -360,18 +398,94 @@ impl Default for VaultTransitConfig {
|
||||
pub enum VaultAuthMethod {
|
||||
/// Token authentication
|
||||
Token { token: String },
|
||||
/// AppRole authentication
|
||||
AppRole { role_id: String, secret_id: String },
|
||||
/// AppRole authentication: login with `role_id` + `secret_id` for a
|
||||
/// lease-bound token that is renewed in the background.
|
||||
AppRole {
|
||||
role_id: String,
|
||||
/// Inline secret_id; used only when `secret_id_file` is unset.
|
||||
secret_id: String,
|
||||
/// Path to a file holding the secret_id. Re-read on every login so an
|
||||
/// externally rotated secret_id is picked up; takes precedence over the
|
||||
/// inline value.
|
||||
#[serde(default)]
|
||||
secret_id_file: Option<PathBuf>,
|
||||
/// AppRole auth engine mount path.
|
||||
#[serde(default = "default_vault_approle_mount")]
|
||||
mount: String,
|
||||
/// Fail-closed margin in seconds: once the current token is within this
|
||||
/// window of expiry without a successful refresh, requests are refused
|
||||
/// instead of sent with a token that may lapse mid-flight. Defaults to
|
||||
/// the per-attempt timeout.
|
||||
#[serde(default)]
|
||||
refresh_safety_window_secs: Option<u64>,
|
||||
},
|
||||
/// Agent-managed token file (for example a Vault Agent auto-auth sink):
|
||||
/// the token is read from `path` and re-read periodically so a token
|
||||
/// rotated by the agent is picked up without a restart.
|
||||
TokenFile {
|
||||
path: PathBuf,
|
||||
/// Seconds between token file re-reads. Each successful read also
|
||||
/// extends the token's observed validity to twice this value, so a
|
||||
/// file that stops being readable eventually trips the fail-closed
|
||||
/// window. Defaults to 30 seconds.
|
||||
#[serde(default)]
|
||||
poll_interval_secs: Option<u64>,
|
||||
/// Fail-closed margin in seconds, as on `AppRole`. Defaults to the
|
||||
/// per-attempt timeout.
|
||||
#[serde(default)]
|
||||
refresh_safety_window_secs: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl VaultAuthMethod {
|
||||
/// AppRole authentication with the default mount and no secret-id file.
|
||||
pub fn approle(role_id: String, secret_id: String) -> Self {
|
||||
Self::AppRole {
|
||||
role_id,
|
||||
secret_id,
|
||||
secret_id_file: None,
|
||||
mount: default_vault_approle_mount(),
|
||||
refresh_safety_window_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Agent-managed token file with the default poll interval.
|
||||
pub fn token_file(path: PathBuf) -> Self {
|
||||
Self::TokenFile {
|
||||
path,
|
||||
poll_interval_secs: None,
|
||||
refresh_safety_window_secs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for VaultAuthMethod {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Token { token } => f.debug_struct("Token").field("token", &redacted_secret(token)).finish(),
|
||||
Self::AppRole { role_id, secret_id } => f
|
||||
Self::AppRole {
|
||||
role_id,
|
||||
secret_id,
|
||||
secret_id_file,
|
||||
mount,
|
||||
refresh_safety_window_secs,
|
||||
} => f
|
||||
.debug_struct("AppRole")
|
||||
.field("role_id", role_id)
|
||||
.field("secret_id", &redacted_secret(secret_id))
|
||||
.field("secret_id_file", secret_id_file)
|
||||
.field("mount", mount)
|
||||
.field("refresh_safety_window_secs", refresh_safety_window_secs)
|
||||
.finish(),
|
||||
Self::TokenFile {
|
||||
path,
|
||||
poll_interval_secs,
|
||||
refresh_safety_window_secs,
|
||||
} => f
|
||||
.debug_struct("TokenFile")
|
||||
.field("path", path)
|
||||
.field("poll_interval_secs", poll_interval_secs)
|
||||
.field("refresh_safety_window_secs", refresh_safety_window_secs)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
@@ -424,7 +538,12 @@ impl KmsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new KMS configuration for Vault backend with token authentication (recommended for production)
|
||||
/// Create a new KMS configuration for the Vault KV v2 backend with token authentication.
|
||||
///
|
||||
/// Master key material is stored directly in Vault KV v2: confidentiality relies on
|
||||
/// Vault ACLs, KV v2 at-rest encryption, and TLS. KV read access to the key path is
|
||||
/// equivalent to holding the plaintext master keys. Use [`KmsConfig::vault_transit`]
|
||||
/// when key material must never be readable through Vault storage APIs.
|
||||
pub fn vault(address: Url, token: String) -> Self {
|
||||
Self {
|
||||
backend: KmsBackend::VaultKv2,
|
||||
@@ -437,13 +556,16 @@ impl KmsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new KMS configuration for Vault backend with AppRole authentication (recommended for production)
|
||||
/// Create a new KMS configuration for the Vault KV v2 backend with AppRole authentication.
|
||||
///
|
||||
/// Shares the security boundary described on [`KmsConfig::vault`]: key material lives
|
||||
/// in KV v2 and is protected only by Vault ACLs and KV v2 at-rest encryption.
|
||||
pub fn vault_approle(address: Url, role_id: String, secret_id: String) -> Self {
|
||||
Self {
|
||||
backend: KmsBackend::VaultKv2,
|
||||
backend_config: BackendConfig::VaultKv2(Box::new(VaultConfig {
|
||||
address: address.to_string(),
|
||||
auth_method: VaultAuthMethod::AppRole { role_id, secret_id },
|
||||
auth_method: VaultAuthMethod::approle(role_id, secret_id),
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
@@ -536,6 +658,16 @@ impl KmsConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Per-attempt timeout with the configured value clamped to the supported maximum.
|
||||
pub(crate) fn effective_timeout(&self) -> Duration {
|
||||
self.timeout.min(MAX_OPERATION_TIMEOUT)
|
||||
}
|
||||
|
||||
/// Retry attempts with the configured value clamped to the supported maximum.
|
||||
pub(crate) fn effective_retry_attempts(&self) -> u32 {
|
||||
self.retry_attempts.min(MAX_RETRY_ATTEMPTS)
|
||||
}
|
||||
|
||||
/// Validate the configuration
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
// Validate timeout
|
||||
@@ -548,6 +680,23 @@ impl KmsConfig {
|
||||
return Err(KmsError::configuration_error("Retry attempts must be greater than 0"));
|
||||
}
|
||||
|
||||
// Oversized values are clamped at use (not rejected) so pre-existing
|
||||
// configurations cannot keep the service from starting after upgrade.
|
||||
if self.timeout > MAX_OPERATION_TIMEOUT {
|
||||
tracing::warn!(
|
||||
configured_secs = self.timeout.as_secs(),
|
||||
max_secs = MAX_OPERATION_TIMEOUT.as_secs(),
|
||||
"KMS timeout exceeds the supported maximum; backend operations clamp it to the maximum"
|
||||
);
|
||||
}
|
||||
if self.retry_attempts > MAX_RETRY_ATTEMPTS {
|
||||
tracing::warn!(
|
||||
configured = self.retry_attempts,
|
||||
max = MAX_RETRY_ATTEMPTS,
|
||||
"KMS retry_attempts exceeds the supported maximum; backend operations clamp it to the maximum"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate backend-specific configuration
|
||||
match &self.backend_config {
|
||||
BackendConfig::Local(config) => {
|
||||
@@ -574,13 +723,14 @@ impl KmsConfig {
|
||||
return Err(KmsError::configuration_error("Vault KV2 address must use http or https scheme"));
|
||||
}
|
||||
|
||||
validate_vault_auth_method("Vault KV2", &config.auth_method)?;
|
||||
|
||||
if !self.allow_insecure_dev_defaults {
|
||||
validate_vault_development_defaults("Vault KV2", &config.address, &config.auth_method, config.tls.as_ref())?;
|
||||
}
|
||||
|
||||
if config.mount_path.is_empty() {
|
||||
return Err(KmsError::configuration_error("Vault KV2 mount path cannot be empty"));
|
||||
}
|
||||
// `mount_path` is deprecated and unused by this backend, so an empty value
|
||||
// is deliberately not an error.
|
||||
|
||||
// Validate TLS configuration if using HTTPS
|
||||
if config.address.starts_with("https://")
|
||||
@@ -598,6 +748,8 @@ impl KmsConfig {
|
||||
return Err(KmsError::configuration_error("Vault Transit address must use http or https scheme"));
|
||||
}
|
||||
|
||||
validate_vault_auth_method("Vault Transit", &config.auth_method)?;
|
||||
|
||||
if !self.allow_insecure_dev_defaults {
|
||||
validate_vault_development_defaults(
|
||||
"Vault Transit",
|
||||
@@ -702,14 +854,24 @@ impl KmsConfig {
|
||||
}
|
||||
KmsBackend::VaultKv2 => {
|
||||
let address = get_env_str("RUSTFS_KMS_VAULT_ADDRESS", "http://localhost:8200");
|
||||
let token = get_env_str("RUSTFS_KMS_VAULT_TOKEN", "dev-token");
|
||||
let auth_method = vault_auth_method_from_env()?;
|
||||
let skip_tls_verify = get_env_bool(ENV_KMS_VAULT_SKIP_TLS_VERIFY, false);
|
||||
|
||||
let mount_path = match get_env_opt_str("RUSTFS_KMS_VAULT_MOUNT_PATH") {
|
||||
Some(path) => {
|
||||
tracing::warn!(
|
||||
"RUSTFS_KMS_VAULT_MOUNT_PATH is deprecated for the Vault KV2 backend: it never calls the Transit engine and the value is stored but unused"
|
||||
);
|
||||
path
|
||||
}
|
||||
None => default_vault_kv2_mount_path(),
|
||||
};
|
||||
|
||||
config.backend_config = BackendConfig::VaultKv2(Box::new(VaultConfig {
|
||||
address,
|
||||
auth_method: VaultAuthMethod::Token { token },
|
||||
auth_method,
|
||||
namespace: get_env_opt_str("RUSTFS_KMS_VAULT_NAMESPACE"),
|
||||
mount_path: get_env_str("RUSTFS_KMS_VAULT_MOUNT_PATH", "transit"),
|
||||
mount_path,
|
||||
kv_mount: get_env_str("RUSTFS_KMS_VAULT_KV_MOUNT", "secret"),
|
||||
key_path_prefix: get_env_str("RUSTFS_KMS_VAULT_KEY_PREFIX", "rustfs/kms/keys"),
|
||||
tls: vault_tls_config(skip_tls_verify),
|
||||
@@ -717,12 +879,12 @@ impl KmsConfig {
|
||||
}
|
||||
KmsBackend::VaultTransit => {
|
||||
let address = get_env_str("RUSTFS_KMS_VAULT_ADDRESS", "http://localhost:8200");
|
||||
let token = get_env_str("RUSTFS_KMS_VAULT_TOKEN", "dev-token");
|
||||
let auth_method = vault_auth_method_from_env()?;
|
||||
let skip_tls_verify = get_env_bool(ENV_KMS_VAULT_SKIP_TLS_VERIFY, false);
|
||||
|
||||
config.backend_config = BackendConfig::VaultTransit(Box::new(VaultTransitConfig {
|
||||
address,
|
||||
auth_method: VaultAuthMethod::Token { token },
|
||||
auth_method,
|
||||
namespace: get_env_opt_str("RUSTFS_KMS_VAULT_NAMESPACE"),
|
||||
mount_path: get_env_str("RUSTFS_KMS_VAULT_MOUNT_PATH", "transit"),
|
||||
metadata_kv_mount: get_env_str(
|
||||
@@ -801,6 +963,95 @@ fn is_under_temp_dir(path: &Path) -> bool {
|
||||
path.starts_with(std::env::temp_dir())
|
||||
}
|
||||
|
||||
/// Resolve the Vault auth method from environment variables.
|
||||
///
|
||||
/// Setting `RUSTFS_KMS_VAULT_APPROLE_ROLE_ID` selects AppRole authentication;
|
||||
/// the secret_id then comes from `RUSTFS_KMS_VAULT_APPROLE_SECRET_ID_FILE`
|
||||
/// (re-read on every login, mirroring the `RUSTFS_KMS_STATIC_SECRET_KEY_FILE`
|
||||
/// precedent) or inline from `RUSTFS_KMS_VAULT_APPROLE_SECRET_ID`, with the
|
||||
/// file taking precedence. Without a role id the legacy token flow applies.
|
||||
fn vault_auth_method_from_env() -> Result<VaultAuthMethod> {
|
||||
if let Some(token_file) = get_env_opt_str(ENV_KMS_VAULT_TOKEN_FILE) {
|
||||
// A token file names one authoritative credential source; combining it
|
||||
// with another one would leave the effective identity ambiguous, so
|
||||
// that is a configuration error rather than a precedence rule.
|
||||
if get_env_opt_str(ENV_KMS_VAULT_APPROLE_ROLE_ID).is_some() {
|
||||
return Err(KmsError::configuration_error(format!(
|
||||
"{ENV_KMS_VAULT_TOKEN_FILE} cannot be combined with {ENV_KMS_VAULT_APPROLE_ROLE_ID}; configure exactly one Vault auth method"
|
||||
)));
|
||||
}
|
||||
if get_env_opt_str("RUSTFS_KMS_VAULT_TOKEN").is_some() {
|
||||
return Err(KmsError::configuration_error(format!(
|
||||
"{ENV_KMS_VAULT_TOKEN_FILE} cannot be combined with RUSTFS_KMS_VAULT_TOKEN; configure exactly one Vault auth method"
|
||||
)));
|
||||
}
|
||||
return Ok(VaultAuthMethod::token_file(PathBuf::from(token_file)));
|
||||
}
|
||||
|
||||
let Some(role_id) = get_env_opt_str(ENV_KMS_VAULT_APPROLE_ROLE_ID) else {
|
||||
return Ok(VaultAuthMethod::Token {
|
||||
token: get_env_str("RUSTFS_KMS_VAULT_TOKEN", "dev-token"),
|
||||
});
|
||||
};
|
||||
|
||||
let secret_id_file = get_env_opt_str(ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE).map(PathBuf::from);
|
||||
let secret_id = get_env_opt_str(ENV_KMS_VAULT_APPROLE_SECRET_ID).unwrap_or_default();
|
||||
if secret_id.is_empty() && secret_id_file.is_none() {
|
||||
return Err(KmsError::configuration_error(format!(
|
||||
"Vault AppRole requires {ENV_KMS_VAULT_APPROLE_SECRET_ID} or {ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE} to be set"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(VaultAuthMethod::AppRole {
|
||||
role_id,
|
||||
secret_id,
|
||||
secret_id_file,
|
||||
mount: get_env_str(ENV_KMS_VAULT_APPROLE_MOUNT, DEFAULT_VAULT_APPROLE_MOUNT),
|
||||
refresh_safety_window_secs: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_vault_auth_method(backend_name: &str, auth_method: &VaultAuthMethod) -> Result<()> {
|
||||
match auth_method {
|
||||
VaultAuthMethod::Token { .. } => Ok(()),
|
||||
VaultAuthMethod::AppRole {
|
||||
role_id,
|
||||
secret_id,
|
||||
secret_id_file,
|
||||
mount,
|
||||
..
|
||||
} => {
|
||||
if role_id.is_empty() {
|
||||
return Err(KmsError::configuration_error(format!("{backend_name} AppRole role_id cannot be empty")));
|
||||
}
|
||||
if secret_id.is_empty() && secret_id_file.is_none() {
|
||||
return Err(KmsError::configuration_error(format!(
|
||||
"{backend_name} AppRole requires a secret_id or a secret_id_file"
|
||||
)));
|
||||
}
|
||||
if mount.is_empty() {
|
||||
return Err(KmsError::configuration_error(format!("{backend_name} AppRole mount cannot be empty")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
VaultAuthMethod::TokenFile {
|
||||
path,
|
||||
poll_interval_secs,
|
||||
..
|
||||
} => {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(KmsError::configuration_error(format!("{backend_name} token file path cannot be empty")));
|
||||
}
|
||||
if poll_interval_secs == &Some(0) {
|
||||
return Err(KmsError::configuration_error(format!(
|
||||
"{backend_name} token file poll interval must be greater than 0"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_vault_development_defaults(
|
||||
backend_name: &str,
|
||||
address: &str,
|
||||
@@ -855,6 +1106,31 @@ mod tests {
|
||||
assert_eq!(local_config.key_dir, temp_dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_oversized_timeout_and_retries_clamped_not_rejected() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let config = KmsConfig {
|
||||
timeout: Duration::from_secs(3_600),
|
||||
retry_attempts: 50,
|
||||
..KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults()
|
||||
};
|
||||
|
||||
// Out-of-range values must not keep the service from starting.
|
||||
assert!(config.validate().is_ok());
|
||||
assert_eq!(config.effective_timeout(), MAX_OPERATION_TIMEOUT);
|
||||
assert_eq!(config.effective_retry_attempts(), MAX_RETRY_ATTEMPTS);
|
||||
|
||||
// In-range values pass through unchanged.
|
||||
let config = KmsConfig {
|
||||
timeout: Duration::from_secs(45),
|
||||
retry_attempts: 5,
|
||||
..config
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
assert_eq!(config.effective_timeout(), Duration::from_secs(45));
|
||||
assert_eq!(config.effective_retry_attempts(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_local_development_defaults_require_opt_in() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
@@ -977,6 +1253,73 @@ mod tests {
|
||||
assert!(config.vault_config().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_persisted_vault_kv2_config_without_mount_path_deserializes() {
|
||||
// Configurations persisted after mount_path was deprecated may omit the field;
|
||||
// it must default instead of failing deserialization.
|
||||
let raw = r#"{
|
||||
"backend": "VaultKV2",
|
||||
"backend_config": {
|
||||
"VaultKV2": {
|
||||
"address": "http://127.0.0.1:8200",
|
||||
"auth_method": { "Token": { "token": "t" } },
|
||||
"namespace": null,
|
||||
"kv_mount": "secret",
|
||||
"key_path_prefix": "rustfs/kms/keys",
|
||||
"tls": null
|
||||
}
|
||||
},
|
||||
"default_key_id": null,
|
||||
"timeout": {"secs": 30, "nanos": 0},
|
||||
"retry_attempts": 3,
|
||||
"enable_cache": true,
|
||||
"cache_config": {
|
||||
"max_keys": 1000,
|
||||
"ttl": {"secs": 3600, "nanos": 0},
|
||||
"enable_metrics": true
|
||||
}
|
||||
}"#;
|
||||
let config: KmsConfig = serde_json::from_str(raw).expect("persisted kms config without mount_path");
|
||||
assert_eq!(config.backend, KmsBackend::VaultKv2);
|
||||
let vault = config.vault_config().expect("vault-kv2 config");
|
||||
assert_eq!(vault.mount_path, "transit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_kv2_empty_mount_path_passes_validation() {
|
||||
let address = Url::parse("https://vault.example.com:8200").expect("Valid URL");
|
||||
let mut config = KmsConfig::vault(address, "test-token".to_string());
|
||||
if let BackendConfig::VaultKv2(vault) = &mut config.backend_config {
|
||||
vault.mount_path = String::new();
|
||||
}
|
||||
assert!(config.validate().is_ok(), "deprecated mount_path must not be required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_kv2_sources_do_not_claim_transit_wrapping() {
|
||||
let sources = [
|
||||
("config.rs", include_str!("config.rs")),
|
||||
("api_types.rs", include_str!("api_types.rs")),
|
||||
("backends/vault.rs", include_str!("backends/vault.rs")),
|
||||
("lib.rs", include_str!("lib.rs")),
|
||||
];
|
||||
// Assemble the needles at runtime so this guard does not match its own source.
|
||||
let needles = [
|
||||
format!("wrapping via {}", "Transit"),
|
||||
format!("KV v2 + {}", "Transit"),
|
||||
format!("KV2+{}", "Transit"),
|
||||
format!("you would use Vault's {} engine", "transit"),
|
||||
];
|
||||
for (name, source) in sources {
|
||||
for needle in &needles {
|
||||
assert!(
|
||||
!source.contains(needle.as_str()),
|
||||
"{name} still describes the Vault KV2 backend with `{needle}`"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_legacy_persisted_vault_transit_config_uses_metadata_defaults() {
|
||||
let raw = r#"{
|
||||
@@ -1133,6 +1476,220 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_env_selects_approle_when_role_id_is_set() {
|
||||
with_vars(
|
||||
vec![
|
||||
("RUSTFS_KMS_BACKEND", Some("vault")),
|
||||
("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")),
|
||||
(ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")),
|
||||
(ENV_KMS_VAULT_APPROLE_SECRET_ID, Some("env-approle-secret-id")),
|
||||
(ENV_KMS_VAULT_APPROLE_MOUNT, Some("approle-alt")),
|
||||
// A stale token env var must not override the AppRole selection.
|
||||
("RUSTFS_KMS_VAULT_TOKEN", Some("vault-token")),
|
||||
],
|
||||
|| {
|
||||
let config = KmsConfig::from_env().expect("kms config should load from env");
|
||||
let vault = config.vault_config().expect("vault backend config");
|
||||
let VaultAuthMethod::AppRole {
|
||||
role_id,
|
||||
secret_id,
|
||||
secret_id_file,
|
||||
mount,
|
||||
refresh_safety_window_secs,
|
||||
} = &vault.auth_method
|
||||
else {
|
||||
panic!("role id in the environment must select AppRole auth, got {:?}", vault.auth_method);
|
||||
};
|
||||
assert_eq!(role_id, "env-role-id");
|
||||
assert_eq!(secret_id, "env-approle-secret-id");
|
||||
assert_eq!(secret_id_file, &None);
|
||||
assert_eq!(mount, "approle-alt");
|
||||
assert_eq!(refresh_safety_window_secs, &None);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_env_approle_secret_id_file_is_stored_as_path() {
|
||||
with_vars(
|
||||
vec![
|
||||
("RUSTFS_KMS_BACKEND", Some("vault-transit")),
|
||||
("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")),
|
||||
(ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")),
|
||||
(ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE, Some("/etc/rustfs/approle-secret-id")),
|
||||
],
|
||||
|| {
|
||||
let config = KmsConfig::from_env().expect("kms config should load from env");
|
||||
let vault = config.vault_transit_config().expect("vault transit backend config");
|
||||
let VaultAuthMethod::AppRole {
|
||||
secret_id,
|
||||
secret_id_file,
|
||||
mount,
|
||||
..
|
||||
} = &vault.auth_method
|
||||
else {
|
||||
panic!("role id in the environment must select AppRole auth");
|
||||
};
|
||||
// The path is stored, not read: the secret_id file is re-read on
|
||||
// every login so external rotation is picked up.
|
||||
assert_eq!(secret_id_file.as_deref(), Some(std::path::Path::new("/etc/rustfs/approle-secret-id")));
|
||||
assert!(secret_id.is_empty());
|
||||
assert_eq!(mount, DEFAULT_VAULT_APPROLE_MOUNT);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_env_approle_requires_secret_id_or_file() {
|
||||
with_vars(
|
||||
vec![
|
||||
("RUSTFS_KMS_BACKEND", Some("vault")),
|
||||
(ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")),
|
||||
(ENV_KMS_VAULT_APPROLE_SECRET_ID, None::<&str>),
|
||||
(ENV_KMS_VAULT_APPROLE_SECRET_ID_FILE, None::<&str>),
|
||||
],
|
||||
|| {
|
||||
let error = KmsConfig::from_env().expect_err("approle without a secret_id source must be rejected");
|
||||
assert!(error.to_string().contains(ENV_KMS_VAULT_APPROLE_SECRET_ID));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_env_selects_token_file() {
|
||||
with_vars(
|
||||
vec![
|
||||
("RUSTFS_KMS_BACKEND", Some("vault")),
|
||||
("RUSTFS_KMS_VAULT_ADDRESS", Some("https://vault.example.com")),
|
||||
(ENV_KMS_VAULT_TOKEN_FILE, Some("/run/vault-agent/token")),
|
||||
],
|
||||
|| {
|
||||
let config = KmsConfig::from_env().expect("kms config should load from env");
|
||||
let vault = config.vault_config().expect("vault backend config");
|
||||
let VaultAuthMethod::TokenFile {
|
||||
path,
|
||||
poll_interval_secs,
|
||||
refresh_safety_window_secs,
|
||||
} = &vault.auth_method
|
||||
else {
|
||||
panic!("token file in the environment must select TokenFile auth, got {:?}", vault.auth_method);
|
||||
};
|
||||
assert_eq!(path, std::path::Path::new("/run/vault-agent/token"));
|
||||
assert_eq!(poll_interval_secs, &None);
|
||||
assert_eq!(refresh_safety_window_secs, &None);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_env_token_file_is_mutually_exclusive_with_other_auth() {
|
||||
with_vars(
|
||||
vec![
|
||||
("RUSTFS_KMS_BACKEND", Some("vault")),
|
||||
(ENV_KMS_VAULT_TOKEN_FILE, Some("/run/vault-agent/token")),
|
||||
(ENV_KMS_VAULT_APPROLE_ROLE_ID, Some("env-role-id")),
|
||||
],
|
||||
|| {
|
||||
let error = KmsConfig::from_env().expect_err("token file combined with approle must be rejected");
|
||||
assert!(error.to_string().contains(ENV_KMS_VAULT_TOKEN_FILE));
|
||||
assert!(error.to_string().contains(ENV_KMS_VAULT_APPROLE_ROLE_ID));
|
||||
},
|
||||
);
|
||||
|
||||
with_vars(
|
||||
vec![
|
||||
("RUSTFS_KMS_BACKEND", Some("vault-transit")),
|
||||
(ENV_KMS_VAULT_TOKEN_FILE, Some("/run/vault-agent/token")),
|
||||
("RUSTFS_KMS_VAULT_TOKEN", Some("vault-token")),
|
||||
],
|
||||
|| {
|
||||
let error = KmsConfig::from_env().expect_err("token file combined with a static token must be rejected");
|
||||
assert!(error.to_string().contains(ENV_KMS_VAULT_TOKEN_FILE));
|
||||
assert!(error.to_string().contains("RUSTFS_KMS_VAULT_TOKEN"));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_bad_token_file_settings() {
|
||||
let vault_config = |auth_method: VaultAuthMethod| KmsConfig {
|
||||
backend: KmsBackend::VaultKv2,
|
||||
backend_config: BackendConfig::VaultKv2(Box::new(VaultConfig {
|
||||
address: "https://vault.example.com:8200".to_string(),
|
||||
auth_method,
|
||||
..Default::default()
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let error = vault_config(VaultAuthMethod::token_file(PathBuf::new()))
|
||||
.validate()
|
||||
.expect_err("empty token file path must be rejected");
|
||||
assert!(error.to_string().contains("path"));
|
||||
|
||||
let error = vault_config(VaultAuthMethod::TokenFile {
|
||||
path: PathBuf::from("/run/vault-agent/token"),
|
||||
poll_interval_secs: Some(0),
|
||||
refresh_safety_window_secs: None,
|
||||
})
|
||||
.validate()
|
||||
.expect_err("zero poll interval must be rejected");
|
||||
assert!(error.to_string().contains("poll interval"));
|
||||
|
||||
vault_config(VaultAuthMethod::token_file(PathBuf::from("/run/vault-agent/token")))
|
||||
.validate()
|
||||
.expect("well-formed token file auth must validate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_approle_config_deserializes_legacy_shape_with_defaults() {
|
||||
// Persisted configurations from before the AppRole implementation only
|
||||
// carry role_id and secret_id; the new fields must fill with defaults.
|
||||
let legacy = serde_json::json!({
|
||||
"AppRole": {
|
||||
"role_id": "legacy-role",
|
||||
"secret_id": "legacy-secret-id",
|
||||
}
|
||||
});
|
||||
let auth: VaultAuthMethod = serde_json::from_value(legacy).expect("legacy AppRole config must keep deserializing");
|
||||
let VaultAuthMethod::AppRole {
|
||||
role_id,
|
||||
secret_id_file,
|
||||
mount,
|
||||
refresh_safety_window_secs,
|
||||
..
|
||||
} = auth
|
||||
else {
|
||||
panic!("expected AppRole");
|
||||
};
|
||||
assert_eq!(role_id, "legacy-role");
|
||||
assert_eq!(secret_id_file, None);
|
||||
assert_eq!(mount, DEFAULT_VAULT_APPROLE_MOUNT);
|
||||
assert_eq!(refresh_safety_window_secs, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_rejects_incomplete_approle() {
|
||||
let mut config = KmsConfig::vault_approle(
|
||||
Url::parse("https://vault.example.com:8200").expect("vault URL"),
|
||||
String::new(),
|
||||
"secret-id".to_string(),
|
||||
);
|
||||
let error = config.validate().expect_err("empty role_id must be rejected");
|
||||
assert!(error.to_string().contains("role_id"));
|
||||
|
||||
config = KmsConfig::vault_approle(
|
||||
Url::parse("https://vault.example.com:8200").expect("vault URL"),
|
||||
"role-id".to_string(),
|
||||
String::new(),
|
||||
);
|
||||
let error = config
|
||||
.validate()
|
||||
.expect_err("approle without secret_id or secret_id_file must be rejected");
|
||||
assert!(error.to_string().contains("secret_id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_env_requires_vault_development_opt_in() {
|
||||
with_vars(
|
||||
|
||||
@@ -32,7 +32,10 @@ use std::collections::HashMap;
|
||||
///
|
||||
/// This structure stores the encrypted DEK along with metadata needed for decryption.
|
||||
/// The `master_key_version` field records which version of the KEK (Key Encryption Key)
|
||||
/// was used to encrypt this DEK, enabling proper key rotation support.
|
||||
/// wrapped this DEK so rotation-aware backends can load the matching historical
|
||||
/// material. Envelopes written before versioning carry `None`; backends must resolve
|
||||
/// `None` to a deterministic baseline version recorded in key metadata, never
|
||||
/// implicitly to whatever version is current.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DataKeyEnvelope {
|
||||
pub key_id: String,
|
||||
@@ -43,6 +46,12 @@ pub struct DataKeyEnvelope {
|
||||
pub encryption_context: HashMap<String, String>,
|
||||
#[serde(with = "crate::time_serde::zoned")]
|
||||
pub created_at: Zoned,
|
||||
/// KEK version that wrapped `encrypted_key`; `None` on pre-versioning envelopes.
|
||||
///
|
||||
/// Optional and omitted when `None` so envelopes from non-rotating backends stay
|
||||
/// byte-identical to the historical seven-field JSON shape.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub master_key_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -307,6 +316,7 @@ mod tests {
|
||||
map
|
||||
},
|
||||
created_at: Zoned::now(),
|
||||
master_key_version: None,
|
||||
};
|
||||
|
||||
// Test serialization
|
||||
@@ -336,6 +346,8 @@ mod tests {
|
||||
let deserialized: DataKeyEnvelope = serde_json::from_str(envelope_json).expect("Should deserialize current format");
|
||||
assert_eq!(deserialized.key_id, "test-key-id");
|
||||
assert_eq!(deserialized.master_key_id, "master-key-id");
|
||||
// Envelopes persisted before versioning must parse with no master key version.
|
||||
assert_eq!(deserialized.master_key_version, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -353,6 +365,50 @@ mod tests {
|
||||
let deserialized: DataKeyEnvelope = serde_json::from_str(envelope_json).expect("Should deserialize legacy format");
|
||||
assert_eq!(deserialized.key_id, "test-key-id");
|
||||
assert_eq!(deserialized.master_key_id, "master-key-id");
|
||||
assert_eq!(deserialized.master_key_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_key_envelope_none_version_serializes_without_field() {
|
||||
// A `None` version must keep the serialized envelope on the historical
|
||||
// seven-field JSON shape so non-rotating backends emit byte-compatible
|
||||
// envelopes that older readers accept unchanged.
|
||||
let envelope = DataKeyEnvelope {
|
||||
key_id: "test-key-id".to_string(),
|
||||
master_key_id: "master-key-id".to_string(),
|
||||
key_spec: "AES_256".to_string(),
|
||||
encrypted_key: vec![1, 2, 3, 4],
|
||||
nonce: vec![5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
||||
encryption_context: HashMap::new(),
|
||||
created_at: Zoned::now(),
|
||||
master_key_version: None,
|
||||
};
|
||||
|
||||
let value = serde_json::to_value(&envelope).expect("serialize envelope");
|
||||
let object = value.as_object().expect("envelope serializes to an object");
|
||||
assert!(!object.contains_key("master_key_version"));
|
||||
assert_eq!(object.len(), 7, "None version must not change the seven-field JSON shape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_key_envelope_version_round_trip() {
|
||||
let envelope = DataKeyEnvelope {
|
||||
key_id: "test-key-id".to_string(),
|
||||
master_key_id: "master-key-id".to_string(),
|
||||
key_spec: "AES_256".to_string(),
|
||||
encrypted_key: vec![1, 2, 3, 4],
|
||||
nonce: vec![5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
|
||||
encryption_context: HashMap::new(),
|
||||
created_at: Zoned::now(),
|
||||
master_key_version: Some(7),
|
||||
};
|
||||
|
||||
let serialized = serde_json::to_vec(&envelope).expect("serialize envelope");
|
||||
let value: serde_json::Value = serde_json::from_slice(&serialized).expect("parse serialized envelope");
|
||||
assert_eq!(value.get("master_key_version"), Some(&serde_json::json!(7)));
|
||||
|
||||
let deserialized: DataKeyEnvelope = serde_json::from_slice(&serialized).expect("deserialize envelope");
|
||||
assert_eq!(deserialized.master_key_version, Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -368,8 +424,19 @@ mod tests {
|
||||
}"#;
|
||||
let minio_legacy = br#"{"aead":"AES-256-GCM-HMAC-SHA-256","iv":[1],"nonce":[2],"bytes":[3]}"#;
|
||||
let duplicate_key_id = [b"{\"key_id\":\"duplicate\",".as_slice(), &kms_envelope[1..]].concat();
|
||||
// Rotation-aware envelope: the optional master_key_version field must not
|
||||
// change how mixed batches of old and new envelopes are routed.
|
||||
let versioned_envelope = {
|
||||
let mut value: serde_json::Value = serde_json::from_slice(kms_envelope).expect("parse KMS envelope fixture");
|
||||
value
|
||||
.as_object_mut()
|
||||
.expect("KMS envelope fixture is an object")
|
||||
.insert("master_key_version".to_string(), serde_json::json!(2));
|
||||
serde_json::to_vec(&value).expect("serialize versioned envelope")
|
||||
};
|
||||
|
||||
assert!(is_data_key_envelope(kms_envelope));
|
||||
assert!(is_data_key_envelope(&versioned_envelope));
|
||||
assert!(is_data_key_envelope(&[b" \n".as_slice(), kms_envelope].concat()));
|
||||
assert!(!is_data_key_envelope(&duplicate_key_id));
|
||||
assert!(!is_data_key_envelope(b"bm9uY2U=:Y2lwaGVydGV4dA=="));
|
||||
|
||||
@@ -90,6 +90,49 @@ pub enum KmsError {
|
||||
/// Encryption context mismatch
|
||||
#[error("Encryption context mismatch: {message}")]
|
||||
ContextMismatch { message: String },
|
||||
|
||||
/// Backend operation exceeded its per-attempt timeout or total deadline
|
||||
#[error("Operation timed out: {message}")]
|
||||
OperationTimedOut { message: String },
|
||||
|
||||
/// Backend operation aborted by cancellation or shutdown
|
||||
#[error("Operation cancelled: {message}")]
|
||||
OperationCancelled { message: String },
|
||||
|
||||
// New variants must be appended below (never inserted above) so that
|
||||
// concurrent additions rebase without conflicts.
|
||||
/// Persisted key material is absent from an otherwise readable key record
|
||||
#[error(
|
||||
"Key material missing for key {key_id}: the stored record has no key material; restore it from backup or repair the key explicitly"
|
||||
)]
|
||||
MaterialMissing { key_id: String },
|
||||
|
||||
/// Persisted key material exists but cannot be decoded
|
||||
#[error("Key material corrupt for key {key_id}: {message}")]
|
||||
MaterialCorrupt { key_id: String, message: String },
|
||||
|
||||
/// Persisted key material failed authenticated decryption
|
||||
#[error(
|
||||
"Key material authentication failed for key {key_id}: the stored material cannot be decrypted with the configured master key"
|
||||
)]
|
||||
MaterialAuthenticationFailed { key_id: String },
|
||||
|
||||
/// Persisted key record uses a format version unknown to this build
|
||||
#[error("Unsupported key format version {version:?} for key {key_id}")]
|
||||
UnsupportedFormatVersion { key_id: String, version: String },
|
||||
|
||||
/// Requested master key version has no persisted material for the key
|
||||
#[error("Key version {version} not found for key {key_id}")]
|
||||
KeyVersionNotFound { key_id: String, version: u32 },
|
||||
|
||||
/// Backup/restore bundle contract violation; see [`crate::backup::BackupError`]
|
||||
#[error(transparent)]
|
||||
Backup(#[from] crate::backup::BackupError),
|
||||
|
||||
/// Backend credentials expired or could not be refreshed in time; requests
|
||||
/// fail closed instead of being sent with credentials that may lapse mid-flight
|
||||
#[error("KMS credentials unavailable: {message}")]
|
||||
CredentialsUnavailable { message: String },
|
||||
}
|
||||
|
||||
impl KmsError {
|
||||
@@ -187,6 +230,55 @@ impl KmsError {
|
||||
pub fn context_mismatch<S: Into<String>>(message: S) -> Self {
|
||||
Self::ContextMismatch { message: message.into() }
|
||||
}
|
||||
|
||||
/// Create an operation timed out error
|
||||
pub fn operation_timed_out<S: Into<String>>(message: S) -> Self {
|
||||
Self::OperationTimedOut { message: message.into() }
|
||||
}
|
||||
|
||||
/// Create an operation cancelled error
|
||||
pub fn operation_cancelled<S: Into<String>>(message: S) -> Self {
|
||||
Self::OperationCancelled { message: message.into() }
|
||||
}
|
||||
|
||||
/// Create a material missing error
|
||||
pub fn material_missing<S: Into<String>>(key_id: S) -> Self {
|
||||
Self::MaterialMissing { key_id: key_id.into() }
|
||||
}
|
||||
|
||||
/// Create a material corrupt error
|
||||
pub fn material_corrupt<S1: Into<String>, S2: Into<String>>(key_id: S1, message: S2) -> Self {
|
||||
Self::MaterialCorrupt {
|
||||
key_id: key_id.into(),
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a material authentication failed error
|
||||
pub fn material_authentication_failed<S: Into<String>>(key_id: S) -> Self {
|
||||
Self::MaterialAuthenticationFailed { key_id: key_id.into() }
|
||||
}
|
||||
|
||||
/// Create an unsupported format version error
|
||||
pub fn unsupported_format_version<S1: Into<String>, S2: Into<String>>(key_id: S1, version: S2) -> Self {
|
||||
Self::UnsupportedFormatVersion {
|
||||
key_id: key_id.into(),
|
||||
version: version.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a key version not found error
|
||||
pub fn key_version_not_found<S: Into<String>>(key_id: S, version: u32) -> Self {
|
||||
Self::KeyVersionNotFound {
|
||||
key_id: key_id.into(),
|
||||
version,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a credentials unavailable error
|
||||
pub fn credentials_unavailable<S: Into<String>>(message: S) -> Self {
|
||||
Self::CredentialsUnavailable { message: message.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert from standard library errors
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **Multiple Backends**: Local file storage, Vault KV2+Transit, and Vault Transit (optional)
|
||||
//! - **Multiple Backends**: Local file storage, Vault KV2 (plain KV storage), and Vault Transit (optional)
|
||||
//! - **Object Encryption**: Transparent S3-compatible object encryption
|
||||
//! - **Streaming Encryption**: Memory-efficient encryption for large files
|
||||
//! - **Key Management**: Full lifecycle management of encryption keys
|
||||
@@ -66,11 +66,16 @@
|
||||
// Core modules
|
||||
pub mod api_types;
|
||||
pub mod backends;
|
||||
pub mod backup;
|
||||
mod cache;
|
||||
pub mod config;
|
||||
mod encryption;
|
||||
mod error;
|
||||
pub mod manager;
|
||||
// The executor is wired into the Vault backends in a follow-up change; until
|
||||
// then the module is only exercised by its own tests.
|
||||
#[allow(dead_code)]
|
||||
mod policy;
|
||||
pub mod service;
|
||||
pub mod service_manager;
|
||||
mod time_serde;
|
||||
@@ -89,7 +94,7 @@ pub use error::{KmsError, KmsUnavailableError, Result};
|
||||
pub use manager::KmsManager;
|
||||
pub use service::{DataKey, ObjectEncryptionService};
|
||||
pub use service_manager::{
|
||||
KmsServiceManager, KmsServiceStatus, get_global_encryption_service, get_global_kms_service_manager,
|
||||
KmsServiceManager, KmsServiceStatus, KmsStartOutcome, get_global_encryption_service, get_global_kms_service_manager,
|
||||
init_global_kms_service_manager,
|
||||
};
|
||||
pub use types::*;
|
||||
|
||||
@@ -0,0 +1,605 @@
|
||||
// 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.
|
||||
|
||||
//! Operation execution policy for external KMS backend calls.
|
||||
//!
|
||||
//! Vault-backed operations leave the process boundary, so every call needs a
|
||||
//! per-attempt timeout, a total operation deadline, and classification-driven
|
||||
//! bounded retries. This module provides the engine only; the Vault backends
|
||||
//! wire their call sites through [`execute`] in a follow-up change.
|
||||
//!
|
||||
//! Retry safety is driven by two orthogonal classifications:
|
||||
//! - [`OpClass`] states whether replaying the operation is safe at all.
|
||||
//! - [`ErrorClass`] states whether the observed failure is worth replaying.
|
||||
//!
|
||||
//! Mutating operations without an idempotency key or CAS precondition are never
|
||||
//! retried automatically: a response lost after the server applied the write
|
||||
//! would otherwise be replayed into duplicate side effects (extra key versions,
|
||||
//! repeated deletes).
|
||||
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use rand::{RngExt, SeedableRng, rngs::StdRng};
|
||||
use tokio::time::Instant;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::config::KmsConfig;
|
||||
use crate::error::{KmsError, Result};
|
||||
|
||||
/// Default backoff cap before the first retry; doubles per retry.
|
||||
const DEFAULT_BASE_BACKOFF: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Default upper bound for a single backoff sleep.
|
||||
const DEFAULT_MAX_BACKOFF: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Replay safety of a backend operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum OpClass {
|
||||
/// No persistent side effects (encrypt/decrypt/generate/describe/list/health);
|
||||
/// safe to retry on any retryable failure.
|
||||
ReadIdempotent,
|
||||
/// External write without an idempotency key or CAS precondition
|
||||
/// (create/rotate/delete/configure); executed at most once.
|
||||
MutatingNonIdempotent,
|
||||
/// Authentication exchange (login, token renewal); safe to resend.
|
||||
Auth,
|
||||
}
|
||||
|
||||
impl OpClass {
|
||||
fn retryable(self) -> bool {
|
||||
!matches!(self, OpClass::MutatingNonIdempotent)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry-relevant classification of a failed attempt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ErrorClass {
|
||||
/// Connection-level failure (connect/send/response read); the server may or
|
||||
/// may not have observed the request.
|
||||
RetryableConn,
|
||||
/// Retryable HTTP status: 429 throttling or a recoverable 5xx.
|
||||
RetryableStatus,
|
||||
/// Deterministic failure (auth, validation, not-found, malformed data);
|
||||
/// retrying cannot help and may mask the real problem.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
/// Classify a `vaultrs` client error for retry purposes.
|
||||
///
|
||||
/// Status codes are inspected both on `ClientError::APIError` (JSON error body)
|
||||
/// and on a wrapped rustify `ServerResponseError` (non-JSON body, e.g. an HTML
|
||||
/// page from an intermediate load balancer). Everything that is not throttling,
|
||||
/// a recoverable 5xx, or a connection-level failure is fatal; in particular
|
||||
/// 400/401/403/404 must never be retried.
|
||||
pub(crate) fn classify_vaultrs(error: &vaultrs::error::ClientError) -> ErrorClass {
|
||||
use rustify::errors::ClientError as RestError;
|
||||
use vaultrs::error::ClientError;
|
||||
|
||||
match error {
|
||||
ClientError::APIError { code, .. } => classify_status(*code),
|
||||
ClientError::RestClientError { source } => match source {
|
||||
RestError::ServerResponseError { code, .. } => classify_status(*code),
|
||||
RestError::RequestError { .. } | RestError::ResponseError { .. } => ErrorClass::RetryableConn,
|
||||
_ => ErrorClass::Fatal,
|
||||
},
|
||||
_ => ErrorClass::Fatal,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_status(code: u16) -> ErrorClass {
|
||||
match code {
|
||||
429 | 500 | 502 | 503 | 504 => ErrorClass::RetryableStatus,
|
||||
_ => ErrorClass::Fatal,
|
||||
}
|
||||
}
|
||||
|
||||
/// Failure of a single attempt, carrying its retry classification.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AttemptError {
|
||||
pub(crate) class: ErrorClass,
|
||||
pub(crate) error: KmsError,
|
||||
}
|
||||
|
||||
/// Budgets applied by [`execute`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct RetryPolicy {
|
||||
/// Upper bound for one backend attempt.
|
||||
pub(crate) attempt_timeout: Duration,
|
||||
/// Upper bound for the whole operation, including retries and backoff.
|
||||
pub(crate) op_deadline: Duration,
|
||||
/// Maximum attempts for retryable operation classes; non-idempotent
|
||||
/// mutations always run exactly once regardless of this value.
|
||||
pub(crate) max_attempts: u32,
|
||||
/// Backoff cap before the first retry; doubles per retry.
|
||||
pub(crate) base_backoff: Duration,
|
||||
/// Upper bound for a single backoff sleep.
|
||||
pub(crate) max_backoff: Duration,
|
||||
}
|
||||
|
||||
impl RetryPolicy {
|
||||
/// Derive the policy from the KMS configuration.
|
||||
///
|
||||
/// `timeout` and `retry_attempts` are taken with the config-level clamps
|
||||
/// applied. The operation deadline covers the worst-case budget of all
|
||||
/// attempts plus backoff, so it bounds runaway loops without cutting any
|
||||
/// attempt short; an independently configurable deadline is left to the
|
||||
/// admin-API follow-up.
|
||||
pub(crate) fn from_config(config: &KmsConfig) -> Self {
|
||||
let mut policy = Self {
|
||||
attempt_timeout: config.effective_timeout(),
|
||||
op_deadline: Duration::ZERO,
|
||||
max_attempts: config.effective_retry_attempts(),
|
||||
base_backoff: DEFAULT_BASE_BACKOFF,
|
||||
max_backoff: DEFAULT_MAX_BACKOFF,
|
||||
};
|
||||
policy.op_deadline = policy.worst_case_budget();
|
||||
policy
|
||||
}
|
||||
|
||||
/// Total worst-case duration: every attempt hits `attempt_timeout` and
|
||||
/// every backoff sleeps its full cap.
|
||||
fn worst_case_budget(&self) -> Duration {
|
||||
let attempts = self.max_attempts.max(1);
|
||||
let mut budget = self.attempt_timeout.saturating_mul(attempts);
|
||||
for completed in 1..attempts {
|
||||
budget = budget.saturating_add(backoff_cap(self, completed));
|
||||
}
|
||||
budget
|
||||
}
|
||||
}
|
||||
|
||||
/// Exponential backoff cap after `completed_attempts` failed attempts.
|
||||
fn backoff_cap(policy: &RetryPolicy, completed_attempts: u32) -> Duration {
|
||||
let doublings = completed_attempts.saturating_sub(1).min(31);
|
||||
policy.base_backoff.saturating_mul(1u32 << doublings).min(policy.max_backoff)
|
||||
}
|
||||
|
||||
/// Equal jitter: sleep within `[cap / 2, cap]`, keeping at least half the cap
|
||||
/// so backoff still backs off while decorrelating retry bursts.
|
||||
fn equal_jitter(rng: &mut impl RngExt, cap: Duration) -> Duration {
|
||||
let half = cap / 2;
|
||||
let spread = u64::try_from(half.as_nanos()).unwrap_or(u64::MAX);
|
||||
half + Duration::from_nanos(rng.random_range(0..=spread))
|
||||
}
|
||||
|
||||
/// Run `attempt` under the policy.
|
||||
///
|
||||
/// Each attempt is bounded by `attempt_timeout` (further capped by whatever is
|
||||
/// left of `op_deadline`), and retryable failures are replayed with exponential
|
||||
/// backoff and jitter when the operation class allows it. Cancellation aborts
|
||||
/// both in-flight attempts and backoff sleeps.
|
||||
///
|
||||
/// A timed-out attempt counts as a connection-class failure: the server may
|
||||
/// have processed the request, which is exactly why non-idempotent mutations
|
||||
/// are never replayed.
|
||||
pub(crate) async fn execute<T, F, Fut>(
|
||||
operation: &'static str,
|
||||
class: OpClass,
|
||||
policy: &RetryPolicy,
|
||||
cancel: &CancellationToken,
|
||||
attempt: F,
|
||||
) -> Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = std::result::Result<T, AttemptError>>,
|
||||
{
|
||||
// Seed an owned RNG up front: the thread-local RNG is not Send and must
|
||||
// not be held across await points.
|
||||
let mut rng = StdRng::from_rng(&mut rand::rng());
|
||||
execute_with_jitter(operation, class, policy, cancel, move |cap| equal_jitter(&mut rng, cap), attempt).await
|
||||
}
|
||||
|
||||
/// [`execute`] with an injectable jitter source so tests can pin deterministic
|
||||
/// backoff durations instead of asserting around random sleeps.
|
||||
pub(crate) async fn execute_with_jitter<T, F, Fut, J>(
|
||||
operation: &'static str,
|
||||
class: OpClass,
|
||||
policy: &RetryPolicy,
|
||||
cancel: &CancellationToken,
|
||||
mut jitter: J,
|
||||
mut attempt: F,
|
||||
) -> Result<T>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = std::result::Result<T, AttemptError>>,
|
||||
J: FnMut(Duration) -> Duration,
|
||||
{
|
||||
let deadline = Instant::now() + policy.op_deadline;
|
||||
let max_attempts = if class.retryable() { policy.max_attempts.max(1) } else { 1 };
|
||||
|
||||
let mut attempt_no = 0u32;
|
||||
loop {
|
||||
attempt_no += 1;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(KmsError::operation_cancelled(format!(
|
||||
"{operation} cancelled before attempt {attempt_no}"
|
||||
)));
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(KmsError::operation_timed_out(format!(
|
||||
"{operation} exceeded operation deadline of {:?}",
|
||||
policy.op_deadline
|
||||
)));
|
||||
}
|
||||
|
||||
let attempt_budget = policy.attempt_timeout.min(remaining);
|
||||
let outcome = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => {
|
||||
return Err(KmsError::operation_cancelled(format!("{operation} cancelled during attempt {attempt_no}")));
|
||||
}
|
||||
outcome = tokio::time::timeout(attempt_budget, attempt()) => outcome,
|
||||
};
|
||||
|
||||
let failure = match outcome {
|
||||
Ok(Ok(value)) => return Ok(value),
|
||||
Ok(Err(failure)) => failure,
|
||||
Err(_) => AttemptError {
|
||||
class: ErrorClass::RetryableConn,
|
||||
error: KmsError::operation_timed_out(format!(
|
||||
"{operation} attempt {attempt_no} timed out after {attempt_budget:?}"
|
||||
)),
|
||||
},
|
||||
};
|
||||
|
||||
if failure.class == ErrorClass::Fatal || attempt_no >= max_attempts {
|
||||
return Err(failure.error);
|
||||
}
|
||||
|
||||
let backoff = jitter(backoff_cap(policy, attempt_no));
|
||||
if backoff >= deadline.saturating_duration_since(Instant::now()) {
|
||||
// Not enough deadline budget left for another attempt.
|
||||
return Err(failure.error);
|
||||
}
|
||||
tracing::warn!(
|
||||
operation,
|
||||
attempt = attempt_no,
|
||||
error_class = ?failure.class,
|
||||
backoff = ?backoff,
|
||||
"KMS backend attempt failed with a retryable error; backing off before retry"
|
||||
);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => {
|
||||
return Err(KmsError::operation_cancelled(format!("{operation} cancelled during retry backoff")));
|
||||
}
|
||||
_ = tokio::time::sleep(backoff) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
type AttemptResult<T> = std::result::Result<T, AttemptError>;
|
||||
|
||||
fn policy_of(attempt_timeout_ms: u64, op_deadline_ms: u64, max_attempts: u32, base_ms: u64, max_ms: u64) -> RetryPolicy {
|
||||
RetryPolicy {
|
||||
attempt_timeout: Duration::from_millis(attempt_timeout_ms),
|
||||
op_deadline: Duration::from_millis(op_deadline_ms),
|
||||
max_attempts,
|
||||
base_backoff: Duration::from_millis(base_ms),
|
||||
max_backoff: Duration::from_millis(max_ms),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deterministic jitter: always sleep the full backoff cap.
|
||||
fn full_jitter(cap: Duration) -> Duration {
|
||||
cap
|
||||
}
|
||||
|
||||
fn retryable_conn_error() -> AttemptError {
|
||||
AttemptError {
|
||||
class: ErrorClass::RetryableConn,
|
||||
error: KmsError::backend_error("connection reset by peer"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn hung_attempt_fails_within_attempt_timeout() {
|
||||
let policy = policy_of(5_000, 60_000, 1, 100, 2_000);
|
||||
let cancel = CancellationToken::new();
|
||||
let started = Instant::now();
|
||||
|
||||
let result: Result<()> = execute_with_jitter("encrypt", OpClass::ReadIdempotent, &policy, &cancel, full_jitter, || {
|
||||
std::future::pending::<AttemptResult<()>>()
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(KmsError::OperationTimedOut { .. })), "got {result:?}");
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(5_000));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn retryable_status_retries_until_success() {
|
||||
let policy = policy_of(1_000, 60_000, 3, 100, 2_000);
|
||||
let cancel = CancellationToken::new();
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_in_attempt = calls.clone();
|
||||
let started = Instant::now();
|
||||
|
||||
let result =
|
||||
execute_with_jitter("generate_data_key", OpClass::ReadIdempotent, &policy, &cancel, full_jitter, move || {
|
||||
let calls = calls_in_attempt.clone();
|
||||
async move {
|
||||
if calls.fetch_add(1, Ordering::SeqCst) < 2 {
|
||||
Err(AttemptError {
|
||||
class: ErrorClass::RetryableStatus,
|
||||
error: KmsError::backend_error("throttled (429)"),
|
||||
})
|
||||
} else {
|
||||
Ok(7u32)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(result.expect("retries within budget must succeed"), 7);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
// Full-cap backoff: 100ms after attempt 1, 200ms after attempt 2.
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(300));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn fatal_error_is_not_retried() {
|
||||
let policy = policy_of(1_000, 60_000, 5, 100, 2_000);
|
||||
let cancel = CancellationToken::new();
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_in_attempt = calls.clone();
|
||||
let started = Instant::now();
|
||||
|
||||
let result: Result<()> =
|
||||
execute_with_jitter("decrypt", OpClass::ReadIdempotent, &policy, &cancel, full_jitter, move || {
|
||||
let calls = calls_in_attempt.clone();
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err(AttemptError {
|
||||
class: ErrorClass::Fatal,
|
||||
error: KmsError::access_denied("permission denied (403)"),
|
||||
})
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(KmsError::AccessDenied { .. })), "got {result:?}");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(started.elapsed(), Duration::ZERO);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn mutating_non_idempotent_runs_exactly_once() {
|
||||
let policy = policy_of(1_000, 60_000, 5, 100, 2_000);
|
||||
let cancel = CancellationToken::new();
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_in_attempt = calls.clone();
|
||||
|
||||
let result: Result<()> =
|
||||
execute_with_jitter("rotate_key", OpClass::MutatingNonIdempotent, &policy, &cancel, full_jitter, move || {
|
||||
let calls = calls_in_attempt.clone();
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err(retryable_conn_error())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
// Even a retryable failure must not replay a non-idempotent mutation.
|
||||
assert!(matches!(result, Err(KmsError::BackendError { .. })), "got {result:?}");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancel_aborts_backoff_immediately() {
|
||||
// Long backoff (10s cap) so a prompt return can only come from cancellation.
|
||||
let policy = policy_of(1_000, 600_000, 5, 10_000, 10_000);
|
||||
let cancel = CancellationToken::new();
|
||||
let canceller = cancel.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
canceller.cancel();
|
||||
});
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_in_attempt = calls.clone();
|
||||
let started = Instant::now();
|
||||
|
||||
let result: Result<()> =
|
||||
execute_with_jitter("decrypt", OpClass::ReadIdempotent, &policy, &cancel, full_jitter, move || {
|
||||
let calls = calls_in_attempt.clone();
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Err(retryable_conn_error())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(KmsError::OperationCancelled { .. })), "got {result:?}");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(500));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn cancelled_token_short_circuits_before_first_attempt() {
|
||||
let policy = policy_of(1_000, 60_000, 5, 100, 2_000);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_in_attempt = calls.clone();
|
||||
|
||||
let result: Result<()> =
|
||||
execute_with_jitter("list_keys", OpClass::ReadIdempotent, &policy, &cancel, full_jitter, move || {
|
||||
let calls = calls_in_attempt.clone();
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(KmsError::OperationCancelled { .. })), "got {result:?}");
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn total_duration_never_exceeds_deadline() {
|
||||
// Worst case without a deadline would be 5 * 10s + backoff; the 25s
|
||||
// deadline must cut both the attempt count and the final attempt short.
|
||||
let policy = policy_of(10_000, 25_000, 5, 100, 2_000);
|
||||
let cancel = CancellationToken::new();
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_in_attempt = calls.clone();
|
||||
let started = Instant::now();
|
||||
|
||||
let result: Result<()> =
|
||||
execute_with_jitter("describe_key", OpClass::ReadIdempotent, &policy, &cancel, full_jitter, move || {
|
||||
calls_in_attempt.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::pending::<AttemptResult<()>>()
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(KmsError::OperationTimedOut { .. })), "got {result:?}");
|
||||
// attempt 1: 10s + 100ms backoff; attempt 2: 10s + 200ms backoff;
|
||||
// attempt 3 runs with the residual 4.7s budget, then the deadline is
|
||||
// exhausted and no further backoff or attempt happens.
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 3);
|
||||
assert_eq!(started.elapsed(), Duration::from_millis(25_000));
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn default_jitter_stays_within_equal_jitter_bounds() {
|
||||
let policy = policy_of(1_000, 60_000, 3, 100, 2_000);
|
||||
let cancel = CancellationToken::new();
|
||||
let calls = Arc::new(AtomicU32::new(0));
|
||||
let calls_in_attempt = calls.clone();
|
||||
let started = Instant::now();
|
||||
|
||||
let result = execute("health_check", OpClass::ReadIdempotent, &policy, &cancel, move || {
|
||||
let calls = calls_in_attempt.clone();
|
||||
async move {
|
||||
if calls.fetch_add(1, Ordering::SeqCst) < 2 {
|
||||
Err(retryable_conn_error())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
result.expect("retries within budget must succeed");
|
||||
let elapsed = started.elapsed();
|
||||
// Equal jitter sleeps within [cap / 2, cap]; caps are 100ms then 200ms.
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(150) && elapsed <= Duration::from_millis(300),
|
||||
"elapsed {elapsed:?} outside equal-jitter bounds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_jitter_is_seed_deterministic_and_bounded() {
|
||||
let caps = [Duration::from_millis(100), Duration::from_millis(250), Duration::from_secs(2)];
|
||||
let mut first = StdRng::seed_from_u64(1569);
|
||||
let mut second = StdRng::seed_from_u64(1569);
|
||||
for cap in caps {
|
||||
let a = equal_jitter(&mut first, cap);
|
||||
let b = equal_jitter(&mut second, cap);
|
||||
assert_eq!(a, b, "same seed must yield the same jitter sequence");
|
||||
assert!(a >= cap / 2 && a <= cap, "jitter {a:?} outside [{:?}, {cap:?}]", cap / 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_caps_double_up_to_max() {
|
||||
let policy = policy_of(1_000, 60_000, 6, 100, 700);
|
||||
let caps: Vec<Duration> = (1..=5).map(|completed| backoff_cap(&policy, completed)).collect();
|
||||
assert_eq!(
|
||||
caps,
|
||||
vec![
|
||||
Duration::from_millis(100),
|
||||
Duration::from_millis(200),
|
||||
Duration::from_millis(400),
|
||||
Duration::from_millis(700),
|
||||
Duration::from_millis(700),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_policy_from_config_applies_clamps() {
|
||||
let config = KmsConfig {
|
||||
timeout: Duration::from_secs(3_600),
|
||||
retry_attempts: 50,
|
||||
..KmsConfig::default()
|
||||
};
|
||||
let policy = RetryPolicy::from_config(&config);
|
||||
assert_eq!(policy.attempt_timeout, Duration::from_secs(300));
|
||||
assert_eq!(policy.max_attempts, 10);
|
||||
// The deadline must cover the full worst case so it never cuts a
|
||||
// policy-conformant operation short.
|
||||
assert!(policy.op_deadline >= policy.attempt_timeout.saturating_mul(policy.max_attempts));
|
||||
|
||||
let in_range = KmsConfig::default();
|
||||
let policy = RetryPolicy::from_config(&in_range);
|
||||
assert_eq!(policy.attempt_timeout, in_range.timeout);
|
||||
assert_eq!(policy.max_attempts, in_range.retry_attempts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_vaultrs_matrix() {
|
||||
use rustify::errors::ClientError as RestError;
|
||||
use vaultrs::error::ClientError;
|
||||
|
||||
let api = |code: u16| ClientError::APIError { code, errors: vec![] };
|
||||
for code in [429u16, 500, 502, 503, 504] {
|
||||
assert_eq!(classify_vaultrs(&api(code)), ErrorClass::RetryableStatus, "status {code}");
|
||||
}
|
||||
for code in [400u16, 401, 403, 404, 405, 412, 501] {
|
||||
assert_eq!(classify_vaultrs(&api(code)), ErrorClass::Fatal, "status {code}");
|
||||
}
|
||||
|
||||
// Status errors whose body could not be parsed stay wrapped in the
|
||||
// rustify error; classification must still see the code.
|
||||
let raw_status = |code: u16| ClientError::RestClientError {
|
||||
source: RestError::ServerResponseError { code, content: None },
|
||||
};
|
||||
assert_eq!(classify_vaultrs(&raw_status(503)), ErrorClass::RetryableStatus);
|
||||
assert_eq!(classify_vaultrs(&raw_status(403)), ErrorClass::Fatal);
|
||||
|
||||
let send_failure = ClientError::RestClientError {
|
||||
source: RestError::RequestError {
|
||||
source: anyhow::anyhow!("connection refused"),
|
||||
url: "http://127.0.0.1:8200/v1/sys/health".to_string(),
|
||||
method: "GET".to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(classify_vaultrs(&send_failure), ErrorClass::RetryableConn);
|
||||
|
||||
let read_failure = ClientError::RestClientError {
|
||||
source: RestError::ResponseError {
|
||||
source: anyhow::anyhow!("connection reset by peer"),
|
||||
},
|
||||
};
|
||||
assert_eq!(classify_vaultrs(&read_failure), ErrorClass::RetryableConn);
|
||||
|
||||
let malformed = ClientError::JsonParseError {
|
||||
source: serde_json::from_str::<serde_json::Value>("{").expect_err("truncated JSON must not parse"),
|
||||
};
|
||||
assert_eq!(classify_vaultrs(&malformed), ErrorClass::Fatal);
|
||||
assert_eq!(classify_vaultrs(&ClientError::ResponseEmptyError), ErrorClass::Fatal);
|
||||
assert_eq!(classify_vaultrs(&ClientError::InvalidLoginMethodError), ErrorClass::Fatal);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
//! KMS service manager for dynamic configuration and runtime management
|
||||
|
||||
use crate::backends::vault_credentials::CredentialTaskHandle;
|
||||
use crate::backends::{KmsBackend, local::LocalKmsBackend};
|
||||
use crate::config::{BackendConfig, KmsConfig};
|
||||
use crate::error::{KmsError, Result};
|
||||
@@ -21,12 +22,13 @@ use crate::manager::KmsManager;
|
||||
use crate::service::ObjectEncryptionService;
|
||||
use arc_swap::ArcSwap;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::future::Future;
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
const LOG_COMPONENT_KMS: &str = "kms";
|
||||
@@ -93,6 +95,13 @@ pub enum KmsServiceStatus {
|
||||
Error(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KmsStartOutcome {
|
||||
Started,
|
||||
Restarted,
|
||||
AlreadyRunning,
|
||||
}
|
||||
|
||||
/// Service version information for zero-downtime reconfiguration
|
||||
#[derive(Clone)]
|
||||
struct ServiceVersion {
|
||||
@@ -102,18 +111,23 @@ struct ServiceVersion {
|
||||
service: Arc<ObjectEncryptionService>,
|
||||
/// The KMS manager instance
|
||||
manager: Arc<KmsManager>,
|
||||
/// Owner of the backend's credential renewal task, if the backend needs
|
||||
/// one. Stop shuts it down explicitly; reconfigure recycles it through
|
||||
/// the handle's cancel-on-drop behavior when the old version is discarded.
|
||||
credential_task: Option<Arc<CredentialTaskHandle>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RuntimeState {
|
||||
config: Option<KmsConfig>,
|
||||
status: KmsServiceStatus,
|
||||
current_service: Option<ServiceVersion>,
|
||||
}
|
||||
|
||||
/// Dynamic KMS service manager with versioned services for zero-downtime reconfiguration
|
||||
pub struct KmsServiceManager {
|
||||
/// Current service version (if running)
|
||||
/// Uses ArcSwap for atomic, lock-free service switching
|
||||
/// This allows instant atomic updates without blocking readers
|
||||
current_service: ArcSwap<Option<ServiceVersion>>,
|
||||
/// Current configuration
|
||||
config: Arc<RwLock<Option<KmsConfig>>>,
|
||||
/// Current status
|
||||
status: Arc<RwLock<KmsServiceStatus>>,
|
||||
/// Atomically published configuration, status, and current service.
|
||||
state: ArcSwap<RuntimeState>,
|
||||
/// Version counter (monotonically increasing)
|
||||
version_counter: Arc<AtomicU64>,
|
||||
/// Mutex to protect lifecycle operations (start, stop, reconfigure)
|
||||
@@ -125,9 +139,11 @@ impl KmsServiceManager {
|
||||
/// Create a new KMS service manager (not configured)
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
current_service: ArcSwap::from_pointee(None),
|
||||
config: Arc::new(RwLock::new(None)),
|
||||
status: Arc::new(RwLock::new(KmsServiceStatus::NotConfigured)),
|
||||
state: ArcSwap::from_pointee(RuntimeState {
|
||||
config: None,
|
||||
status: KmsServiceStatus::NotConfigured,
|
||||
current_service: None,
|
||||
}),
|
||||
version_counter: Arc::new(AtomicU64::new(0)),
|
||||
lifecycle_mutex: Arc::new(Mutex::new(())),
|
||||
}
|
||||
@@ -135,44 +151,67 @@ impl KmsServiceManager {
|
||||
|
||||
/// Get current service status
|
||||
pub async fn get_status(&self) -> KmsServiceStatus {
|
||||
self.status.read().await.clone()
|
||||
self.state.load().status.clone()
|
||||
}
|
||||
|
||||
/// Get current configuration (if any)
|
||||
pub async fn get_config(&self) -> Option<KmsConfig> {
|
||||
self.config.read().await.clone()
|
||||
self.state.load().config.clone()
|
||||
}
|
||||
|
||||
/// Get configuration for status and management responses without static key material.
|
||||
pub async fn get_redacted_config(&self) -> Option<KmsConfig> {
|
||||
let mut config = self.config.read().await.clone()?;
|
||||
let mut config = self.state.load().config.clone()?;
|
||||
Self::redact_config(&mut config);
|
||||
Some(config)
|
||||
}
|
||||
|
||||
/// Get status and redacted configuration from the same published snapshot.
|
||||
pub async fn get_redacted_state(&self) -> (KmsServiceStatus, Option<KmsConfig>) {
|
||||
let state = self.state.load();
|
||||
let mut config = state.config.clone();
|
||||
if let Some(config) = &mut config {
|
||||
Self::redact_config(config);
|
||||
}
|
||||
(state.status.clone(), config)
|
||||
}
|
||||
|
||||
fn redact_config(config: &mut KmsConfig) {
|
||||
if let BackendConfig::Static(static_config) = &mut config.backend_config {
|
||||
use zeroize::Zeroize;
|
||||
static_config.secret_key.zeroize();
|
||||
}
|
||||
Some(config)
|
||||
}
|
||||
|
||||
/// Configure KMS with new configuration
|
||||
pub async fn configure(&self, new_config: KmsConfig) -> Result<()> {
|
||||
let _guard = self.lifecycle_mutex.lock().await;
|
||||
self.configure_with_persistence(new_config, || async { Ok(()) }).await
|
||||
}
|
||||
|
||||
/// Configure KMS and publish the in-memory state only after persistence succeeds.
|
||||
///
|
||||
/// The persistence callback runs under the lifecycle lock and must not call
|
||||
/// another lifecycle method on this manager.
|
||||
pub async fn configure_with_persistence<Persist, PersistFuture>(&self, new_config: KmsConfig, persist: Persist) -> Result<()>
|
||||
where
|
||||
Persist: FnOnce() -> PersistFuture,
|
||||
PersistFuture: Future<Output = Result<()>>,
|
||||
{
|
||||
new_config.validate()?;
|
||||
{
|
||||
let config = self.config.read().await;
|
||||
validate_local_transition(config.as_ref(), &new_config)?;
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
{
|
||||
let mut config = self.config.write().await;
|
||||
*config = Some(new_config.clone());
|
||||
}
|
||||
|
||||
// Update status
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
*status = KmsServiceStatus::Configured;
|
||||
let _guard = self.lifecycle_mutex.lock().await;
|
||||
let current = self.state.load_full();
|
||||
validate_local_transition(current.config.as_ref(), &new_config)?;
|
||||
if current.current_service.is_some() {
|
||||
return Err(KmsError::configuration_error(
|
||||
"Cannot configure KMS while it is running; use reconfigure instead",
|
||||
));
|
||||
}
|
||||
persist().await?;
|
||||
self.state.store(Arc::new(RuntimeState {
|
||||
config: Some(new_config),
|
||||
status: KmsServiceStatus::Configured,
|
||||
current_service: None,
|
||||
}));
|
||||
|
||||
debug!(
|
||||
event = EVENT_KMS_SERVICE_STATE,
|
||||
@@ -190,19 +229,35 @@ impl KmsServiceManager {
|
||||
self.start_internal().await
|
||||
}
|
||||
|
||||
/// Start or restart KMS with the running-state decision serialized with the lifecycle action.
|
||||
pub async fn start_or_restart(&self, force: bool) -> Result<KmsStartOutcome> {
|
||||
let _guard = self.lifecycle_mutex.lock().await;
|
||||
let running = self.state.load().current_service.is_some();
|
||||
if running && !force {
|
||||
return Ok(KmsStartOutcome::AlreadyRunning);
|
||||
}
|
||||
self.start_internal().await?;
|
||||
Ok(if running {
|
||||
KmsStartOutcome::Restarted
|
||||
} else {
|
||||
KmsStartOutcome::Started
|
||||
})
|
||||
}
|
||||
|
||||
/// Internal start implementation (called within lifecycle mutex)
|
||||
async fn start_internal(&self) -> Result<()> {
|
||||
let config = {
|
||||
let config_guard = self.config.read().await;
|
||||
match config_guard.as_ref() {
|
||||
Some(config) => config.clone(),
|
||||
None => {
|
||||
let err_msg = "Cannot start KMS: no configuration provided";
|
||||
error!("{}", err_msg);
|
||||
let mut status = self.status.write().await;
|
||||
*status = KmsServiceStatus::Error(err_msg.to_string());
|
||||
return Err(KmsError::configuration_error(err_msg));
|
||||
}
|
||||
let state = self.state.load_full();
|
||||
let config = match state.config.as_ref() {
|
||||
Some(config) => config.clone(),
|
||||
None => {
|
||||
let err_msg = "Cannot start KMS: no configuration provided";
|
||||
error!("{}", err_msg);
|
||||
self.state.store(Arc::new(RuntimeState {
|
||||
config: None,
|
||||
status: KmsServiceStatus::Error(err_msg.to_string()),
|
||||
current_service: None,
|
||||
}));
|
||||
return Err(KmsError::configuration_error(err_msg));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -215,17 +270,9 @@ impl KmsServiceManager {
|
||||
"KMS service starting"
|
||||
);
|
||||
|
||||
match self.create_service_version(&config).await {
|
||||
match self.create_healthy_service_version(&config).await {
|
||||
Ok(service_version) => {
|
||||
// Atomically update to new service version (lock-free, instant)
|
||||
// ArcSwap::store() is a true atomic operation using CAS
|
||||
self.current_service.store(Arc::new(Some(service_version)));
|
||||
|
||||
// Update status
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
*status = KmsServiceStatus::Running;
|
||||
}
|
||||
self.publish_running(config, service_version);
|
||||
|
||||
debug!(
|
||||
event = EVENT_KMS_SERVICE_STATE,
|
||||
@@ -239,13 +286,24 @@ impl KmsServiceManager {
|
||||
Err(e) => {
|
||||
let err_msg = format!("Failed to create KMS backend: {e}");
|
||||
error!("{}", err_msg);
|
||||
let mut status = self.status.write().await;
|
||||
*status = KmsServiceStatus::Error(err_msg.clone());
|
||||
if state.current_service.is_none() {
|
||||
self.state.store(Arc::new(RuntimeState {
|
||||
config: state.config.clone(),
|
||||
status: KmsServiceStatus::Error(err_msg.clone()),
|
||||
current_service: None,
|
||||
}));
|
||||
}
|
||||
Err(KmsError::backend_error(&err_msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the running service without exposing a stopped interval.
|
||||
pub async fn restart(&self) -> Result<()> {
|
||||
let _guard = self.lifecycle_mutex.lock().await;
|
||||
self.start_internal().await
|
||||
}
|
||||
|
||||
/// Stop KMS service
|
||||
///
|
||||
/// Note: This stops accepting new operations, but existing operations using
|
||||
@@ -267,14 +325,22 @@ impl KmsServiceManager {
|
||||
|
||||
// Atomically clear current service version (lock-free, instant)
|
||||
// Note: Existing Arc references will keep the service alive until operations complete
|
||||
self.current_service.store(Arc::new(None));
|
||||
let state = self.state.load_full();
|
||||
self.state.store(Arc::new(RuntimeState {
|
||||
config: state.config.clone(),
|
||||
status: if state.config.is_some() {
|
||||
KmsServiceStatus::Configured
|
||||
} else {
|
||||
KmsServiceStatus::NotConfigured
|
||||
},
|
||||
current_service: None,
|
||||
}));
|
||||
|
||||
// Update status (keep configuration)
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
if !matches!(*status, KmsServiceStatus::NotConfigured) {
|
||||
*status = KmsServiceStatus::Configured;
|
||||
}
|
||||
// Shut down the stopped version's credential renewal task before
|
||||
// reporting stopped, so stop deterministically recycles the background
|
||||
// task even while in-flight operations still hold the old service Arc.
|
||||
if let Some(task) = state.current_service.as_ref().and_then(|sv| sv.credential_task.clone()) {
|
||||
task.shutdown().await;
|
||||
}
|
||||
|
||||
debug!(
|
||||
@@ -298,6 +364,22 @@ impl KmsServiceManager {
|
||||
/// This ensures zero downtime during reconfiguration, even for long-running
|
||||
/// operations like encrypting large files.
|
||||
pub async fn reconfigure(&self, new_config: KmsConfig) -> Result<()> {
|
||||
self.reconfigure_with_persistence(new_config, || async { Ok(()) }).await
|
||||
}
|
||||
|
||||
/// Reconfigure KMS after the candidate is healthy and persistence succeeds.
|
||||
///
|
||||
/// The persistence callback runs under the lifecycle lock and must not call
|
||||
/// another lifecycle method on this manager.
|
||||
pub async fn reconfigure_with_persistence<Persist, PersistFuture>(
|
||||
&self,
|
||||
new_config: KmsConfig,
|
||||
persist: Persist,
|
||||
) -> Result<()>
|
||||
where
|
||||
Persist: FnOnce() -> PersistFuture,
|
||||
PersistFuture: Future<Output = Result<()>>,
|
||||
{
|
||||
let _guard = self.lifecycle_mutex.lock().await;
|
||||
|
||||
debug!(
|
||||
@@ -308,33 +390,18 @@ impl KmsServiceManager {
|
||||
"KMS service reconfiguring"
|
||||
);
|
||||
new_config.validate()?;
|
||||
{
|
||||
let config = self.config.read().await;
|
||||
validate_local_transition(config.as_ref(), &new_config)?;
|
||||
}
|
||||
validate_local_transition(self.state.load().config.as_ref(), &new_config)?;
|
||||
|
||||
// Create new service version without stopping old one
|
||||
// This allows existing operations to continue while new operations use new service
|
||||
match self.create_service_version(&new_config).await {
|
||||
match self.create_healthy_service_version(&new_config).await {
|
||||
Ok(new_service_version) => {
|
||||
// Get old version for logging (lock-free read)
|
||||
let old_version = self.current_service.load().as_ref().as_ref().map(|sv| sv.version);
|
||||
let old_version = self.state.load().current_service.as_ref().map(|sv| sv.version);
|
||||
|
||||
{
|
||||
let mut config = self.config.write().await;
|
||||
*config = Some(new_config);
|
||||
}
|
||||
persist().await?;
|
||||
|
||||
// Atomically switch to new service version (lock-free, instant CAS operation)
|
||||
// This is a true atomic operation - no waiting for locks, instant switch
|
||||
// Old service will be dropped when no more Arc references exist
|
||||
self.current_service.store(Arc::new(Some(new_service_version.clone())));
|
||||
|
||||
// Update status
|
||||
{
|
||||
let mut status = self.status.write().await;
|
||||
*status = KmsServiceStatus::Running;
|
||||
}
|
||||
self.publish_running(new_config, new_service_version.clone());
|
||||
|
||||
if let Some(old_ver) = old_version {
|
||||
info!(
|
||||
@@ -371,7 +438,7 @@ impl KmsServiceManager {
|
||||
/// Returns the manager from the current service version.
|
||||
/// Uses lock-free atomic load for optimal performance.
|
||||
pub async fn get_manager(&self) -> Option<Arc<KmsManager>> {
|
||||
self.current_service.load().as_ref().as_ref().map(|sv| sv.manager.clone())
|
||||
self.state.load().current_service.as_ref().map(|sv| sv.manager.clone())
|
||||
}
|
||||
|
||||
/// Get encryption service (if running)
|
||||
@@ -381,7 +448,7 @@ impl KmsServiceManager {
|
||||
/// This ensures new operations always use the latest service version,
|
||||
/// while existing operations continue using their Arc references.
|
||||
pub async fn get_encryption_service(&self) -> Option<Arc<ObjectEncryptionService>> {
|
||||
self.current_service.load().as_ref().as_ref().map(|sv| sv.service.clone())
|
||||
self.state.load().current_service.as_ref().map(|sv| sv.service.clone())
|
||||
}
|
||||
|
||||
/// Get current service version number
|
||||
@@ -389,14 +456,16 @@ impl KmsServiceManager {
|
||||
/// Useful for monitoring and debugging.
|
||||
/// Uses lock-free atomic load.
|
||||
pub async fn get_service_version(&self) -> Option<u64> {
|
||||
self.current_service.load().as_ref().as_ref().map(|sv| sv.version)
|
||||
self.state.load().current_service.as_ref().map(|sv| sv.version)
|
||||
}
|
||||
|
||||
/// Health check for the KMS service
|
||||
pub async fn health_check(&self) -> Result<bool> {
|
||||
let manager = self.get_manager().await;
|
||||
match manager {
|
||||
Some(manager) => {
|
||||
let checked_state = self.state.load_full();
|
||||
match checked_state.current_service.as_ref() {
|
||||
Some(service_version) => {
|
||||
let manager = service_version.manager.clone();
|
||||
let checked_version = service_version.version;
|
||||
// Perform health check on the backend
|
||||
match manager.health_check().await {
|
||||
Ok(healthy) => {
|
||||
@@ -407,9 +476,8 @@ impl KmsServiceManager {
|
||||
}
|
||||
Err(e) => {
|
||||
error!("KMS health check error: {}", e);
|
||||
// Update status to error
|
||||
let mut status = self.status.write().await;
|
||||
*status = KmsServiceStatus::Error(format!("Health check failed: {e}"));
|
||||
let _guard = self.lifecycle_mutex.lock().await;
|
||||
self.mark_health_error_if_current(checked_version, &e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
@@ -432,7 +500,10 @@ impl KmsServiceManager {
|
||||
|
||||
info!("Creating KMS service version {} with backend: {:?}", version, config.backend);
|
||||
|
||||
// Create backend
|
||||
// Create backend. Vault backends may also spawn a background
|
||||
// credential renewal task whose owner handle lives on the service
|
||||
// version, so replacing the version recycles the task.
|
||||
let mut credential_task = None;
|
||||
let backend = match &config.backend_config {
|
||||
BackendConfig::Local(_) => {
|
||||
info!("Creating Local KMS backend for version {}", version);
|
||||
@@ -442,11 +513,13 @@ impl KmsServiceManager {
|
||||
BackendConfig::VaultKv2(_) => {
|
||||
info!("Creating Vault KV2 KMS backend for version {}", version);
|
||||
let backend = crate::backends::vault::VaultKmsBackend::new(config.clone()).await?;
|
||||
credential_task = backend.spawn_credential_renewal().map(Arc::new);
|
||||
Arc::new(backend) as Arc<dyn KmsBackend>
|
||||
}
|
||||
BackendConfig::VaultTransit(_) => {
|
||||
info!("Creating Vault Transit KMS backend for version {}", version);
|
||||
let backend = crate::backends::vault_transit::VaultTransitKmsBackend::new(config.clone()).await?;
|
||||
credential_task = backend.spawn_credential_renewal().map(Arc::new);
|
||||
Arc::new(backend) as Arc<dyn KmsBackend>
|
||||
}
|
||||
BackendConfig::Static(_) => {
|
||||
@@ -466,8 +539,36 @@ impl KmsServiceManager {
|
||||
version,
|
||||
service: encryption_service,
|
||||
manager: kms_manager,
|
||||
credential_task,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_healthy_service_version(&self, config: &KmsConfig) -> Result<ServiceVersion> {
|
||||
let service_version = self.create_service_version(config).await?;
|
||||
if !service_version.manager.health_check().await? {
|
||||
return Err(KmsError::backend_error("KMS backend health check failed"));
|
||||
}
|
||||
Ok(service_version)
|
||||
}
|
||||
|
||||
fn publish_running(&self, config: KmsConfig, service_version: ServiceVersion) {
|
||||
self.state.store(Arc::new(RuntimeState {
|
||||
config: Some(config),
|
||||
status: KmsServiceStatus::Running,
|
||||
current_service: Some(service_version),
|
||||
}));
|
||||
}
|
||||
|
||||
fn mark_health_error_if_current(&self, checked_version: u64, error: &KmsError) {
|
||||
let current = self.state.load_full();
|
||||
if current.current_service.as_ref().map(|version| version.version) == Some(checked_version) {
|
||||
self.state.store(Arc::new(RuntimeState {
|
||||
config: current.config.clone(),
|
||||
status: KmsServiceStatus::Error(format!("Health check failed: {error}")),
|
||||
current_service: current.current_service.clone(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for KmsServiceManager {
|
||||
@@ -500,6 +601,11 @@ pub async fn get_global_encryption_service() -> Option<Arc<ObjectEncryptionServi
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
|
||||
fn static_config(key_id: &str, fill: u8) -> KmsConfig {
|
||||
KmsConfig::static_kms(key_id.to_string(), BASE64_STANDARD.encode([fill; 32]))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_rejects_insecure_development_defaults_before_state_update() {
|
||||
@@ -517,8 +623,6 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn redacted_config_omits_static_key_material() {
|
||||
use base64::Engine as _;
|
||||
|
||||
let manager = KmsServiceManager::new();
|
||||
let encoded_key = base64::engine::general_purpose::STANDARD.encode([0x5au8; 32]);
|
||||
manager
|
||||
@@ -533,6 +637,136 @@ mod tests {
|
||||
assert!(static_config.secret_key.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_persistence_failure_leaves_state_unchanged() {
|
||||
let manager = KmsServiceManager::new();
|
||||
|
||||
let result = manager
|
||||
.configure_with_persistence(static_config("key-a", 0x11), || async { Err(KmsError::backend_error("persist failed")) })
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(manager.get_status().await, KmsServiceStatus::NotConfigured);
|
||||
assert!(manager.get_config().await.is_none());
|
||||
assert!(manager.get_encryption_service().await.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_rejects_running_service_without_changing_snapshot() {
|
||||
let manager = KmsServiceManager::new();
|
||||
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||
manager.start().await.expect("start");
|
||||
let version = manager.get_service_version().await;
|
||||
|
||||
let result = manager.configure(static_config("key-b", 0x22)).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||
assert_eq!(manager.get_service_version().await, version);
|
||||
assert_eq!(
|
||||
manager.get_config().await.and_then(|config| config.default_key_id),
|
||||
Some("key-a".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconfigure_persistence_failure_keeps_old_running_snapshot() {
|
||||
let manager = KmsServiceManager::new();
|
||||
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||
manager.start().await.expect("start");
|
||||
let old_version = manager.get_service_version().await;
|
||||
let old_service = manager.get_encryption_service().await.expect("old service");
|
||||
|
||||
let result = manager
|
||||
.reconfigure_with_persistence(static_config("key-b", 0x22), || async {
|
||||
Err(KmsError::backend_error("persist failed"))
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||
assert_eq!(manager.get_service_version().await, old_version);
|
||||
assert_eq!(
|
||||
manager.get_config().await.and_then(|config| config.default_key_id),
|
||||
Some("key-a".to_string())
|
||||
);
|
||||
assert!(Arc::ptr_eq(
|
||||
&old_service,
|
||||
&manager.get_encryption_service().await.expect("old service remains")
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconfigure_candidate_failure_keeps_old_running_snapshot() {
|
||||
let manager = KmsServiceManager::new();
|
||||
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||
manager.start().await.expect("start");
|
||||
let old_version = manager.get_service_version().await;
|
||||
let invalid_parent = tempfile::NamedTempFile::new().expect("temporary file");
|
||||
let invalid_config = KmsConfig::local(invalid_parent.path().join("keys")).with_insecure_development_defaults();
|
||||
|
||||
let result = manager.reconfigure(invalid_config).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||
assert_eq!(manager.get_service_version().await, old_version);
|
||||
assert_eq!(
|
||||
manager.get_config().await.and_then(|config| config.default_key_id),
|
||||
Some("key-a".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_never_unpublishes_the_running_service() {
|
||||
let manager = Arc::new(KmsServiceManager::new());
|
||||
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||
manager.start().await.expect("start");
|
||||
let old_version = manager.get_service_version().await.expect("old version");
|
||||
let restarting = {
|
||||
let manager = manager.clone();
|
||||
tokio::spawn(async move { manager.restart().await })
|
||||
};
|
||||
|
||||
while !restarting.is_finished() {
|
||||
assert!(manager.get_encryption_service().await.is_some());
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
restarting.await.expect("restart task").expect("restart");
|
||||
|
||||
assert!(manager.get_encryption_service().await.is_some());
|
||||
assert!(manager.get_service_version().await.expect("new version") > old_version);
|
||||
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_or_restart_decides_under_the_lifecycle_lock() {
|
||||
let manager = KmsServiceManager::new();
|
||||
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||
|
||||
assert_eq!(manager.start_or_restart(false).await.expect("initial start"), KmsStartOutcome::Started);
|
||||
let first_version = manager.get_service_version().await.expect("first version");
|
||||
assert_eq!(
|
||||
manager.start_or_restart(false).await.expect("already running"),
|
||||
KmsStartOutcome::AlreadyRunning
|
||||
);
|
||||
assert_eq!(manager.get_service_version().await, Some(first_version));
|
||||
assert_eq!(manager.start_or_restart(true).await.expect("forced restart"), KmsStartOutcome::Restarted);
|
||||
assert!(manager.get_service_version().await.expect("restarted version") > first_version);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_health_failure_cannot_poison_new_service_status() {
|
||||
let manager = KmsServiceManager::new();
|
||||
manager.configure(static_config("key-a", 0x11)).await.expect("configure");
|
||||
manager.start().await.expect("start");
|
||||
let old_version = manager.get_service_version().await.expect("old version");
|
||||
manager.restart().await.expect("restart");
|
||||
|
||||
manager.mark_health_error_if_current(old_version, &KmsError::backend_error("stale failure"));
|
||||
|
||||
assert_eq!(manager.get_status().await, KmsServiceStatus::Running);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forbidden_local_master_key_change_preserves_running_config_and_service() {
|
||||
use crate::types::{CreateKeyRequest, KeyUsage};
|
||||
|
||||
@@ -64,6 +64,7 @@ mod error;
|
||||
mod global;
|
||||
mod logging;
|
||||
pub mod metrics;
|
||||
mod node_identity;
|
||||
mod telemetry;
|
||||
|
||||
pub use cleaner::*;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
use crate::metrics::report::PrometheusMetric;
|
||||
use crate::metrics::schema::process_resource::*;
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
|
||||
/// Resource statistics for metrics collection.
|
||||
///
|
||||
@@ -30,6 +31,8 @@ use crate::metrics::schema::process_resource::*;
|
||||
/// this struct from their available data sources.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ResourceStats {
|
||||
/// Stable local node identity for labeling node-local process resource metrics
|
||||
pub server: String,
|
||||
/// CPU usage as a percentage (can exceed 100% on multi-core systems)
|
||||
pub cpu_percent: f64,
|
||||
/// Resident memory usage in bytes
|
||||
@@ -43,10 +46,14 @@ pub struct ResourceStats {
|
||||
/// Uses the metric descriptors from `metrics_type::process_resource` module.
|
||||
/// Returns a vector of Prometheus metrics for resource statistics.
|
||||
pub fn collect_resource_metrics(stats: &ResourceStats) -> Vec<PrometheusMetric> {
|
||||
let server_label = stats.server.as_str();
|
||||
vec![
|
||||
PrometheusMetric::from_descriptor(&PROCESS_CPU_PERCENT_MD, stats.cpu_percent),
|
||||
PrometheusMetric::from_descriptor(&PROCESS_MEMORY_BYTES_MD, stats.memory_bytes as f64),
|
||||
PrometheusMetric::from_descriptor(&PROCESS_UPTIME_SECONDS_MD, stats.uptime_seconds as f64),
|
||||
PrometheusMetric::from_descriptor(&PROCESS_CPU_PERCENT_MD, stats.cpu_percent)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&PROCESS_MEMORY_BYTES_MD, stats.memory_bytes as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&PROCESS_UPTIME_SECONDS_MD, stats.uptime_seconds as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -58,6 +65,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_collect_resource_metrics() {
|
||||
let stats = ResourceStats {
|
||||
server: "node1:9000".to_string(),
|
||||
cpu_percent: 45.5,
|
||||
memory_bytes: 1024 * 1024 * 256,
|
||||
uptime_seconds: 7200,
|
||||
@@ -67,6 +75,11 @@ mod tests {
|
||||
report_metrics(&metrics);
|
||||
|
||||
assert_eq!(metrics.len(), 3);
|
||||
assert!(
|
||||
metrics
|
||||
.iter()
|
||||
.all(|m| m.labels.iter().any(|(k, v)| *k == SERVER_LABEL && v == "node1:9000"))
|
||||
);
|
||||
|
||||
// Verify CPU metric
|
||||
let cpu_metric_name = PROCESS_CPU_PERCENT_MD.get_full_metric_name();
|
||||
@@ -97,13 +110,16 @@ mod tests {
|
||||
|
||||
for metric in &metrics {
|
||||
assert_eq!(metric.value, 0.0);
|
||||
assert!(metric.labels.is_empty());
|
||||
assert_eq!(metric.labels.len(), 1);
|
||||
assert_eq!(metric.labels[0].0, SERVER_LABEL);
|
||||
assert!(metric.labels[0].1.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_resource_metrics_high_cpu() {
|
||||
let stats = ResourceStats {
|
||||
server: "node1:9000".to_string(),
|
||||
cpu_percent: 150.0, // Can exceed 100% on multi-core systems
|
||||
memory_bytes: 0,
|
||||
uptime_seconds: 0,
|
||||
|
||||
@@ -25,11 +25,14 @@
|
||||
use crate::metrics::report::PrometheusMetric;
|
||||
use crate::metrics::schema::system_cpu::*;
|
||||
use crate::metrics::schema::system_process::{PROCESS_CPU_USAGE_MD, PROCESS_CPU_UTILIZATION_MD};
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// System CPU statistics.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CpuStats {
|
||||
/// Stable local node identity for labeling node-local CPU metrics
|
||||
pub server: String,
|
||||
/// Average CPU idle time (percentage, 0-100)
|
||||
pub avg_idle: f64,
|
||||
/// CPU load average over 1 minute
|
||||
@@ -56,11 +59,16 @@ pub struct ProcessCpuStats {
|
||||
/// Uses the metric descriptors from `metrics_type::system_cpu` module.
|
||||
/// Returns a vector of Prometheus metrics for CPU statistics.
|
||||
pub fn collect_cpu_metrics(stats: &CpuStats) -> Vec<PrometheusMetric> {
|
||||
let server_label = stats.server.as_str();
|
||||
vec![
|
||||
PrometheusMetric::from_descriptor(&SYS_CPU_AVG_IDLE_MD, stats.avg_idle),
|
||||
PrometheusMetric::from_descriptor(&SYS_CPU_LOAD_MD, stats.load_avg),
|
||||
PrometheusMetric::from_descriptor(&SYS_CPU_LOAD_PERC_MD, stats.load_avg_perc),
|
||||
PrometheusMetric::from_descriptor(&SYS_CPU_USAGE_PERC_MD, stats.usage_perc),
|
||||
PrometheusMetric::from_descriptor(&SYS_CPU_AVG_IDLE_MD, stats.avg_idle)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&SYS_CPU_LOAD_MD, stats.load_avg)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&SYS_CPU_LOAD_PERC_MD, stats.load_avg_perc)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&SYS_CPU_USAGE_PERC_MD, stats.usage_perc)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,10 +99,12 @@ pub fn collect_process_cpu_metrics(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::metrics::report::report_metrics;
|
||||
use crate::metrics::schema::system_process::{PROCESS_EXECUTABLE_NAME_LABEL, PROCESS_PID_LABEL};
|
||||
|
||||
#[test]
|
||||
fn test_collect_cpu_metrics() {
|
||||
let stats = CpuStats {
|
||||
server: "node1:9000".to_string(),
|
||||
avg_idle: 75.5,
|
||||
load_avg: 1.5,
|
||||
load_avg_perc: 37.5,
|
||||
@@ -108,6 +118,11 @@ mod tests {
|
||||
|
||||
// Verify that metric names are properly generated from descriptors
|
||||
assert!(metrics.iter().all(|m| m.name.starts_with("rustfs_system_cpu_")));
|
||||
assert!(
|
||||
metrics
|
||||
.iter()
|
||||
.all(|m| m.labels.iter().any(|(k, v)| *k == SERVER_LABEL && v == "node1:9000"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -118,13 +133,16 @@ mod tests {
|
||||
assert_eq!(metrics.len(), 4);
|
||||
for metric in &metrics {
|
||||
assert_eq!(metric.value, 0.0);
|
||||
assert!(metric.labels.is_empty());
|
||||
assert_eq!(metric.labels.len(), 1);
|
||||
assert_eq!(metric.labels[0].0, SERVER_LABEL);
|
||||
assert!(metric.labels[0].1.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_cpu_metrics_export_total_usage_under_honest_name() {
|
||||
let stats = CpuStats {
|
||||
server: "node1:9000".to_string(),
|
||||
avg_idle: 40.0,
|
||||
load_avg: 1.0,
|
||||
load_avg_perc: 25.0,
|
||||
@@ -171,8 +189,9 @@ mod tests {
|
||||
};
|
||||
|
||||
let labels = vec![
|
||||
("process_pid", Cow::Borrowed("12345")),
|
||||
("process_executable_name", Cow::Borrowed("rustfs")),
|
||||
(SERVER_LABEL, Cow::Borrowed("node1:9000")),
|
||||
(PROCESS_PID_LABEL, Cow::Borrowed("12345")),
|
||||
(PROCESS_EXECUTABLE_NAME_LABEL, Cow::Borrowed("rustfs")),
|
||||
];
|
||||
|
||||
let metrics = collect_process_cpu_metrics(&stats, Some(&labels));
|
||||
@@ -180,7 +199,8 @@ mod tests {
|
||||
|
||||
// All metrics should have the labels
|
||||
for metric in &metrics {
|
||||
assert_eq!(metric.labels.len(), 2);
|
||||
assert_eq!(metric.labels.len(), 3);
|
||||
assert!(metric.labels.iter().any(|(k, v)| *k == SERVER_LABEL && v == "node1:9000"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
use crate::metrics::report::PrometheusMetric;
|
||||
use crate::metrics::schema::system_drive::*;
|
||||
use crate::metrics::schema::system_process::PROCESS_DISK_IO_MD;
|
||||
use crate::metrics::schema::system_process::{DIRECTION_LABEL, PROCESS_DISK_IO_MD};
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Detailed drive statistics for a single drive.
|
||||
@@ -223,8 +223,8 @@ pub fn collect_process_disk_metrics(
|
||||
let mut read_metric = PrometheusMetric::from_descriptor(&PROCESS_DISK_IO_MD, stats.read_bytes as f64);
|
||||
let mut write_metric = PrometheusMetric::from_descriptor(&PROCESS_DISK_IO_MD, stats.written_bytes as f64);
|
||||
|
||||
read_metric.labels.push(("direction", Cow::Borrowed("read")));
|
||||
write_metric.labels.push(("direction", Cow::Borrowed("write")));
|
||||
read_metric.labels.push((DIRECTION_LABEL, Cow::Borrowed("read")));
|
||||
write_metric.labels.push((DIRECTION_LABEL, Cow::Borrowed("write")));
|
||||
|
||||
if let Some(l) = labels {
|
||||
read_metric.labels.extend(l.iter().map(|(k, v)| (*k, v.clone())));
|
||||
@@ -238,6 +238,7 @@ pub fn collect_process_disk_metrics(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::metrics::report::report_metrics;
|
||||
use crate::metrics::schema::system_process::{PROCESS_EXECUTABLE_NAME_LABEL, PROCESS_PID_LABEL};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn assert_metric_label_keys(
|
||||
@@ -371,4 +372,32 @@ mod tests {
|
||||
assert!(offline.is_some());
|
||||
assert_eq!(offline.map(|m| m.value), Some(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_process_disk_metrics_with_node_and_process_labels() {
|
||||
let stats = ProcessDiskStats {
|
||||
read_bytes: 1024,
|
||||
written_bytes: 2048,
|
||||
};
|
||||
let labels = vec![
|
||||
(SERVER_LABEL, Cow::Borrowed("node1:9000")),
|
||||
(PROCESS_PID_LABEL, Cow::Borrowed("12345")),
|
||||
(PROCESS_EXECUTABLE_NAME_LABEL, Cow::Borrowed("rustfs")),
|
||||
];
|
||||
|
||||
let metrics = collect_process_disk_metrics(&stats, Some(&labels));
|
||||
|
||||
assert_eq!(metrics.len(), 2);
|
||||
assert_metric_label_keys(
|
||||
&metrics,
|
||||
&PROCESS_DISK_IO_MD,
|
||||
1024.0,
|
||||
&[
|
||||
DIRECTION_LABEL,
|
||||
SERVER_LABEL,
|
||||
PROCESS_PID_LABEL,
|
||||
PROCESS_EXECUTABLE_NAME_LABEL,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,14 @@
|
||||
use crate::metrics::report::PrometheusMetric;
|
||||
use crate::metrics::schema::system_memory::*;
|
||||
use crate::metrics::schema::system_process::{PROCESS_RESIDENT_MEMORY_BYTES_MD, PROCESS_VIRTUAL_MEMORY_BYTES_MD};
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// System memory statistics.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MemoryStats {
|
||||
/// Stable local node identity for labeling node-local memory metrics
|
||||
pub server: String,
|
||||
/// Total memory in bytes
|
||||
pub total: u64,
|
||||
/// Used memory in bytes
|
||||
@@ -64,15 +67,24 @@ pub struct ProcessMemoryStats {
|
||||
/// Uses the metric descriptors from `metrics_type::system_memory` module.
|
||||
/// Returns a vector of Prometheus metrics for memory statistics.
|
||||
pub fn collect_memory_metrics(stats: &MemoryStats) -> Vec<PrometheusMetric> {
|
||||
let server_label = stats.server.as_str();
|
||||
vec![
|
||||
PrometheusMetric::from_descriptor(&MEM_TOTAL_MD, stats.total as f64),
|
||||
PrometheusMetric::from_descriptor(&MEM_USED_MD, stats.used as f64),
|
||||
PrometheusMetric::from_descriptor(&MEM_USED_PERC_MD, stats.used_perc),
|
||||
PrometheusMetric::from_descriptor(&MEM_FREE_MD, stats.free as f64),
|
||||
PrometheusMetric::from_descriptor(&MEM_BUFFERS_MD, stats.buffers as f64),
|
||||
PrometheusMetric::from_descriptor(&MEM_CACHE_MD, stats.cache as f64),
|
||||
PrometheusMetric::from_descriptor(&MEM_SHARED_MD, stats.shared as f64),
|
||||
PrometheusMetric::from_descriptor(&MEM_AVAILABLE_MD, stats.available as f64),
|
||||
PrometheusMetric::from_descriptor(&MEM_TOTAL_MD, stats.total as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&MEM_USED_MD, stats.used as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&MEM_USED_PERC_MD, stats.used_perc)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&MEM_FREE_MD, stats.free as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&MEM_BUFFERS_MD, stats.buffers as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&MEM_CACHE_MD, stats.cache as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&MEM_SHARED_MD, stats.shared as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&MEM_AVAILABLE_MD, stats.available as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -104,10 +116,12 @@ pub fn collect_process_memory_metrics(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::metrics::report::report_metrics;
|
||||
use crate::metrics::schema::system_process::{PROCESS_EXECUTABLE_NAME_LABEL, PROCESS_PID_LABEL};
|
||||
|
||||
#[test]
|
||||
fn test_collect_memory_metrics() {
|
||||
let stats = MemoryStats {
|
||||
server: "node1:9000".to_string(),
|
||||
total: 16 * 1024 * 1024 * 1024, // 16 GB
|
||||
used: 8 * 1024 * 1024 * 1024, // 8 GB
|
||||
used_perc: 50.0,
|
||||
@@ -123,6 +137,11 @@ mod tests {
|
||||
|
||||
assert_eq!(metrics.len(), 8);
|
||||
assert!(metrics.iter().all(|m| m.name.starts_with("rustfs_system_memory_")));
|
||||
assert!(
|
||||
metrics
|
||||
.iter()
|
||||
.all(|m| m.labels.iter().any(|(k, v)| *k == SERVER_LABEL && v == "node1:9000"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -133,7 +152,9 @@ mod tests {
|
||||
assert_eq!(metrics.len(), 8);
|
||||
for metric in &metrics {
|
||||
assert_eq!(metric.value, 0.0);
|
||||
assert!(metric.labels.is_empty());
|
||||
assert_eq!(metric.labels.len(), 1);
|
||||
assert_eq!(metric.labels[0].0, SERVER_LABEL);
|
||||
assert!(metric.labels[0].1.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,13 +178,18 @@ mod tests {
|
||||
virtual_mem: 1024 * 1024 * 1024,
|
||||
};
|
||||
|
||||
let labels = vec![("process_pid", Cow::Borrowed("12345"))];
|
||||
let labels = vec![
|
||||
(SERVER_LABEL, Cow::Borrowed("node1:9000")),
|
||||
(PROCESS_PID_LABEL, Cow::Borrowed("12345")),
|
||||
(PROCESS_EXECUTABLE_NAME_LABEL, Cow::Borrowed("rustfs")),
|
||||
];
|
||||
|
||||
let metrics = collect_process_memory_metrics(&stats, Some(&labels));
|
||||
assert_eq!(metrics.len(), 2);
|
||||
|
||||
for metric in &metrics {
|
||||
assert_eq!(metric.labels.len(), 1);
|
||||
assert_eq!(metric.labels.len(), 3);
|
||||
assert!(metric.labels.iter().any(|(k, v)| *k == SERVER_LABEL && v == "node1:9000"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,13 @@
|
||||
|
||||
use crate::metrics::report::PrometheusMetric;
|
||||
use crate::metrics::schema::system_network::*;
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
|
||||
/// Network statistics for internode communication.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NetworkStats {
|
||||
/// Stable local node identity for labeling node-local internode metrics
|
||||
pub server: String,
|
||||
/// Total number of failed internode calls
|
||||
pub internode_errors_total: u64,
|
||||
/// Total number of TCP dial timeouts and errors
|
||||
@@ -44,12 +47,18 @@ pub struct NetworkStats {
|
||||
/// Uses the metric descriptors from `metrics_type::system_network` module.
|
||||
/// Returns a vector of Prometheus metrics for network statistics.
|
||||
pub fn collect_network_metrics(stats: &NetworkStats) -> Vec<PrometheusMetric> {
|
||||
let server_label = stats.server.as_str();
|
||||
vec![
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_ERRORS_TOTAL_MD, stats.internode_errors_total as f64),
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_DIAL_ERRORS_TOTAL_MD, stats.internode_dial_errors_total as f64),
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_DIAL_AVG_TIME_NANOS_MD, stats.internode_dial_avg_time_nanos as f64),
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_SENT_BYTES_TOTAL_MD, stats.internode_sent_bytes_total as f64),
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_RECV_BYTES_TOTAL_MD, stats.internode_recv_bytes_total as f64),
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_ERRORS_TOTAL_MD, stats.internode_errors_total as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_DIAL_ERRORS_TOTAL_MD, stats.internode_dial_errors_total as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_DIAL_AVG_TIME_NANOS_MD, stats.internode_dial_avg_time_nanos as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_SENT_BYTES_TOTAL_MD, stats.internode_sent_bytes_total as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
PrometheusMetric::from_descriptor(&INTERNODE_RECV_BYTES_TOTAL_MD, stats.internode_recv_bytes_total as f64)
|
||||
.with_label_owned(SERVER_LABEL, server_label.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -61,6 +70,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_collect_network_metrics() {
|
||||
let stats = NetworkStats {
|
||||
server: "node1:9000".to_string(),
|
||||
internode_errors_total: 10,
|
||||
internode_dial_errors_total: 5,
|
||||
internode_dial_avg_time_nanos: 1_500_000, // 1.5ms
|
||||
@@ -73,6 +83,11 @@ mod tests {
|
||||
|
||||
assert_eq!(metrics.len(), 5);
|
||||
assert!(metrics.iter().all(|m| m.name.contains("internode")));
|
||||
assert!(
|
||||
metrics
|
||||
.iter()
|
||||
.all(|m| m.labels.iter().any(|(k, v)| *k == SERVER_LABEL && v == "node1:9000"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -83,7 +98,9 @@ mod tests {
|
||||
assert_eq!(metrics.len(), 5);
|
||||
for metric in &metrics {
|
||||
assert_eq!(metric.value, 0.0);
|
||||
assert!(metric.labels.is_empty());
|
||||
assert_eq!(metric.labels.len(), 1);
|
||||
assert_eq!(metric.labels[0].0, SERVER_LABEL);
|
||||
assert!(metric.labels[0].1.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::metrics::report::PrometheusMetric;
|
||||
use crate::metrics::schema::system_network_host::{
|
||||
DIRECTION_LABEL, HOST_NETWORK_IO_MD, HOST_NETWORK_IO_PER_INTERFACE_MD, INTERFACE_LABEL,
|
||||
};
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use std::borrow::Cow;
|
||||
|
||||
/// Network I/O statistics.
|
||||
@@ -25,6 +26,8 @@ use std::borrow::Cow;
|
||||
/// Contains host-wide network I/O totals and per-interface counters.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct HostNetworkStats {
|
||||
/// Stable local node identity for labeling host-wide network metrics.
|
||||
pub server: String,
|
||||
/// Total bytes received across observed host interfaces.
|
||||
pub total_received: u64,
|
||||
/// Total bytes transmitted across observed host interfaces.
|
||||
@@ -47,7 +50,11 @@ pub fn collect_host_network_metrics(
|
||||
let mut received_metric = PrometheusMetric::from_descriptor(&HOST_NETWORK_IO_MD, stats.total_received as f64);
|
||||
let mut transmitted_metric = PrometheusMetric::from_descriptor(&HOST_NETWORK_IO_MD, stats.total_transmitted as f64);
|
||||
|
||||
received_metric.labels.push((SERVER_LABEL, Cow::Owned(stats.server.clone())));
|
||||
received_metric.labels.push((DIRECTION_LABEL, Cow::Borrowed("received")));
|
||||
transmitted_metric
|
||||
.labels
|
||||
.push((SERVER_LABEL, Cow::Owned(stats.server.clone())));
|
||||
transmitted_metric
|
||||
.labels
|
||||
.push((DIRECTION_LABEL, Cow::Borrowed("transmitted")));
|
||||
@@ -64,9 +71,13 @@ pub fn collect_host_network_metrics(
|
||||
let mut iface_received = PrometheusMetric::from_descriptor(&HOST_NETWORK_IO_PER_INTERFACE_MD, *received as f64);
|
||||
let mut iface_transmitted = PrometheusMetric::from_descriptor(&HOST_NETWORK_IO_PER_INTERFACE_MD, *transmitted as f64);
|
||||
|
||||
iface_received.labels.push((SERVER_LABEL, Cow::Owned(stats.server.clone())));
|
||||
iface_received.labels.push((INTERFACE_LABEL, Cow::Owned(interface.clone())));
|
||||
iface_received.labels.push((DIRECTION_LABEL, Cow::Borrowed("received")));
|
||||
|
||||
iface_transmitted
|
||||
.labels
|
||||
.push((SERVER_LABEL, Cow::Owned(stats.server.clone())));
|
||||
iface_transmitted
|
||||
.labels
|
||||
.push((INTERFACE_LABEL, Cow::Owned(interface.clone())));
|
||||
@@ -92,6 +103,7 @@ mod tests {
|
||||
#[test]
|
||||
fn host_network_metrics_use_dedicated_network_host_prefix() {
|
||||
let stats = HostNetworkStats {
|
||||
server: "node1:9000".to_string(),
|
||||
total_received: 1024,
|
||||
total_transmitted: 2048,
|
||||
per_interface: vec![("eth0".to_string(), 512, 256)],
|
||||
@@ -107,9 +119,9 @@ mod tests {
|
||||
);
|
||||
|
||||
let total_keys: BTreeSet<&str> = metrics[0].labels.iter().map(|(key, _)| *key).collect();
|
||||
assert_eq!(total_keys, BTreeSet::from([DIRECTION_LABEL]));
|
||||
assert_eq!(total_keys, BTreeSet::from([SERVER_LABEL, DIRECTION_LABEL]));
|
||||
|
||||
let per_interface_keys: BTreeSet<&str> = metrics[2].labels.iter().map(|(key, _)| *key).collect();
|
||||
assert_eq!(per_interface_keys, BTreeSet::from([DIRECTION_LABEL, INTERFACE_LABEL]));
|
||||
assert_eq!(per_interface_keys, BTreeSet::from([SERVER_LABEL, DIRECTION_LABEL, INTERFACE_LABEL]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
use crate::metrics::report::PrometheusMetric;
|
||||
use crate::metrics::schema::system_process::*;
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use std::borrow::Cow;
|
||||
use sysinfo::{Pid, ProcessStatus, System};
|
||||
|
||||
@@ -146,6 +147,8 @@ impl From<ProcessStatus> for ProcessStatusType {
|
||||
/// Process statistics for the RustFS server process.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ProcessStats {
|
||||
/// Stable local node identity for labeling node-local process metrics
|
||||
pub server: String,
|
||||
/// Total read locks held
|
||||
pub locks_read_total: u64,
|
||||
/// Total write locks held
|
||||
@@ -190,6 +193,7 @@ pub struct ProcessStats {
|
||||
///
|
||||
/// Returns a vector of Prometheus metrics for process statistics.
|
||||
pub fn collect_process_metrics(stats: &ProcessStats) -> Vec<PrometheusMetric> {
|
||||
let server_label = stats.server.as_str();
|
||||
let mut metrics = vec![
|
||||
PrometheusMetric::from_descriptor(&PROCESS_LOCKS_READ_TOTAL_MD, stats.locks_read_total as f64),
|
||||
PrometheusMetric::from_descriptor(&PROCESS_LOCKS_WRITE_TOTAL_MD, stats.locks_write_total as f64),
|
||||
@@ -209,12 +213,18 @@ pub fn collect_process_metrics(stats: &ProcessStats) -> Vec<PrometheusMetric> {
|
||||
PrometheusMetric::from_descriptor(&PROCESS_VIRTUAL_MEMORY_BYTES_MD, stats.virtual_memory_bytes as f64),
|
||||
PrometheusMetric::from_descriptor(&PROCESS_VIRTUAL_MEMORY_MAX_BYTES_MD, stats.virtual_memory_max_bytes as f64),
|
||||
];
|
||||
for metric in &mut metrics {
|
||||
metric.labels.push((SERVER_LABEL, Cow::Owned(server_label.to_string())));
|
||||
}
|
||||
|
||||
// Add process status metric
|
||||
let mut status_metric = PrometheusMetric::from_descriptor(&PROCESS_STATUS_MD, stats.status_value as f64);
|
||||
status_metric
|
||||
.labels
|
||||
.push(("status", Cow::Owned(format!("{:?}", stats.status))));
|
||||
.push((SERVER_LABEL, Cow::Owned(server_label.to_string())));
|
||||
status_metric
|
||||
.labels
|
||||
.push((STATUS_LABEL, Cow::Owned(format!("{:?}", stats.status))));
|
||||
metrics.push(status_metric);
|
||||
|
||||
metrics
|
||||
@@ -235,6 +245,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_collect_process_metrics() {
|
||||
let stats = ProcessStats {
|
||||
server: "node1:9000".to_string(),
|
||||
locks_read_total: 100,
|
||||
locks_write_total: 50,
|
||||
cpu_total_seconds: 1234.56,
|
||||
@@ -261,6 +272,11 @@ mod tests {
|
||||
|
||||
// 17 original metrics + 1 status metric = 18
|
||||
assert_eq!(metrics.len(), 18);
|
||||
assert!(
|
||||
metrics
|
||||
.iter()
|
||||
.all(|m| m.labels.iter().any(|(k, v)| *k == SERVER_LABEL && v == "node1:9000"))
|
||||
);
|
||||
|
||||
// Verify uptime
|
||||
let uptime_name = PROCESS_UPTIME_SECONDS_MD.get_full_metric_name();
|
||||
@@ -288,6 +304,7 @@ mod tests {
|
||||
|
||||
// 17 original metrics + 1 status metric = 18
|
||||
assert_eq!(metrics.len(), 18);
|
||||
assert!(metrics.iter().all(|m| m.labels.iter().any(|(k, _)| *k == SERVER_LABEL)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -296,7 +313,7 @@ mod tests {
|
||||
let result = collect_process_attributes();
|
||||
assert!(result.is_ok());
|
||||
|
||||
let attrs = result.unwrap();
|
||||
let attrs = result.expect("current process attributes should be collectable");
|
||||
assert!(attrs.pid > 0);
|
||||
assert!(!attrs.executable_name.is_empty());
|
||||
}
|
||||
@@ -319,7 +336,7 @@ mod tests {
|
||||
|
||||
let labels = attrs.to_labels();
|
||||
assert_eq!(labels.len(), 4);
|
||||
assert_eq!(labels[0].0, "process_pid");
|
||||
assert_eq!(labels[0].0, PROCESS_PID_LABEL);
|
||||
assert_eq!(labels[0].1, "12345");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ use crate::metrics::schema::notification_target::{
|
||||
NOTIFICATION_TARGET_FAILED_MESSAGES_MD, NOTIFICATION_TARGET_QUEUE_LENGTH_MD, NOTIFICATION_TARGET_TOTAL_MESSAGES_MD,
|
||||
TARGET_ID as NOTIFICATION_TARGET_ID_LABEL, TARGET_TYPE as NOTIFICATION_TARGET_TYPE_LABEL,
|
||||
};
|
||||
use crate::metrics::schema::system_process::{PROCESS_EXECUTABLE_NAME_LABEL, PROCESS_PID_LABEL};
|
||||
use crate::metrics::stats_collector::{
|
||||
ProcessMetricBundle, collect_bucket_replication_bandwidth_stats, collect_bucket_replication_detail_stats,
|
||||
collect_bucket_stats, collect_cluster_and_health_stats, collect_cluster_config_stats, collect_cluster_usage_metric_stats,
|
||||
@@ -100,6 +101,7 @@ use crate::metrics::stats_collector::{
|
||||
collect_process_metric_bundle_with, collect_replication_stats, collect_scanner_metric_stats,
|
||||
collect_system_cpu_and_memory_stats_with,
|
||||
};
|
||||
use crate::node_identity::{SERVER_LABEL, current_local_node_identity};
|
||||
use crate::telemetry::retire_metric_series;
|
||||
use futures_util::FutureExt;
|
||||
use rustfs_audit::audit_target_metrics;
|
||||
@@ -1509,7 +1511,7 @@ pub fn init_metrics_runtime(token: CancellationToken) {
|
||||
|
||||
let token_clone = token.clone();
|
||||
tokio::spawn(async move {
|
||||
let labels = current_process_metric_labels();
|
||||
let process_attribute_labels = current_process_attribute_labels();
|
||||
let mut host_system = System::new_all();
|
||||
let mut host_networks = Networks::new();
|
||||
let mut process_sampler = ProcessSampler::new();
|
||||
@@ -1560,6 +1562,7 @@ pub fn init_metrics_runtime(token: CancellationToken) {
|
||||
}
|
||||
|
||||
if now >= next_system_run {
|
||||
let labels = current_process_metric_labels(&process_attribute_labels);
|
||||
#[cfg(feature = "gpu")]
|
||||
let mut metrics =
|
||||
collect_system_monitoring_metrics(&bundle, &labels, &mut host_system, &mut host_networks);
|
||||
@@ -1686,24 +1689,33 @@ fn advance_deadline(deadline: &mut Instant, interval: Duration, now: Instant) {
|
||||
}
|
||||
}
|
||||
|
||||
fn current_process_metric_labels() -> Vec<(&'static str, Cow<'static, str>)> {
|
||||
fn current_process_attribute_labels() -> Vec<(&'static str, Cow<'static, str>)> {
|
||||
match collect_process_attributes() {
|
||||
Ok(attrs) => vec![
|
||||
("process_pid", Cow::Owned(attrs.pid.to_string())),
|
||||
("process_executable_name", Cow::Owned(attrs.executable_name)),
|
||||
(PROCESS_PID_LABEL, Cow::Owned(attrs.pid.to_string())),
|
||||
(PROCESS_EXECUTABLE_NAME_LABEL, Cow::Owned(attrs.executable_name)),
|
||||
],
|
||||
Err(err) => fallback_process_metric_labels(err),
|
||||
Err(err) => fallback_process_attribute_labels(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn fallback_process_metric_labels(err: ProcessAttributeError) -> Vec<(&'static str, Cow<'static, str>)> {
|
||||
fn fallback_process_attribute_labels(err: ProcessAttributeError) -> Vec<(&'static str, Cow<'static, str>)> {
|
||||
warn!(event = EVENT_METRICS_RUNTIME_STATE, component = LOG_COMPONENT_OBS, subsystem = LOG_SUBSYSTEM_METRICS_RUNTIME, collector = "process_metric_labels", result = "collect_failed", error = %err, "metrics runtime state changed");
|
||||
vec![
|
||||
("process_pid", Cow::Owned(std::process::id().to_string())),
|
||||
("process_executable_name", Cow::Borrowed("unknown")),
|
||||
(PROCESS_PID_LABEL, Cow::Owned(std::process::id().to_string())),
|
||||
(PROCESS_EXECUTABLE_NAME_LABEL, Cow::Borrowed("unknown")),
|
||||
]
|
||||
}
|
||||
|
||||
fn current_process_metric_labels(
|
||||
process_attribute_labels: &[(&'static str, Cow<'static, str>)],
|
||||
) -> Vec<(&'static str, Cow<'static, str>)> {
|
||||
let mut labels = Vec::with_capacity(process_attribute_labels.len() + 1);
|
||||
labels.push((SERVER_LABEL, Cow::Owned(current_local_node_identity())));
|
||||
labels.extend(process_attribute_labels.iter().map(|(key, value)| (*key, value.clone())));
|
||||
labels
|
||||
}
|
||||
|
||||
fn collect_system_monitoring_metrics(
|
||||
bundle: &ProcessMetricBundle,
|
||||
labels: &[(&'static str, Cow<'static, str>)],
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use crate::{MetricDescriptor, MetricName, MetricSubsystem, new_gauge_md};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -22,7 +23,7 @@ pub static PROCESS_CPU_PERCENT_MD: LazyLock<MetricDescriptor> = LazyLock::new(||
|
||||
new_gauge_md(
|
||||
MetricName::Custom("cpu_percent".to_string()),
|
||||
"CPU usage of the RustFS process as a percentage",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
MetricSubsystem::new("/process"),
|
||||
)
|
||||
});
|
||||
@@ -32,7 +33,7 @@ pub static PROCESS_MEMORY_BYTES_MD: LazyLock<MetricDescriptor> = LazyLock::new(|
|
||||
new_gauge_md(
|
||||
MetricName::Custom("memory_bytes".to_string()),
|
||||
"Resident memory usage of the RustFS process in bytes",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
MetricSubsystem::new("/process"),
|
||||
)
|
||||
});
|
||||
@@ -42,7 +43,7 @@ pub static PROCESS_UPTIME_SECONDS_MD: LazyLock<MetricDescriptor> = LazyLock::new
|
||||
new_gauge_md(
|
||||
MetricName::Custom("uptime_seconds".to_string()),
|
||||
"Uptime of the RustFS process in seconds",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
MetricSubsystem::new("/process"),
|
||||
)
|
||||
});
|
||||
|
||||
@@ -14,24 +14,37 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use crate::{MetricDescriptor, MetricName, new_gauge_md, subsystems};
|
||||
/// CPU system-related metric descriptors
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub static SYS_CPU_AVG_IDLE_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUAvgIdle, "Average CPU idle time", &[], subsystems::SYSTEM_CPU));
|
||||
pub static SYS_CPU_AVG_IDLE_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::SysCPUAvgIdle,
|
||||
"Average CPU idle time",
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_CPU,
|
||||
)
|
||||
});
|
||||
|
||||
pub static SYS_CPU_AVG_IOWAIT_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUAvgIOWait, "Average CPU IOWait time", &[], subsystems::SYSTEM_CPU));
|
||||
pub static SYS_CPU_AVG_IOWAIT_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::SysCPUAvgIOWait,
|
||||
"Average CPU IOWait time",
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_CPU,
|
||||
)
|
||||
});
|
||||
|
||||
pub static SYS_CPU_LOAD_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPULoad, "CPU load average 1min", &[], subsystems::SYSTEM_CPU));
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPULoad, "CPU load average 1min", &[SERVER_LABEL], subsystems::SYSTEM_CPU));
|
||||
|
||||
pub static SYS_CPU_LOAD_PERC_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::SysCPULoadPerc,
|
||||
"CPU load average 1min (percentage)",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_CPU,
|
||||
)
|
||||
});
|
||||
@@ -40,19 +53,19 @@ pub static SYS_CPU_USAGE_PERC_MD: LazyLock<MetricDescriptor> = LazyLock::new(||
|
||||
new_gauge_md(
|
||||
MetricName::Custom("usage_perc".to_string()),
|
||||
"Total CPU usage percentage across all measured CPU time categories",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_CPU,
|
||||
)
|
||||
});
|
||||
|
||||
pub static SYS_CPU_NICE_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUNice, "CPU nice time", &[], subsystems::SYSTEM_CPU));
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUNice, "CPU nice time", &[SERVER_LABEL], subsystems::SYSTEM_CPU));
|
||||
|
||||
pub static SYS_CPU_STEAL_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUSteal, "CPU steal time", &[], subsystems::SYSTEM_CPU));
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUSteal, "CPU steal time", &[SERVER_LABEL], subsystems::SYSTEM_CPU));
|
||||
|
||||
pub static SYS_CPU_SYSTEM_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUSystem, "CPU system time", &[], subsystems::SYSTEM_CPU));
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUSystem, "CPU system time", &[SERVER_LABEL], subsystems::SYSTEM_CPU));
|
||||
|
||||
pub static SYS_CPU_USER_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUUser, "CPU user time", &[], subsystems::SYSTEM_CPU));
|
||||
LazyLock::new(|| new_gauge_md(MetricName::SysCPUUser, "CPU user time", &[SERVER_LABEL], subsystems::SYSTEM_CPU));
|
||||
|
||||
@@ -14,43 +14,74 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use crate::{MetricDescriptor, MetricName, new_gauge_md, subsystems};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Total memory available on the node
|
||||
pub static MEM_TOTAL_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::MemTotal, "Total memory on the node", &[], subsystems::SYSTEM_MEMORY));
|
||||
pub static MEM_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::MemTotal,
|
||||
"Total memory on the node",
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_MEMORY,
|
||||
)
|
||||
});
|
||||
|
||||
/// Memory currently in use on the node
|
||||
pub static MEM_USED_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::MemUsed, "Used memory on the node", &[], subsystems::SYSTEM_MEMORY));
|
||||
LazyLock::new(|| new_gauge_md(MetricName::MemUsed, "Used memory on the node", &[SERVER_LABEL], subsystems::SYSTEM_MEMORY));
|
||||
|
||||
/// Percentage of total memory currently in use
|
||||
pub static MEM_USED_PERC_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::MemUsedPerc,
|
||||
"Used memory percentage on the node",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_MEMORY,
|
||||
)
|
||||
});
|
||||
|
||||
/// Memory not currently in use and available for allocation
|
||||
pub static MEM_FREE_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::MemFree, "Free memory on the node", &[], subsystems::SYSTEM_MEMORY));
|
||||
LazyLock::new(|| new_gauge_md(MetricName::MemFree, "Free memory on the node", &[SERVER_LABEL], subsystems::SYSTEM_MEMORY));
|
||||
|
||||
/// Memory used for file buffers by the kernel
|
||||
pub static MEM_BUFFERS_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::MemBuffers, "Buffers memory on the node", &[], subsystems::SYSTEM_MEMORY));
|
||||
pub static MEM_BUFFERS_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::MemBuffers,
|
||||
"Buffers memory on the node",
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_MEMORY,
|
||||
)
|
||||
});
|
||||
|
||||
/// Memory used for caching file data by the kernel
|
||||
pub static MEM_CACHE_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::MemCache, "Cache memory on the node", &[], subsystems::SYSTEM_MEMORY));
|
||||
pub static MEM_CACHE_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::MemCache,
|
||||
"Cache memory on the node",
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_MEMORY,
|
||||
)
|
||||
});
|
||||
|
||||
/// Memory shared between multiple processes
|
||||
pub static MEM_SHARED_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::MemShared, "Shared memory on the node", &[], subsystems::SYSTEM_MEMORY));
|
||||
pub static MEM_SHARED_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::MemShared,
|
||||
"Shared memory on the node",
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_MEMORY,
|
||||
)
|
||||
});
|
||||
|
||||
/// Estimate of memory available for new applications without swapping
|
||||
pub static MEM_AVAILABLE_MD: LazyLock<MetricDescriptor> =
|
||||
LazyLock::new(|| new_gauge_md(MetricName::MemAvailable, "Available memory on the node", &[], subsystems::SYSTEM_MEMORY));
|
||||
pub static MEM_AVAILABLE_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::MemAvailable,
|
||||
"Available memory on the node",
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_MEMORY,
|
||||
)
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use crate::{MetricDescriptor, MetricName, new_counter_md, new_gauge_md, subsystems};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -22,7 +23,7 @@ pub static INTERNODE_ERRORS_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock::new
|
||||
new_counter_md(
|
||||
MetricName::InternodeErrorsTotal,
|
||||
"Total number of failed internode calls",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_NETWORK_INTERNODE,
|
||||
)
|
||||
});
|
||||
@@ -32,7 +33,7 @@ pub static INTERNODE_DIAL_ERRORS_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock
|
||||
new_counter_md(
|
||||
MetricName::InternodeDialErrorsTotal,
|
||||
"Total number of internode TCP dial timeouts and errors",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_NETWORK_INTERNODE,
|
||||
)
|
||||
});
|
||||
@@ -42,7 +43,7 @@ pub static INTERNODE_DIAL_AVG_TIME_NANOS_MD: LazyLock<MetricDescriptor> = LazyLo
|
||||
new_gauge_md(
|
||||
MetricName::InternodeDialAvgTimeNanos,
|
||||
"Average dial time of internode TCP calls in nanoseconds",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_NETWORK_INTERNODE,
|
||||
)
|
||||
});
|
||||
@@ -52,7 +53,7 @@ pub static INTERNODE_SENT_BYTES_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock:
|
||||
new_counter_md(
|
||||
MetricName::InternodeSentBytesTotal,
|
||||
"Total number of bytes sent to other peer nodes",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_NETWORK_INTERNODE,
|
||||
)
|
||||
});
|
||||
@@ -62,7 +63,7 @@ pub static INTERNODE_RECV_BYTES_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock:
|
||||
new_counter_md(
|
||||
MetricName::InternodeRecvBytesTotal,
|
||||
"Total number of bytes received from other peer nodes",
|
||||
&[],
|
||||
&[SERVER_LABEL],
|
||||
subsystems::SYSTEM_NETWORK_INTERNODE,
|
||||
)
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use crate::{MetricDescriptor, MetricName, new_counter_md, subsystems};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -25,7 +26,7 @@ pub static HOST_NETWORK_IO_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_counter_md(
|
||||
MetricName::HostNetworkIO,
|
||||
"Network bytes transferred across system network interfaces",
|
||||
&[DIRECTION_LABEL],
|
||||
&[SERVER_LABEL, DIRECTION_LABEL],
|
||||
subsystems::SYSTEM_NETWORK_HOST,
|
||||
)
|
||||
});
|
||||
@@ -35,7 +36,7 @@ pub static HOST_NETWORK_IO_PER_INTERFACE_MD: LazyLock<MetricDescriptor> = LazyLo
|
||||
new_counter_md(
|
||||
MetricName::HostNetworkIOPerInterface,
|
||||
"Network bytes transferred across system network interfaces (per interface)",
|
||||
&[INTERFACE_LABEL, DIRECTION_LABEL],
|
||||
&[SERVER_LABEL, INTERFACE_LABEL, DIRECTION_LABEL],
|
||||
subsystems::SYSTEM_NETWORK_HOST,
|
||||
)
|
||||
});
|
||||
@@ -48,12 +49,19 @@ mod tests {
|
||||
#[test]
|
||||
fn host_network_descriptors_export_counter_labels() {
|
||||
assert_eq!(HOST_NETWORK_IO_MD.metric_type, MetricType::Counter);
|
||||
assert_eq!(HOST_NETWORK_IO_MD.variable_labels, vec![DIRECTION_LABEL.to_string()]);
|
||||
assert_eq!(
|
||||
HOST_NETWORK_IO_MD.variable_labels,
|
||||
vec![SERVER_LABEL.to_string(), DIRECTION_LABEL.to_string()]
|
||||
);
|
||||
|
||||
assert_eq!(HOST_NETWORK_IO_PER_INTERFACE_MD.metric_type, MetricType::Counter);
|
||||
assert_eq!(
|
||||
HOST_NETWORK_IO_PER_INTERFACE_MD.variable_labels,
|
||||
vec![INTERFACE_LABEL.to_string(), DIRECTION_LABEL.to_string()]
|
||||
vec![
|
||||
SERVER_LABEL.to_string(),
|
||||
INTERFACE_LABEL.to_string(),
|
||||
DIRECTION_LABEL.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,31 @@
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::node_identity::SERVER_LABEL;
|
||||
use crate::{MetricDescriptor, MetricName, new_counter_md, new_gauge_md, subsystems};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub const PROCESS_PID_LABEL: &str = "process_pid";
|
||||
pub const PROCESS_EXECUTABLE_NAME_LABEL: &str = "process_executable_name";
|
||||
pub const DIRECTION_LABEL: &str = "direction";
|
||||
pub const STATUS_LABEL: &str = "status";
|
||||
|
||||
const PROCESS_LABELS: &[&str] = &[SERVER_LABEL];
|
||||
const PROCESS_WITH_ATTRIBUTES_LABELS: &[&str] = &[SERVER_LABEL, PROCESS_PID_LABEL, PROCESS_EXECUTABLE_NAME_LABEL];
|
||||
const PROCESS_DISK_IO_LABELS: &[&str] = &[
|
||||
DIRECTION_LABEL,
|
||||
SERVER_LABEL,
|
||||
PROCESS_PID_LABEL,
|
||||
PROCESS_EXECUTABLE_NAME_LABEL,
|
||||
];
|
||||
const PROCESS_STATUS_LABELS: &[&str] = &[SERVER_LABEL, STATUS_LABEL];
|
||||
|
||||
/// Number of current READ locks on this peer
|
||||
pub static PROCESS_LOCKS_READ_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::ProcessLocksReadTotal,
|
||||
"Number of current READ locks on this peer",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -32,7 +48,7 @@ pub static PROCESS_LOCKS_WRITE_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock::
|
||||
new_gauge_md(
|
||||
MetricName::ProcessLocksWriteTotal,
|
||||
"Number of current WRITE locks on this peer",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -42,7 +58,7 @@ pub static PROCESS_CPU_TOTAL_SECONDS_MD: LazyLock<MetricDescriptor> = LazyLock::
|
||||
new_counter_md(
|
||||
MetricName::ProcessCPUTotalSeconds,
|
||||
"Total user and system CPU time spent in seconds",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -52,7 +68,7 @@ pub static PROCESS_GO_ROUTINE_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock::n
|
||||
new_gauge_md(
|
||||
MetricName::ProcessGoRoutineTotal,
|
||||
"Total number of go routines running",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -62,7 +78,7 @@ pub static PROCESS_IO_RCHAR_BYTES_MD: LazyLock<MetricDescriptor> = LazyLock::new
|
||||
new_counter_md(
|
||||
MetricName::ProcessIORCharBytes,
|
||||
"Total bytes read by the process from the underlying storage system including cache, /proc/[pid]/io rchar",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -72,7 +88,7 @@ pub static PROCESS_IO_READ_BYTES_MD: LazyLock<MetricDescriptor> = LazyLock::new(
|
||||
new_counter_md(
|
||||
MetricName::ProcessIOReadBytes,
|
||||
"Total bytes read by the process from the underlying storage system, /proc/[pid]/io read_bytes",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -82,7 +98,7 @@ pub static PROCESS_IO_WCHAR_BYTES_MD: LazyLock<MetricDescriptor> = LazyLock::new
|
||||
new_counter_md(
|
||||
MetricName::ProcessIOWCharBytes,
|
||||
"Total bytes written by the process to the underlying storage system including page cache, /proc/[pid]/io wchar",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -92,7 +108,7 @@ pub static PROCESS_IO_WRITE_BYTES_MD: LazyLock<MetricDescriptor> = LazyLock::new
|
||||
new_counter_md(
|
||||
MetricName::ProcessIOWriteBytes,
|
||||
"Total bytes written by the process to the underlying storage system, /proc/[pid]/io write_bytes",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -102,7 +118,7 @@ pub static PROCESS_START_TIME_SECONDS_MD: LazyLock<MetricDescriptor> = LazyLock:
|
||||
new_gauge_md(
|
||||
MetricName::ProcessStartTimeSeconds,
|
||||
"Start time for RustFS process in seconds since Unix epoch",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -112,7 +128,7 @@ pub static PROCESS_UPTIME_SECONDS_MD: LazyLock<MetricDescriptor> = LazyLock::new
|
||||
new_gauge_md(
|
||||
MetricName::ProcessUptimeSeconds,
|
||||
"Uptime for RustFS process in seconds",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -122,7 +138,7 @@ pub static PROCESS_FILE_DESCRIPTOR_LIMIT_TOTAL_MD: LazyLock<MetricDescriptor> =
|
||||
new_gauge_md(
|
||||
MetricName::ProcessFileDescriptorLimitTotal,
|
||||
"Limit on total number of open file descriptors for the RustFS Server process",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -132,7 +148,7 @@ pub static PROCESS_FILE_DESCRIPTOR_OPEN_TOTAL_MD: LazyLock<MetricDescriptor> = L
|
||||
new_gauge_md(
|
||||
MetricName::ProcessFileDescriptorOpenTotal,
|
||||
"Total number of open file descriptors by the RustFS Server process",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -142,7 +158,7 @@ pub static PROCESS_SYSCALL_READ_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock:
|
||||
new_counter_md(
|
||||
MetricName::ProcessSyscallReadTotal,
|
||||
"Total read SysCalls to the kernel. /proc/[pid]/io syscr",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -152,7 +168,7 @@ pub static PROCESS_SYSCALL_WRITE_TOTAL_MD: LazyLock<MetricDescriptor> = LazyLock
|
||||
new_counter_md(
|
||||
MetricName::ProcessSyscallWriteTotal,
|
||||
"Total write SysCalls to the kernel. /proc/[pid]/io syscw",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -162,7 +178,7 @@ pub static PROCESS_RESIDENT_MEMORY_BYTES_MD: LazyLock<MetricDescriptor> = LazyLo
|
||||
new_gauge_md(
|
||||
MetricName::ProcessResidentMemoryBytes,
|
||||
"Resident memory size in bytes",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -172,7 +188,7 @@ pub static PROCESS_VIRTUAL_MEMORY_BYTES_MD: LazyLock<MetricDescriptor> = LazyLoc
|
||||
new_gauge_md(
|
||||
MetricName::ProcessVirtualMemoryBytes,
|
||||
"Virtual memory size in bytes",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -182,7 +198,7 @@ pub static PROCESS_VIRTUAL_MEMORY_MAX_BYTES_MD: LazyLock<MetricDescriptor> = Laz
|
||||
new_gauge_md(
|
||||
MetricName::ProcessVirtualMemoryMaxBytes,
|
||||
"Maximum virtual memory size in bytes",
|
||||
&[],
|
||||
PROCESS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -196,7 +212,7 @@ pub static PROCESS_CPU_USAGE_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::ProcessCPUUsage,
|
||||
"The percentage of CPU in use by the process",
|
||||
&[],
|
||||
PROCESS_WITH_ATTRIBUTES_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -206,7 +222,7 @@ pub static PROCESS_CPU_UTILIZATION_MD: LazyLock<MetricDescriptor> = LazyLock::ne
|
||||
new_gauge_md(
|
||||
MetricName::ProcessCPUUtilization,
|
||||
"The amount of CPU in use by the process (considering multiple cores)",
|
||||
&[],
|
||||
PROCESS_WITH_ATTRIBUTES_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -216,7 +232,7 @@ pub static PROCESS_DISK_IO_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::ProcessDiskIO,
|
||||
"Disk bytes transferred by the process",
|
||||
&[],
|
||||
PROCESS_DISK_IO_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
@@ -226,7 +242,7 @@ pub static PROCESS_STATUS_MD: LazyLock<MetricDescriptor> = LazyLock::new(|| {
|
||||
new_gauge_md(
|
||||
MetricName::ProcessStatus,
|
||||
"Process status (0: Running, 1: Sleeping, 2: Zombie, 3: Other)",
|
||||
&[],
|
||||
PROCESS_STATUS_LABELS,
|
||||
subsystems::SYSTEM_PROCESS,
|
||||
)
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ use crate::metrics::{
|
||||
obs_load_compression_total_from_memory, obs_load_data_usage_from_backend, obs_replication_site_stats_snapshot,
|
||||
obs_resolve_object_store_handle,
|
||||
};
|
||||
use crate::node_identity::current_local_node_identity;
|
||||
use chrono::Utc;
|
||||
use rustfs_common::heal_channel::HealScanMode;
|
||||
use rustfs_common::metrics::{ScannerMetricsReport, global_metrics};
|
||||
@@ -539,12 +540,13 @@ pub async fn collect_disk_stats() -> Vec<DiskStats> {
|
||||
disk_stats
|
||||
}
|
||||
|
||||
fn build_system_cpu_stats(system: &System) -> CpuStats {
|
||||
fn build_system_cpu_stats(system: &System, server: &str) -> CpuStats {
|
||||
let cpu_usage = system.global_cpu_usage() as f64;
|
||||
let cpu_count = system.cpus().len().max(1) as f64;
|
||||
let load_avg = System::load_average().one;
|
||||
|
||||
CpuStats {
|
||||
server: server.to_string(),
|
||||
avg_idle: (100.0 - cpu_usage).max(0.0),
|
||||
load_avg,
|
||||
load_avg_perc: (load_avg / cpu_count) * 100.0,
|
||||
@@ -552,11 +554,12 @@ fn build_system_cpu_stats(system: &System) -> CpuStats {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_system_memory_stats(system: &System) -> MemoryStats {
|
||||
fn build_system_memory_stats(system: &System, server: &str) -> MemoryStats {
|
||||
let total = system.total_memory();
|
||||
let used = system.used_memory();
|
||||
|
||||
MemoryStats {
|
||||
server: server.to_string(),
|
||||
total,
|
||||
used,
|
||||
used_perc: if total > 0 {
|
||||
@@ -582,7 +585,8 @@ pub fn collect_system_cpu_and_memory_stats() -> (CpuStats, MemoryStats) {
|
||||
pub fn collect_system_cpu_and_memory_stats_with(system: &mut System) -> (CpuStats, MemoryStats) {
|
||||
system.refresh_cpu_all();
|
||||
system.refresh_memory();
|
||||
(build_system_cpu_stats(system), build_system_memory_stats(system))
|
||||
let server = current_local_node_identity();
|
||||
(build_system_cpu_stats(system, &server), build_system_memory_stats(system, &server))
|
||||
}
|
||||
|
||||
/// Collect system CPU statistics from the current host.
|
||||
@@ -692,6 +696,7 @@ fn process_metric_bundle_from_snapshots(
|
||||
resource_snapshot: ProcessResourceSnapshot,
|
||||
process_snapshot: ProcessSystemSnapshot,
|
||||
) -> ProcessMetricBundle {
|
||||
let server = current_local_node_identity();
|
||||
let status = match process_snapshot.status {
|
||||
ProcessStatusSnapshot::Running => ProcessStatusType::Running,
|
||||
ProcessStatusSnapshot::Sleeping => ProcessStatusType::Sleeping,
|
||||
@@ -700,11 +705,13 @@ fn process_metric_bundle_from_snapshots(
|
||||
};
|
||||
|
||||
let resource_stats = ResourceStats {
|
||||
server: server.clone(),
|
||||
cpu_percent: resource_snapshot.cpu_percent,
|
||||
memory_bytes: resource_snapshot.memory_bytes,
|
||||
uptime_seconds: resource_snapshot.uptime_seconds,
|
||||
};
|
||||
let process_stats = ProcessStats {
|
||||
server,
|
||||
locks_read_total: process_snapshot.locks_read_total,
|
||||
locks_write_total: process_snapshot.locks_write_total,
|
||||
cpu_total_seconds: process_snapshot.cpu_total_seconds,
|
||||
@@ -770,6 +777,7 @@ pub fn collect_host_network_stats_with(networks: &Networks) -> HostNetworkStats
|
||||
}
|
||||
|
||||
HostNetworkStats {
|
||||
server: current_local_node_identity(),
|
||||
total_received,
|
||||
total_transmitted,
|
||||
per_interface,
|
||||
@@ -794,6 +802,7 @@ pub fn collect_internode_network_stats() -> Option<NetworkStats> {
|
||||
let snapshot = global_internode_metrics().snapshot();
|
||||
|
||||
Some(NetworkStats {
|
||||
server: current_local_node_identity(),
|
||||
internode_errors_total: snapshot.errors_total,
|
||||
internode_dial_errors_total: snapshot.dial_errors_total,
|
||||
internode_dial_avg_time_nanos: snapshot.dial_avg_time_nanos,
|
||||
@@ -1307,6 +1316,27 @@ mod tests {
|
||||
assert!(cluster_config_stats_from_backend_parities(Some(1), Some(overflow)).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn node_local_resource_stats_use_stable_local_node_identity() {
|
||||
let _guard = crate::node_identity::local_node_identity_test_guard().await;
|
||||
let previous = rustfs_common::get_global_local_node_name().await;
|
||||
rustfs_common::set_global_local_node_name("node1:9000").await;
|
||||
|
||||
let mut system = System::new_all();
|
||||
let (cpu, memory) = collect_system_cpu_and_memory_stats_with(&mut system);
|
||||
let host_network = collect_host_network_stats_with(&Networks::new());
|
||||
let process_bundle =
|
||||
process_metric_bundle_from_snapshots(ProcessResourceSnapshot::default(), ProcessSystemSnapshot::default());
|
||||
|
||||
assert_eq!(cpu.server, "node1:9000");
|
||||
assert_eq!(memory.server, "node1:9000");
|
||||
assert_eq!(host_network.server, "node1:9000");
|
||||
assert_eq!(process_bundle.resource.server, "node1:9000");
|
||||
assert_eq!(process_bundle.process.server, "node1:9000");
|
||||
|
||||
rustfs_common::set_global_local_node_name(&previous).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erasure_set_stats_skip_unknown_backend_layout() {
|
||||
let storage_info = storage_info_with_one_online_disk();
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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.
|
||||
|
||||
pub(crate) const RUSTFS_NODE_ATTRIBUTE: &str = "rustfs.node";
|
||||
pub(crate) const SERVER_LABEL: &str = "server";
|
||||
|
||||
#[cfg(test)]
|
||||
static LOCAL_NODE_IDENTITY_TEST_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
|
||||
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
|
||||
|
||||
pub(crate) fn local_node_identity(local_ip: &str) -> String {
|
||||
rustfs_common::try_get_global_local_node_name().unwrap_or_else(|| local_ip.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn current_local_node_identity() -> String {
|
||||
let local_ip = rustfs_utils::get_local_ip_with_default();
|
||||
local_node_identity(&local_ip)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn local_node_identity_test_guard() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
LOCAL_NODE_IDENTITY_TEST_LOCK.lock().await
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user