mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-01 02:52:15 +00:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 707d062174 | |||
| 4b6b6f14bd | |||
| 9080ea8ea0 | |||
| 739efaaea1 | |||
| b6d4689c75 | |||
| 8763cd0c67 | |||
| fdac60b0e2 | |||
| 98b20b4231 | |||
| ffc9de72cb | |||
| c09d11ff3b | |||
| 792f2ef204 | |||
| 74019845c4 | |||
| 04c5921850 | |||
| db8039dece | |||
| 40eee6177a | |||
| 76b3c085b5 | |||
| 78d6918c52 | |||
| dd11145a26 | |||
| f718e72e24 | |||
| e06c9c02c6 | |||
| a2fe5d7d88 | |||
| cad8246ffb | |||
| b4901abd17 | |||
| 1d383e239d | |||
| 2e29c330a9 | |||
| 3921336b23 | |||
| 342ee1df78 | |||
| 40ef0db9cc | |||
| b457c6abcc | |||
| 7051a5ce41 | |||
| 1d3ba1eb8b | |||
| 8368017fb2 | |||
| 699ef14ddd | |||
| 704ea43da5 | |||
| 35a20622f1 | |||
| 7662b2436a | |||
| d4f2efa2ad | |||
| 19cdd806a2 | |||
| 6e5f330ff5 | |||
| e86d4cb579 | |||
| e08847d2b6 | |||
| 145d38133b | |||
| 30dc04c94b | |||
| ad7663afd1 | |||
| 6e6b38ad8e | |||
| 8601179c39 | |||
| b83c9c4663 |
@@ -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
|
||||
|
||||
|
||||
@@ -60,6 +60,16 @@ The file `prometheus-rules/rustfs-get-optimization-alerts.yaml` contains pre-con
|
||||
| `CodecStreamingFallbackSpike` | Warning | Codec streaming fallback > 10x baseline for 10m |
|
||||
| `IoQueueSaturation` | Warning | IO queue utilization > 90% for 5m |
|
||||
|
||||
The file `prometheus-rules/rustfs-kms-alerts.yml` contains alerting rules for the KMS backend operation metrics. Thresholds are conservative defaults pending staging baseline calibration; response procedures live in `docs/operations/kms-observability-runbook.md`, and the matching dashboard is `deploy/observability/grafana/rustfs-kms-observability.json`.
|
||||
|
||||
| Alert | Severity | Condition |
|
||||
|-------|----------|-----------|
|
||||
| `KmsBackendFatalErrors` | Critical | Fatal (non-retryable) attempt failures > 0 for 5m |
|
||||
| `KmsBackendHighErrorRate` | Critical | Non-success operation ratio > 5% for 10m (with traffic guard) |
|
||||
| `KmsBackendP99LatencyHigh` | Warning | Operation p99 duration (incl. retries) > 2s for 10m |
|
||||
| `KmsBackendAttemptFailureSpike` | Warning | Attempt failure rate > 0.5/s for 10m |
|
||||
| `KmsBackendRetryBudgetExhausted` | Warning | budget_exhausted / deadline_exceeded outcomes > 0.05/s for 10m |
|
||||
|
||||
### Enabling Alert Rules
|
||||
|
||||
Add the alert rules file to your Prometheus configuration:
|
||||
|
||||
@@ -60,6 +60,16 @@
|
||||
| `CodecStreamingFallbackSpike` | 警告 | Codec streaming 回退 > 10x 基线,持续 10 分钟 |
|
||||
| `IoQueueSaturation` | 警告 | IO 队列利用率 > 90%,持续 5 分钟 |
|
||||
|
||||
文件 `prometheus-rules/rustfs-kms-alerts.yml` 包含 KMS 后端操作指标的告警规则。阈值为保守默认值,待 staging 基线校准;响应流程见 `docs/operations/kms-observability-runbook.md`,配套仪表盘为 `deploy/observability/grafana/rustfs-kms-observability.json`。
|
||||
|
||||
| 告警 | 级别 | 条件 |
|
||||
|------|------|------|
|
||||
| `KmsBackendFatalErrors` | 严重 | fatal(不可重试)尝试失败 > 0,持续 5 分钟 |
|
||||
| `KmsBackendHighErrorRate` | 严重 | 非 success 操作占比 > 5%,持续 10 分钟(含流量下限保护) |
|
||||
| `KmsBackendP99LatencyHigh` | 警告 | 操作 p99 耗时(含重试)> 2s,持续 10 分钟 |
|
||||
| `KmsBackendAttemptFailureSpike` | 警告 | 尝试失败率 > 0.5/s,持续 10 分钟 |
|
||||
| `KmsBackendRetryBudgetExhausted` | 警告 | budget_exhausted / deadline_exceeded 结果 > 0.05/s,持续 10 分钟 |
|
||||
|
||||
### 启用告警规则
|
||||
|
||||
在 Prometheus 配置中添加告警规则文件:
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# 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.
|
||||
|
||||
# =============================================================================
|
||||
# RustFS KMS backend — Prometheus alerting rules
|
||||
# =============================================================================
|
||||
#
|
||||
# Metric source: the KMS operation-policy choke point in
|
||||
# crates/kms/src/policy.rs. All label values are static enum strings
|
||||
# (operation, op_class, outcome, error_class); key identifiers, key material,
|
||||
# and tokens never appear in labels.
|
||||
#
|
||||
# Response procedures: docs/operations/kms-observability-runbook.md
|
||||
#
|
||||
# IMPORTANT — threshold status: every numeric threshold below is a
|
||||
# conservative default chosen without a production baseline. Calibrate against
|
||||
# a staging baseline before relying on these alerts for paging, and prefer
|
||||
# loosening over tightening until the baseline exists. Formal SLO targets are
|
||||
# deliberately not encoded here (see rustfs/backlog#1584).
|
||||
#
|
||||
# NOTE: prometheus.yml loads /etc/prometheus/rules/*.yml — keep the .yml
|
||||
# extension or the file is silently ignored by the docker-compose stack.
|
||||
#
|
||||
# Validate: promtool check rules rustfs-kms-alerts.yml
|
||||
# =============================================================================
|
||||
|
||||
groups:
|
||||
# ==========================================================================
|
||||
# Critical alerts — immediate action required
|
||||
# ==========================================================================
|
||||
- name: rustfs-kms-critical
|
||||
interval: 30s
|
||||
rules:
|
||||
# ------------------------------------------------------------------
|
||||
# 1. KmsBackendFatalErrors
|
||||
# Any attempt failure classified as fatal (non-retryable): auth
|
||||
# or permission errors, malformed requests, missing keys. The
|
||||
# policy never retries these, so even a low rate means real
|
||||
# operations are failing right now.
|
||||
# ------------------------------------------------------------------
|
||||
- alert: KmsBackendFatalErrors
|
||||
expr: |
|
||||
sum by (operation) (rate(rustfs_kms_backend_attempt_failures_total{error_class="fatal"}[5m])) > 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
component: kms
|
||||
annotations:
|
||||
summary: "KMS backend fatal errors on operation {{ $labels.operation }}"
|
||||
description: >-
|
||||
Attempt failures classified as fatal are occurring at
|
||||
{{ $value | printf "%.3f" }}/s on operation
|
||||
{{ $labels.operation }}. Fatal failures are not retried:
|
||||
each one is a KMS backend call that failed permanently
|
||||
(authentication, permissions, malformed request, or a
|
||||
missing key/version).
|
||||
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendfatalerrors"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. KmsBackendHighErrorRate
|
||||
# Sustained share of operations terminating without success
|
||||
# (fatal, budget_exhausted, deadline_exceeded). The cancelled
|
||||
# outcome is excluded because shutdowns legitimately produce it.
|
||||
# The traffic guard keeps a single failure on a near-idle
|
||||
# cluster from firing the alert.
|
||||
# Threshold: 5% for 10m — conservative default, calibrate
|
||||
# against a staging baseline.
|
||||
# ------------------------------------------------------------------
|
||||
- alert: KmsBackendHighErrorRate
|
||||
expr: |
|
||||
(
|
||||
sum(rate(rustfs_kms_backend_operations_total{outcome!~"success|cancelled"}[5m]))
|
||||
/
|
||||
clamp_min(sum(rate(rustfs_kms_backend_operations_total[5m])), 1e-9)
|
||||
) > 0.05
|
||||
and
|
||||
sum(rate(rustfs_kms_backend_operations_total[5m])) > 0.02
|
||||
for: 10m
|
||||
labels:
|
||||
severity: critical
|
||||
component: kms
|
||||
annotations:
|
||||
summary: "KMS backend non-success ratio above 5% for 10m"
|
||||
description: >-
|
||||
{{ $value | humanizePercentage }} of KMS backend operations
|
||||
are terminating in fatal, budget_exhausted, or
|
||||
deadline_exceeded. Object encryption and decryption paths
|
||||
depending on the KMS are degraded or failing.
|
||||
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendhigherrorrate"
|
||||
|
||||
# ==========================================================================
|
||||
# Warning alerts — investigation needed
|
||||
# ==========================================================================
|
||||
- name: rustfs-kms-warning
|
||||
interval: 30s
|
||||
rules:
|
||||
# ------------------------------------------------------------------
|
||||
# 3. KmsBackendP99LatencyHigh
|
||||
# p99 wall-clock duration of whole operations (attempts plus
|
||||
# backoff) is sustained above 2 seconds. Because the histogram
|
||||
# includes retries, a high p99 usually means the retry policy
|
||||
# is absorbing backend failures, not that every call is slow.
|
||||
# Threshold: 2s for 10m — conservative default, calibrate
|
||||
# against a staging baseline.
|
||||
# ------------------------------------------------------------------
|
||||
- alert: KmsBackendP99LatencyHigh
|
||||
expr: |
|
||||
histogram_quantile(0.99,
|
||||
sum by (le) (rate(rustfs_kms_backend_operation_duration_seconds_bucket[5m]))
|
||||
) > 2
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
component: kms
|
||||
annotations:
|
||||
summary: "KMS backend operation p99 latency above 2s for 10m"
|
||||
description: >-
|
||||
The 99th-percentile KMS backend operation duration is
|
||||
{{ $value | humanizeDuration }}, including retries and
|
||||
backoff. Encryption and decryption latency is leaking into
|
||||
S3 request latency.
|
||||
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendp99latencyhigh"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. KmsBackendAttemptFailureSpike
|
||||
# Aggregate attempt-failure rate (all error classes) sustained
|
||||
# above an absolute floor. An absolute threshold is used instead
|
||||
# of an offset-1d baseline ratio because fresh deployments have
|
||||
# no baseline and an empty offset vector would keep a ratio
|
||||
# alert from ever firing; switch to a baseline-relative form
|
||||
# (see rustfs-get-optimization-alerts.yaml for the pattern)
|
||||
# once a stable staging baseline exists.
|
||||
# Threshold: 0.5/s for 10m — conservative default, calibrate
|
||||
# against a staging baseline.
|
||||
# ------------------------------------------------------------------
|
||||
- alert: KmsBackendAttemptFailureSpike
|
||||
expr: |
|
||||
sum(rate(rustfs_kms_backend_attempt_failures_total[5m])) > 0.5
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
component: kms
|
||||
annotations:
|
||||
summary: "KMS backend attempt failures above 0.5/s for 10m"
|
||||
description: >-
|
||||
KMS backend attempts are failing at
|
||||
{{ $value | printf "%.2f" }}/s across all error classes.
|
||||
The retry policy may still be masking these from callers —
|
||||
check the error-class breakdown before it stops absorbing
|
||||
them.
|
||||
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendattemptfailurespike"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. KmsBackendRetryBudgetExhausted
|
||||
# Operations are running out of retry budget (budget_exhausted)
|
||||
# or operation deadline (deadline_exceeded). These surface to
|
||||
# callers as failed KMS operations even though every individual
|
||||
# failure was retryable — the backend is unhealthy for longer
|
||||
# than the policy can bridge.
|
||||
# Threshold: 0.05/s for 10m — conservative default, calibrate
|
||||
# against a staging baseline.
|
||||
# ------------------------------------------------------------------
|
||||
- alert: KmsBackendRetryBudgetExhausted
|
||||
expr: |
|
||||
sum by (outcome) (rate(rustfs_kms_backend_operations_total{outcome=~"budget_exhausted|deadline_exceeded"}[5m])) > 0.05
|
||||
for: 10m
|
||||
labels:
|
||||
severity: warning
|
||||
component: kms
|
||||
annotations:
|
||||
summary: "KMS backend operations exhausting retry budget ({{ $labels.outcome }})"
|
||||
description: >-
|
||||
KMS backend operations are terminating as
|
||||
{{ $labels.outcome }} at {{ $value | printf "%.3f" }}/s.
|
||||
Retryable failures are outlasting the retry budget, so
|
||||
callers are seeing hard failures.
|
||||
runbook_url: "https://github.com/rustfs/rustfs/blob/main/docs/operations/kms-observability-runbook.md#kmsbackendretrybudgetexhausted"
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -12,18 +12,20 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# Companion to ci.yml for the required "Test and Lint" status check.
|
||||
# Companion to ci.yml for the required "Test and Lint" and "Quick Checks"
|
||||
# status checks.
|
||||
#
|
||||
# ci.yml skips docs-only pull requests via paths-ignore, but the branch
|
||||
# ruleset requires a check named "Test and Lint" — without this workflow a
|
||||
# docs-only PR would wait on that check forever. This workflow triggers on
|
||||
# exactly the paths ci.yml ignores and reports an instant success under the
|
||||
# same job name. Mixed PRs trigger both workflows and the real check still
|
||||
# gates: a required check with any failing run blocks the merge.
|
||||
# ruleset requires checks named "Test and Lint" and "Quick Checks" — without
|
||||
# this workflow a docs-only PR would wait on those checks forever. This
|
||||
# workflow triggers on exactly the paths ci.yml ignores and reports success
|
||||
# under the same job names. Mixed PRs trigger both workflows and the real
|
||||
# checks still gate: a required check with any failing run blocks the merge.
|
||||
# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks
|
||||
#
|
||||
# Keep the paths list below in sync with the pull_request paths-ignore list
|
||||
# in ci.yml.
|
||||
# in ci.yml, and keep the quick-checks steps below byte-identical to the
|
||||
# quick-checks job in ci.yml (see the comment on that job).
|
||||
|
||||
name: Continuous Integration (docs only)
|
||||
|
||||
@@ -52,9 +54,63 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Deliberately NOT a bare `echo`. Once "Quick Checks" becomes a required
|
||||
# check, ci.yml gates every expensive job behind it, so a mixed PR reports
|
||||
# two check runs with this name: the real one (45-51s) and this companion.
|
||||
# GitHub has no written contract for how it picks between same-named
|
||||
# required check runs ("latest wins" vs "any failure blocks"), so instead of
|
||||
# relying on ordering we make both runs execute the same commands against
|
||||
# the same merge ref — their conclusions are then necessarily identical and
|
||||
# the choice does not matter. Keep these steps byte-identical to the
|
||||
# quick-checks job in ci.yml (a guard script that asserts this, and the paths
|
||||
# sync below, is tracked in rustfs/backlog#1603).
|
||||
#
|
||||
# For a genuinely docs-only PR this adds no strictness (no code changed, so
|
||||
# fmt and the guards always pass) and costs ~50s of ubuntu-latest.
|
||||
quick-checks:
|
||||
name: Quick Checks
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
|
||||
- name: Install ripgrep
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
components: rustfmt
|
||||
|
||||
- name: Check code formatting
|
||||
run: cargo fmt --all --check
|
||||
|
||||
- name: Check unsafe code allowances
|
||||
run: ./scripts/check_unsafe_code_allowances.sh
|
||||
|
||||
- name: Check layered dependencies
|
||||
run: ./scripts/check_layer_dependencies.sh
|
||||
|
||||
- name: Check architecture migration rules
|
||||
run: ./scripts/check_architecture_migration_rules.sh
|
||||
|
||||
- name: Check tokio io-uring feature guard
|
||||
run: ./scripts/check_no_tokio_io_uring.sh
|
||||
|
||||
- name: Check extension schema boundaries
|
||||
run: ./scripts/check_extension_schema_boundaries.sh
|
||||
|
||||
- name: Check body-cache whitelist guard
|
||||
run: ./scripts/check_body_cache_whitelist.sh
|
||||
|
||||
- name: Check no planning docs committed
|
||||
run: ./scripts/check_no_planning_docs.sh
|
||||
|
||||
test-and-lint:
|
||||
name: Test and Lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
|
||||
@@ -448,6 +448,13 @@ jobs:
|
||||
|
||||
uring-integration:
|
||||
name: io_uring Integration (real)
|
||||
# The pull_request trigger includes `closed` purely so the concurrency
|
||||
# group cancels in-flight runs of a closed PR; every other job opts out of
|
||||
# that run with this guard (or is skipped through its `needs` chain). This
|
||||
# job had neither, so each closed/merged PR really ran the whole io_uring
|
||||
# suite (measured 4m17s / 7m19s / 7m31s on runs 30678272341 / 30678117601 /
|
||||
# 30662728539) and kept the cancellation run in progress for minutes.
|
||||
if: github.event_name != 'pull_request' || github.event.action != 'closed'
|
||||
# GitHub-hosted ubuntu-latest runs a recent kernel with io_uring and, unlike
|
||||
# a container, applies no seccomp filter that would block io_uring_setup — so
|
||||
# the probe succeeds and the tests exercise the real UringBackend/FdCache/
|
||||
@@ -746,7 +753,13 @@ jobs:
|
||||
# evaluates ILM within ~2s of the due time, well inside the poll window.
|
||||
s3-lifecycle-behavior-tests:
|
||||
name: S3 Lifecycle Behavior Tests
|
||||
needs: [ build-rustfs-debug-binary ]
|
||||
# Also gated on e2e-tests, matching s3-implemented-tests: when the e2e smoke
|
||||
# suite is already red this lane cannot tell us anything new, and it holds a
|
||||
# sm-standard-4 for up to 30 minutes doing so. Both lanes only download the
|
||||
# prebuilt debug binary (no cargo build), and s3-implemented-tests — which
|
||||
# already waits on e2e-tests — finishes later anyway, so a green PR's total
|
||||
# wall clock is unchanged.
|
||||
needs: [ build-rustfs-debug-binary, e2e-tests ]
|
||||
runs-on: sm-standard-4
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -105,33 +105,78 @@ CI) fails the build if anything is committed under `docs/superpowers/`, even via
|
||||
|
||||
## Verification Before PR
|
||||
|
||||
Convert changes into independently verifiable outcomes. Prefer focused tests for behavior changes and run the relevant checks before declaring completion.
|
||||
Non-exempt changes must also pass Adversarial Validation (next section) before the checks below count as completion.
|
||||
Convert changes into independently verifiable outcomes. This section controls
|
||||
agent-run local validation; preparing a commit or PR does not by itself require
|
||||
the broadest gate. Inspect only the final task-owned diff, classify it by
|
||||
behavioral impact rather than line count or path alone, and run the smallest
|
||||
set of checks that provides meaningful coverage. Do not let unrelated
|
||||
worktree changes or a generic contributor checklist expand the scope.
|
||||
Non-exempt changes must also pass Adversarial Validation (next section) before
|
||||
the checks below count as completion.
|
||||
|
||||
For code changes, run and pass the following before opening a PR:
|
||||
### Validation floor
|
||||
|
||||
```bash
|
||||
make pre-pr
|
||||
```
|
||||
- Every change that is not documentation-only must finish with
|
||||
`cargo fmt --all --check` passing. An umbrella gate that runs this exact
|
||||
check satisfies the requirement; do not run it twice. Use `cargo fmt --all`
|
||||
only when formatting needs to be fixed. Run the configured formatter or
|
||||
validator for other changed languages when one exists.
|
||||
- Documentation-only or instruction-only means all task-owned changes are
|
||||
prose or documentation assets and cannot affect runtime, builds, CI,
|
||||
dependencies, generated code, or tests. Run `git diff --check` and any
|
||||
relevant documentation guard, but skip Cargo formatting, compilation,
|
||||
Clippy, tests, `make pre-commit`, and `make pre-pr`.
|
||||
- Behavior changes require relevant existing or new tests. Prefer the most
|
||||
focused test or affected package. A passing targeted test can also provide
|
||||
sufficient compilation coverage when it builds every changed target and
|
||||
feature involved; do not add a redundant `cargo check` in that case.
|
||||
- `cargo check` supplements compilation coverage; it never substitutes for a
|
||||
behavioral test. If a relevant test cannot reasonably be added or run, use
|
||||
the narrowest compilation check and report the reason and remaining risk.
|
||||
|
||||
Before committing code changes, prefer focused verification for the touched
|
||||
surface and use the faster local gate when a broad smoke check is needed:
|
||||
### Validation tiers
|
||||
|
||||
```bash
|
||||
make pre-commit
|
||||
```
|
||||
1. **Documentation/instruction-only:** Apply the exemption above. Run a guard
|
||||
such as `make doc-paths-check` only when it is relevant to the edited text.
|
||||
2. **Non-behavioral source change:** For comments, formatting, or another
|
||||
demonstrably non-executable change, run the formatting floor. Compilation,
|
||||
Clippy, and tests may be skipped only when the edit cannot affect
|
||||
compilation or runtime behavior; run targeted doctests if executable
|
||||
documentation examples changed.
|
||||
3. **Localized or bounded behavior change:** Run the formatting floor and the
|
||||
narrowest relevant tests. Add package-scoped `cargo check` or Clippy only
|
||||
for changed targets, features, APIs, error handling, async behavior, or
|
||||
control flow not already covered. When several crates are affected but the
|
||||
dependency set is identifiable, validate those packages and known
|
||||
dependents instead of the whole workspace. Use `make pre-commit` only when
|
||||
a repository-wide fast gate adds useful confidence beyond those checks.
|
||||
4. **Broad or high-risk change:** Run `make pre-pr` only when targeted coverage
|
||||
cannot bound the impact, including:
|
||||
- dependency, feature, build-script, procedural-macro, code-generation,
|
||||
toolchain, or CI changes that alter compilation or the test matrix;
|
||||
- cross-crate public APIs, shared foundational code, or broad refactors with
|
||||
an unbounded dependent set;
|
||||
- locking, storage durability or formats, erasure coding, replication,
|
||||
RPC/protocol compatibility, IAM/KMS/auth, cryptography, or other
|
||||
security-sensitive behavior;
|
||||
- a targeted check that reveals wider impact, an explicit user request, or
|
||||
a release policy that requires the full gate.
|
||||
|
||||
For migration batches, do not run the full `make pre-pr` gate before every
|
||||
intermediate commit. Use focused tests and `make pre-commit` during
|
||||
development, then reserve `make pre-pr` for the final PR-ready branch.
|
||||
Documentation-only and non-behavioral classifications take precedence over
|
||||
path-based triggers. A small diff can still be high-risk, while a CI comment,
|
||||
manifest comment, or release-note edit does not require full validation.
|
||||
|
||||
Before pushing code changes, make sure formatting is clean:
|
||||
`make pre-pr` includes `make pre-commit` coverage. Never run both for the same
|
||||
unchanged diff, and do not repeat equivalent checks during PR preparation or
|
||||
because a local hook already ran them. Rerun only checks whose scope is affected
|
||||
by later edits. Full workspace checks do not replace a relevant integration or
|
||||
E2E test for changed behavior; run that focused test when required and
|
||||
available, or report why it was not run and the remaining risk.
|
||||
|
||||
- Run `cargo fmt --all`.
|
||||
- Run `cargo fmt --all --check` and ensure no files are modified unexpectedly.
|
||||
If `make` is unavailable, run the equivalent checks defined under
|
||||
`.config/make/`. At handoff, list the checks actually run, checks intentionally
|
||||
skipped, and the reason for the selected tier.
|
||||
|
||||
If `make` is unavailable, run the equivalent checks defined under `.config/make/`.
|
||||
Documentation-only or instruction-only changes are exempt from the verification commands above (including the `.config/make/` equivalents), though any locally installed git pre-commit hooks may still run on commit unless explicitly skipped.
|
||||
After build-based verification completes, clean generated build artifacts before wrapping up to avoid unnecessary disk usage.
|
||||
Do not open a PR with code changes when the required checks fail.
|
||||
Make a failing check pass by fixing the cause, never by weakening the gate:
|
||||
|
||||
Generated
+133
-35
@@ -1528,7 +1528,7 @@ version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array 0.14.9",
|
||||
"generic-array 0.14.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1547,7 +1547,7 @@ version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array 0.14.9",
|
||||
"generic-array 0.14.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1598,7 +1598,7 @@ version = "3.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f"
|
||||
dependencies = [
|
||||
"darling 0.23.0",
|
||||
"darling 0.20.11",
|
||||
"ident_case",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
@@ -1870,7 +1870,7 @@ version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.6",
|
||||
"crypto-common 0.1.7",
|
||||
"inout 0.1.4",
|
||||
]
|
||||
|
||||
@@ -2328,7 +2328,7 @@ version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array 0.14.9",
|
||||
"generic-array 0.14.7",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -2353,11 +2353,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array 0.14.9",
|
||||
"generic-array 0.14.7",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -3554,7 +3554,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.6",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -3672,6 +3672,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures",
|
||||
"hex",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
@@ -3813,7 +3814,7 @@ dependencies = [
|
||||
"crypto-bigint 0.5.5",
|
||||
"digest 0.10.7",
|
||||
"ff 0.13.1",
|
||||
"generic-array 0.14.9",
|
||||
"generic-array 0.14.7",
|
||||
"group 0.13.0",
|
||||
"hkdf 0.12.4",
|
||||
"pem-rfc7468 0.7.0",
|
||||
@@ -4258,9 +4259,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.9"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -4273,7 +4274,7 @@ version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab4e5aa225bc56696909483320f0ff9b600f1a971b52e07a17d70f3d9b43254b"
|
||||
dependencies = [
|
||||
"generic-array 0.14.9",
|
||||
"generic-array 0.14.7",
|
||||
"rustversion",
|
||||
"typenum",
|
||||
]
|
||||
@@ -4372,9 +4373,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-auth"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3494870d06f3cbbb3561ada6f234982549e3a2fb31e719ef258e6eadb9ae09a"
|
||||
checksum = "f54aab44c16b8463ae11b165a87c3d484780231f157bb1ed65843d591beb5abd"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"aws-lc-rs",
|
||||
@@ -4401,9 +4402,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-gax"
|
||||
version = "1.12.0"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3103a4a9013f1aed573ca56e19a9680b0211643a99ea85caf524b397d6be8be3"
|
||||
checksum = "b9a46dd0fd026bbc4a5d84e6ab0c941cee6e3b057976a0bb107fdb5238ce598f"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -4420,9 +4421,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-gax-internal"
|
||||
version = "0.7.15"
|
||||
version = "0.7.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0df265fba091ed7e00ecd0755009423310163f8b52820f007b6b4d97f4c6617"
|
||||
checksum = "fb04c54317ace06d489213f761797240b3046142a9b7ce6b9a82a9d134e193d1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
@@ -4524,9 +4525,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-storage"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc4b1d78c88db5c2530b12461e373a7d0d3a6caa3f6c1fc14e5d824cf2aeb307"
|
||||
checksum = "9227f65175fa91a6e41f246797917697efdadfe09dd8ea84ad8b737a71efbd28"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
@@ -4577,9 +4578,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-wkt"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46df1fcc3ab69164af3f4199ed21f45b5dbc56d9f03211eb4fa20116d442364b"
|
||||
checksum = "7fccf98cfd5481a5f5a285181ab0c62123d7d47cd2bb7299448440649349e4e7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -4916,19 +4917,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66750a77f4f6b408a148be5102ef1f3ba7172def7ee92b1cfc75d9f7a3870453"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
"async-trait",
|
||||
"cfg-if",
|
||||
"crossbeam-channel",
|
||||
"flate2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"hdrhistogram",
|
||||
"hotpath-macros",
|
||||
"hotpath-meta",
|
||||
"http 1.5.0",
|
||||
"libc",
|
||||
"object 0.36.7",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"prettytable-rs",
|
||||
"quanta",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"reqwest-middleware",
|
||||
"rustc-demangle",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tiny_http",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4942,6 +4955,21 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hotpath-macros-meta"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21f2f70b29b6f42311acd2fb0b2f91a34d9a573c76fb8a7f51970670a1673a49"
|
||||
|
||||
[[package]]
|
||||
name = "hotpath-meta"
|
||||
version = "0.22.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b771f77b2f409086bb40ff029f850ce99d17118d4e207df0f28cb32bd7568c7"
|
||||
dependencies = [
|
||||
"hotpath-macros-meta",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "htmlescape"
|
||||
version = "0.3.1"
|
||||
@@ -5023,9 +5051,9 @@ checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.13"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
|
||||
checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b"
|
||||
dependencies = [
|
||||
"ctutils",
|
||||
"subtle",
|
||||
@@ -5273,7 +5301,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding 0.3.3",
|
||||
"generic-array 0.14.9",
|
||||
"generic-array 0.14.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6783,6 +6811,15 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.36.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "object"
|
||||
version = "0.37.3"
|
||||
@@ -7820,7 +7857,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
"multimap",
|
||||
"once_cell",
|
||||
@@ -7840,7 +7877,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.13.0",
|
||||
"log",
|
||||
"multimap",
|
||||
"petgraph 0.8.3",
|
||||
@@ -7861,7 +7898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -7874,7 +7911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.10.5",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -8514,6 +8551,21 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest-middleware"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"http 1.5.0",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"thiserror 2.0.19",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resolv-conf"
|
||||
version = "0.7.6"
|
||||
@@ -8988,6 +9040,7 @@ dependencies = [
|
||||
"const-str",
|
||||
"futures",
|
||||
"hashbrown 0.17.1",
|
||||
"hotpath",
|
||||
"metrics",
|
||||
"rustfs-config",
|
||||
"rustfs-s3-types",
|
||||
@@ -9008,6 +9061,7 @@ dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
"crc-fast",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"md-5 0.11.0",
|
||||
"pretty_assertions",
|
||||
@@ -9021,6 +9075,7 @@ name = "rustfs-common"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hotpath",
|
||||
"metrics",
|
||||
"rmp-serde",
|
||||
"s3s",
|
||||
@@ -9035,6 +9090,7 @@ dependencies = [
|
||||
name = "rustfs-concurrency"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"insta",
|
||||
"rustfs-io-core",
|
||||
"serde",
|
||||
@@ -9048,6 +9104,7 @@ name = "rustfs-config"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"const-str",
|
||||
"hotpath",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -9058,6 +9115,7 @@ version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"hmac 0.13.0",
|
||||
"hotpath",
|
||||
"rand 0.10.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -9073,6 +9131,7 @@ dependencies = [
|
||||
"argon2",
|
||||
"base64-simd",
|
||||
"chacha20poly1305",
|
||||
"hotpath",
|
||||
"jsonwebtoken 11.0.0",
|
||||
"pbkdf2 0.13.0",
|
||||
"rand 0.10.2",
|
||||
@@ -9090,6 +9149,7 @@ name = "rustfs-data-usage"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
"rmp-serde",
|
||||
"rustfs-filemeta",
|
||||
"serde",
|
||||
@@ -9099,7 +9159,6 @@ dependencies = [
|
||||
name = "rustfs-ecstore"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
"async-recursion",
|
||||
@@ -9115,7 +9174,6 @@ dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"bytesize",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"enumset",
|
||||
@@ -9169,7 +9227,6 @@ dependencies = [
|
||||
"rustfs-erasure-codec",
|
||||
"rustfs-filemeta",
|
||||
"rustfs-io-metrics",
|
||||
"rustfs-kms",
|
||||
"rustfs-lifecycle",
|
||||
"rustfs-lock",
|
||||
"rustfs-madmin",
|
||||
@@ -9238,6 +9295,7 @@ dependencies = [
|
||||
name = "rustfs-extension-schema"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.19",
|
||||
@@ -9276,6 +9334,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.0",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"metrics",
|
||||
"rustfs-common",
|
||||
@@ -9307,6 +9366,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64-simd",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"jsonwebtoken 11.0.0",
|
||||
"moka",
|
||||
@@ -9341,6 +9401,7 @@ name = "rustfs-io-core"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hotpath",
|
||||
"memmap2",
|
||||
"rustfs-io-metrics",
|
||||
"thiserror 2.0.19",
|
||||
@@ -9353,10 +9414,13 @@ name = "rustfs-io-metrics"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"hotpath",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"num_cpus",
|
||||
"rustfs-common",
|
||||
"rustfs-s3-ops",
|
||||
"rustfs-utils",
|
||||
"sysinfo",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
@@ -9417,6 +9481,7 @@ version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"http-body-util",
|
||||
@@ -9442,20 +9507,25 @@ name = "rustfs-kms"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"base64 0.23.0",
|
||||
"chacha20poly1305",
|
||||
"hex",
|
||||
"hotpath",
|
||||
"insta",
|
||||
"jiff",
|
||||
"md-5 0.11.0",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"moka",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"rustfs-security-governance",
|
||||
"rustfs-utils",
|
||||
"rustify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
@@ -9464,6 +9534,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
@@ -9476,6 +9547,7 @@ name = "rustfs-lifecycle"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"proptest",
|
||||
@@ -9500,6 +9572,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"crossbeam-queue",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"parking_lot",
|
||||
"rand 0.10.2",
|
||||
"rustfs-io-metrics",
|
||||
@@ -9521,6 +9594,7 @@ version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"flate2",
|
||||
"hotpath",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -9538,6 +9612,7 @@ name = "rustfs-madmin"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hotpath",
|
||||
"humantime",
|
||||
"hyper",
|
||||
"rmp-serde",
|
||||
@@ -9558,6 +9633,7 @@ dependencies = [
|
||||
"criterion",
|
||||
"form_urlencoded",
|
||||
"hashbrown 0.17.1",
|
||||
"hotpath",
|
||||
"metrics",
|
||||
"percent-encoding",
|
||||
"quick-xml",
|
||||
@@ -9587,6 +9663,7 @@ version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"rustfs-config",
|
||||
"rustfs-io-metrics",
|
||||
"rustfs-utils",
|
||||
@@ -9605,6 +9682,7 @@ version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"criterion",
|
||||
"hotpath",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
"moka",
|
||||
@@ -9627,6 +9705,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"glob",
|
||||
"hotpath",
|
||||
"jiff",
|
||||
"libc",
|
||||
"metrics",
|
||||
@@ -9675,6 +9754,7 @@ dependencies = [
|
||||
"base64-simd",
|
||||
"chrono",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"ipnetwork",
|
||||
"jsonwebtoken 11.0.0",
|
||||
"moka",
|
||||
@@ -9711,6 +9791,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
@@ -9763,6 +9844,7 @@ name = "rustfs-protos"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"flatbuffers",
|
||||
"hotpath",
|
||||
"prost 0.14.4",
|
||||
"rmp-serde",
|
||||
"rustfs-common",
|
||||
@@ -9787,6 +9869,7 @@ version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"hotpath",
|
||||
"regex",
|
||||
"rmp",
|
||||
"rmp-serde",
|
||||
@@ -9845,6 +9928,7 @@ dependencies = [
|
||||
"chacha20poly1305",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"hotpath",
|
||||
"minlz",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
@@ -9862,6 +9946,7 @@ dependencies = [
|
||||
name = "rustfs-s3-ops"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-s3-types",
|
||||
]
|
||||
|
||||
@@ -9869,6 +9954,7 @@ dependencies = [
|
||||
name = "rustfs-s3-types"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -9883,6 +9969,7 @@ dependencies = [
|
||||
"datafusion",
|
||||
"futures",
|
||||
"futures-core",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"metrics",
|
||||
"parking_lot",
|
||||
@@ -9911,6 +9998,7 @@ dependencies = [
|
||||
"datafusion",
|
||||
"derive_builder",
|
||||
"futures",
|
||||
"hotpath",
|
||||
"parking_lot",
|
||||
"rustfs-s3select-api",
|
||||
"s3s",
|
||||
@@ -9928,6 +10016,7 @@ dependencies = [
|
||||
"futures",
|
||||
"hex-simd",
|
||||
"hmac 0.13.0",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"metrics",
|
||||
"rand 0.10.2",
|
||||
@@ -9960,6 +10049,7 @@ dependencies = [
|
||||
name = "rustfs-security-governance"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
@@ -9969,6 +10059,7 @@ version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"hyper",
|
||||
"rustfs-utils",
|
||||
@@ -9985,6 +10076,7 @@ name = "rustfs-storage-api"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"hotpath",
|
||||
"insta",
|
||||
"rustfs-filemeta",
|
||||
"serde",
|
||||
@@ -10006,6 +10098,7 @@ dependencies = [
|
||||
"deadpool-postgres",
|
||||
"futures-util",
|
||||
"hashbrown 0.17.1",
|
||||
"hotpath",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"lapin",
|
||||
@@ -10051,6 +10144,7 @@ dependencies = [
|
||||
name = "rustfs-test-utils"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"hotpath",
|
||||
"rustfs-data-usage",
|
||||
"rustfs-ecstore",
|
||||
"rustfs-storage-api",
|
||||
@@ -10067,6 +10161,7 @@ name = "rustfs-tls-runtime"
|
||||
version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"hotpath",
|
||||
"metrics",
|
||||
"rcgen",
|
||||
"rustfs-common",
|
||||
@@ -10088,6 +10183,7 @@ version = "1.0.0-beta.12"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"ipnetwork",
|
||||
"metrics",
|
||||
@@ -10134,6 +10230,7 @@ dependencies = [
|
||||
"hex-simd",
|
||||
"highway",
|
||||
"hmac 0.13.0",
|
||||
"hotpath",
|
||||
"http 1.5.0",
|
||||
"hyper",
|
||||
"local-ip-address",
|
||||
@@ -10166,6 +10263,7 @@ dependencies = [
|
||||
"astral-tokio-tar",
|
||||
"async-compression",
|
||||
"criterion",
|
||||
"hotpath",
|
||||
"tempfile",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
@@ -10509,7 +10607,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct 0.2.0",
|
||||
"der 0.7.10",
|
||||
"generic-array 0.14.9",
|
||||
"generic-array 0.14.7",
|
||||
"pkcs8 0.10.2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -11499,7 +11597,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
|
||||
+4
-3
@@ -250,8 +250,8 @@ enumset = "1.1.14"
|
||||
faster-hex = "0.10.0"
|
||||
flate2 = "1.1.9"
|
||||
glob = "0.3.4"
|
||||
google-cloud-storage = "1.16.0"
|
||||
google-cloud-auth = "1.14.0"
|
||||
google-cloud-storage = "1.17.0"
|
||||
google-cloud-auth = "1.15.0"
|
||||
hashbrown = { version = "0.17.1" }
|
||||
hex = "0.4.3"
|
||||
hex-simd = "0.8.0"
|
||||
@@ -285,6 +285,7 @@ reed-solomon-simd = "3.1.0"
|
||||
regex = { version = "1.13.1" }
|
||||
rumqttc = { package = "rumqttc-next", version = "0.33.3" }
|
||||
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" }
|
||||
@@ -347,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" }
|
||||
|
||||
|
||||
@@ -25,7 +25,33 @@ documentation = "https://docs.rs/rustfs-audit/latest/rustfs_audit/"
|
||||
keywords = ["audit", "target", "management", "fan-out", "RustFS"]
|
||||
categories = ["web-programming", "development-tools", "asynchronous", "api-bindings"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
"hotpath/futures",
|
||||
"rustfs-config/hotpath",
|
||||
"rustfs-s3-types/hotpath",
|
||||
"rustfs-targets/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-config/hotpath-alloc",
|
||||
"rustfs-s3-types/hotpath-alloc",
|
||||
"rustfs-targets/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-cpu",
|
||||
"rustfs-config/hotpath-cpu",
|
||||
"rustfs-s3-types/hotpath-cpu",
|
||||
"rustfs-targets/hotpath-cpu",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
rustfs-targets = { workspace = true }
|
||||
rustfs-config = { workspace = true, features = ["audit", "server-config-model"] }
|
||||
rustfs-s3-types = { workspace = true }
|
||||
|
||||
@@ -28,7 +28,14 @@ documentation = "https://docs.rs/rustfs-checksums/latest/rustfs_checksum/"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = ["hotpath/hotpath"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
bytes = { workspace = true, features = ["serde"] }
|
||||
crc-fast = { workspace = true }
|
||||
http = { workspace = true }
|
||||
|
||||
@@ -27,7 +27,14 @@ categories = ["web-programming", "development-tools", "data-structures"]
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = ["hotpath/hotpath", "hotpath/tokio"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||
tonic = { workspace = true, features = ["gzip", "deflate"] }
|
||||
uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
|
||||
@@ -13,7 +13,14 @@ categories = ["concurrency", "filesystem"]
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = ["hotpath/hotpath", "hotpath/tokio", "rustfs-io-core/hotpath"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "rustfs-io-core/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-io-core/hotpath-cpu"]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
# Internal crates
|
||||
rustfs-io-core = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -25,6 +25,7 @@ keywords = ["configuration", "settings", "management", "rustfs", "Minio"]
|
||||
categories = ["web-programming", "development-tools", "config"]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
const-str = { workspace = true, optional = true, features = ["std", "proc"] }
|
||||
serde = { workspace = true, optional = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, optional = true, features = ["raw_value"] }
|
||||
@@ -34,6 +35,9 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = ["constants"]
|
||||
hotpath = ["hotpath/hotpath"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
audit = ["dep:const-str", "constants"]
|
||||
constants = ["dep:const-str"]
|
||||
notify = ["dep:const-str", "constants"]
|
||||
|
||||
@@ -24,7 +24,14 @@ description = "Credentials management utilities for RustFS, enabling secure hand
|
||||
keywords = ["rustfs", "Minio", "credentials", "authentication", "authorization"]
|
||||
categories = ["web-programming", "development-tools", "data-structures", "security"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = ["hotpath/hotpath"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
base64-simd = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
rand = { workspace = true, features = ["serde"] }
|
||||
|
||||
@@ -29,6 +29,7 @@ documentation = "https://docs.rs/rustfs-crypto/latest/rustfs_crypto/"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
aes-gcm = { workspace = true, optional = true, features = ["rand_core"] }
|
||||
argon2 = { workspace = true, optional = true }
|
||||
chacha20poly1305 = { workspace = true, optional = true }
|
||||
@@ -49,6 +50,9 @@ time = { workspace = true, features = ["parsing", "formatting", "macros", "serde
|
||||
|
||||
[features]
|
||||
default = ["crypto", "fips"]
|
||||
hotpath = ["hotpath/hotpath"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
fips = []
|
||||
crypto = [
|
||||
"dep:aes-gcm",
|
||||
|
||||
@@ -27,7 +27,14 @@ categories = ["data-structures", "filesystem"]
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = ["hotpath/hotpath", "rustfs-filemeta/hotpath"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "rustfs-filemeta/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-filemeta/hotpath-cpu"]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
rmp-serde = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
@@ -25,10 +25,58 @@ workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
"hotpath/futures",
|
||||
"hotpath/reqwest-0-13",
|
||||
"rustfs-config/hotpath",
|
||||
"rustfs-credentials/hotpath",
|
||||
"rustfs-data-usage/hotpath",
|
||||
"rustfs-ecstore/hotpath",
|
||||
"rustfs-filemeta/hotpath",
|
||||
"rustfs-lock/hotpath",
|
||||
"rustfs-madmin/hotpath",
|
||||
"rustfs-protos/hotpath",
|
||||
"rustfs-rio/hotpath",
|
||||
"rustfs-signer/hotpath",
|
||||
"rustfs-utils/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-config/hotpath-alloc",
|
||||
"rustfs-credentials/hotpath-alloc",
|
||||
"rustfs-data-usage/hotpath-alloc",
|
||||
"rustfs-ecstore/hotpath-alloc",
|
||||
"rustfs-filemeta/hotpath-alloc",
|
||||
"rustfs-lock/hotpath-alloc",
|
||||
"rustfs-madmin/hotpath-alloc",
|
||||
"rustfs-protos/hotpath-alloc",
|
||||
"rustfs-rio/hotpath-alloc",
|
||||
"rustfs-signer/hotpath-alloc",
|
||||
"rustfs-utils/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-cpu",
|
||||
"rustfs-config/hotpath-cpu",
|
||||
"rustfs-credentials/hotpath-cpu",
|
||||
"rustfs-data-usage/hotpath-cpu",
|
||||
"rustfs-ecstore/hotpath-cpu",
|
||||
"rustfs-filemeta/hotpath-cpu",
|
||||
"rustfs-lock/hotpath-cpu",
|
||||
"rustfs-madmin/hotpath-cpu",
|
||||
"rustfs-protos/hotpath-cpu",
|
||||
"rustfs-rio/hotpath-cpu",
|
||||
"rustfs-signer/hotpath-cpu",
|
||||
"rustfs-utils/hotpath-cpu",
|
||||
]
|
||||
ftps = []
|
||||
sftp = []
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
rustfs-config = { workspace = true, features = ["constants"] }
|
||||
rustfs-credentials.workspace = true
|
||||
rustfs-ecstore.workspace = true
|
||||
|
||||
@@ -117,9 +117,14 @@ mod tests {
|
||||
.key("assets/explicit-copy.js")
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.metadata_directive(MetadataDirective::Copy)
|
||||
.customize()
|
||||
.mutate_request(|request| {
|
||||
request.headers_mut().insert("content-type", "application/octet-stream");
|
||||
request.headers_mut().insert("x-amz-meta-request-only", "ignored");
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect("explicit COPY directive failed");
|
||||
.expect("explicit COPY directive with request metadata failed");
|
||||
let explicit_copy_head = client
|
||||
.head_object()
|
||||
.bucket(bucket)
|
||||
@@ -128,6 +133,18 @@ mod tests {
|
||||
.await
|
||||
.expect("HEAD failed after explicit COPY");
|
||||
assert_eq!(explicit_copy_head.cache_control(), Some("max-age=60"));
|
||||
assert_eq!(explicit_copy_head.content_type(), Some("text/javascript; charset=utf-8"));
|
||||
assert_eq!(
|
||||
explicit_copy_head.metadata().and_then(|metadata| metadata.get("mtime")),
|
||||
Some(&"1777992333".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
explicit_copy_head
|
||||
.metadata()
|
||||
.and_then(|metadata| metadata.get("request-only")),
|
||||
None,
|
||||
"COPY must ignore request metadata"
|
||||
);
|
||||
assert_eq!(
|
||||
explicit_copy_head.website_redirect_location(),
|
||||
None,
|
||||
@@ -571,20 +588,6 @@ mod tests {
|
||||
Some("InvalidArgument")
|
||||
);
|
||||
|
||||
let ignored_replacement = client
|
||||
.copy_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.copy_source(format!("{bucket}/{key}"))
|
||||
.content_type("application/ignored")
|
||||
.send()
|
||||
.await
|
||||
.expect_err("Replacement fields without REPLACE should be rejected");
|
||||
assert_eq!(
|
||||
ignored_replacement.as_service_error().and_then(|error| error.code()),
|
||||
Some("InvalidRequest")
|
||||
);
|
||||
|
||||
let unchanged = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
|
||||
@@ -56,6 +56,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn assert_current_list_hides_delete_marker(client: &Client, bucket: &str, key: &str) {
|
||||
let listed = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("list current objects after delete marker");
|
||||
|
||||
assert!(
|
||||
listed.contents().iter().all(|object| object.key() != Some(key)),
|
||||
"ListObjectsV2 must hide an object whose latest version is a delete marker"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioning_only_delete_marker_has_minio_compatible_visibility_for_migration_proof() {
|
||||
@@ -94,6 +109,7 @@ mod tests {
|
||||
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
||||
assert_eq!(markers[0].is_latest(), Some(true));
|
||||
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
||||
assert_current_list_hides_delete_marker(&client, bucket, key).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -118,6 +134,17 @@ mod tests {
|
||||
.await
|
||||
.expect("put historical version");
|
||||
let data_version_id = put.version_id().expect("put should return data version id");
|
||||
let listed_before_delete = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.prefix(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("list current object before creating delete marker");
|
||||
assert!(
|
||||
listed_before_delete.contents().iter().any(|object| object.key() == Some(key)),
|
||||
"ListObjectsV2 must include the current object before it is deleted"
|
||||
);
|
||||
|
||||
let delete_marker = client
|
||||
.delete_object()
|
||||
@@ -145,6 +172,7 @@ mod tests {
|
||||
assert_eq!(markers[0].version_id(), Some(delete_marker_version_id));
|
||||
assert_eq!(markers[0].is_latest(), Some(true));
|
||||
assert_current_get_is_delete_marker_not_found(&client, bucket, key).await;
|
||||
assert_current_list_hides_delete_marker(&client, bucket, key).await;
|
||||
|
||||
let historical = client
|
||||
.get_object()
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
use super::common::*;
|
||||
use aws_sdk_s3::Client;
|
||||
use aws_sdk_s3::error::ProvideErrorMetadata;
|
||||
use aws_sdk_s3::primitives::{ByteStream, DateTimeFormat};
|
||||
use aws_sdk_s3::types::{
|
||||
CompletedMultipartUpload, CompletedPart, Delete, MetadataDirective, ObjectIdentifier, ObjectLockLegalHoldStatus,
|
||||
@@ -2120,6 +2121,127 @@ async fn test_multipart_default_retention_fixed_at_create() {
|
||||
// Versioning Auto-Enable Tests
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_unretained_object_lock_object_delete_and_bucket_cleanup() {
|
||||
init_logging();
|
||||
info!("🧪 Test: Unretained Object Lock object delete and bucket cleanup (Issue #5339)");
|
||||
|
||||
let mut env = ObjectLockTestEnvironment::new()
|
||||
.await
|
||||
.expect("failed to create Object Lock test environment");
|
||||
env.start_rustfs().await.expect("failed to start RustFS");
|
||||
|
||||
let bucket = "test-object-lock-delete-cleanup";
|
||||
let key = "unretained-object";
|
||||
|
||||
env.create_object_lock_bucket(bucket)
|
||||
.await
|
||||
.expect("failed to create Object Lock bucket");
|
||||
let client = env.s3_client();
|
||||
|
||||
let put_response = client
|
||||
.put_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.body(ByteStream::from_static(b"unretained data"))
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to upload unretained object");
|
||||
let object_version_id = put_response
|
||||
.version_id()
|
||||
.expect("Object Lock buckets must create versioned objects")
|
||||
.to_string();
|
||||
|
||||
let delete_response = client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to create delete marker");
|
||||
assert_eq!(delete_response.delete_marker(), Some(true));
|
||||
let delete_marker_version_id = delete_response
|
||||
.version_id()
|
||||
.expect("Deleting without a version ID must create a delete marker")
|
||||
.to_string();
|
||||
|
||||
let get_error = client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await
|
||||
.expect_err("GET must not return an object hidden by a delete marker");
|
||||
assert_eq!(get_error.raw_response().map(|response| response.status().as_u16()), Some(404));
|
||||
assert_eq!(get_error.as_service_error().and_then(|error| error.code()), Some("NoSuchKey"));
|
||||
|
||||
let listed_objects = client
|
||||
.list_objects_v2()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to list current objects");
|
||||
assert!(
|
||||
listed_objects.contents().iter().all(|object| object.key() != Some(key)),
|
||||
"ListObjectsV2 must hide objects whose latest version is a delete marker"
|
||||
);
|
||||
|
||||
let listed_versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to list object versions");
|
||||
assert!(
|
||||
listed_versions
|
||||
.versions()
|
||||
.iter()
|
||||
.any(|version| version.key() == Some(key) && version.version_id() == Some(object_version_id.as_str())),
|
||||
"The data version must remain until it is explicitly deleted"
|
||||
);
|
||||
assert!(
|
||||
listed_versions
|
||||
.delete_markers()
|
||||
.iter()
|
||||
.any(|marker| marker.key() == Some(key) && marker.version_id() == Some(delete_marker_version_id.as_str())),
|
||||
"ListObjectVersions must expose the delete marker"
|
||||
);
|
||||
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(object_version_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to delete the data version");
|
||||
client
|
||||
.delete_object()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.version_id(delete_marker_version_id)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to delete the delete marker");
|
||||
|
||||
let remaining_versions = client
|
||||
.list_object_versions()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("failed to list versions after cleanup");
|
||||
assert!(remaining_versions.versions().is_empty());
|
||||
assert!(remaining_versions.delete_markers().is_empty());
|
||||
|
||||
client
|
||||
.delete_bucket()
|
||||
.bucket(bucket)
|
||||
.send()
|
||||
.await
|
||||
.expect("Deleting every version must remove xl.meta so the bucket can be deleted normally");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_versioning_auto_enabled_with_object_lock() {
|
||||
|
||||
@@ -3237,116 +3237,6 @@ async fn test_bucket_replication_converges_delete_marker_and_version_purge() ->
|
||||
.await?;
|
||||
assert_eq!(retained.body.collect().await?.into_bytes().as_ref(), b"versioned replication payload v2");
|
||||
|
||||
source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key(object_key)
|
||||
.version_id(delete_marker_version_id)
|
||||
.send()
|
||||
.await?;
|
||||
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
|
||||
let target_state = list_replication_state(&target_client, target_bucket).await?;
|
||||
assert!(
|
||||
target_state.iter().all(|entry| entry.version_id != delete_marker_version_id),
|
||||
"target retained the explicitly purged delete-marker version"
|
||||
);
|
||||
assert!(
|
||||
target_state
|
||||
.iter()
|
||||
.any(|entry| !entry.delete_marker && entry.version_id == retained_version_id),
|
||||
"target removed the retained object version while purging the delete marker: {target_state:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bucket_replication_disabled_version_delete_preserves_target_versions_issue_5442() -> TestResult {
|
||||
init_logging();
|
||||
|
||||
let mut source_env = RustFSTestEnvironment::new().await?;
|
||||
let mut source_env_vars = replication_fast_env();
|
||||
source_env_vars.extend_from_slice(LOOPBACK_REPLICATION_TARGET_ENV);
|
||||
source_env.start_rustfs_server_with_env(vec![], &source_env_vars).await?;
|
||||
|
||||
let mut target_env = RustFSTestEnvironment::new().await?;
|
||||
target_env.start_rustfs_server_without_cleanup(vec![]).await?;
|
||||
|
||||
let source_bucket = "replication-no-version-delete-src";
|
||||
let target_bucket = "replication-no-version-delete-dst";
|
||||
let object_key = "retained-versions.txt";
|
||||
let source_client = source_env.create_s3_client();
|
||||
let target_client = target_env.create_s3_client();
|
||||
|
||||
source_client.create_bucket().bucket(source_bucket).send().await?;
|
||||
target_client.create_bucket().bucket(target_bucket).send().await?;
|
||||
enable_bucket_versioning(&source_env, source_bucket).await?;
|
||||
enable_bucket_versioning(&target_env, target_bucket).await?;
|
||||
|
||||
let target_arn = set_replication_target(&source_env, source_bucket, &target_env, target_bucket).await?;
|
||||
put_bucket_replication_with_delete_statuses(&source_env, source_bucket, &target_arn, "Enabled", None).await?;
|
||||
|
||||
let put = source_client
|
||||
.put_object()
|
||||
.bucket(source_bucket)
|
||||
.key(object_key)
|
||||
.body(ByteStream::from_static(b"permanent delete replication disabled"))
|
||||
.send()
|
||||
.await?;
|
||||
let object_version_id = put.version_id().ok_or("source PUT omitted version ID")?.to_string();
|
||||
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
|
||||
|
||||
let delete = source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key(object_key)
|
||||
.send()
|
||||
.await?;
|
||||
let delete_marker_version_id = delete
|
||||
.version_id()
|
||||
.ok_or("source DELETE omitted marker version ID")?
|
||||
.to_string();
|
||||
assert_eq!(delete.delete_marker(), Some(true));
|
||||
assert_replication_converged(&source_client, source_bucket, &target_client, target_bucket).await?;
|
||||
|
||||
let expected_target_state = list_replication_state(&target_client, target_bucket).await?;
|
||||
assert_eq!(expected_target_state.len(), 2);
|
||||
assert!(
|
||||
expected_target_state
|
||||
.iter()
|
||||
.any(|entry| !entry.delete_marker && entry.version_id == object_version_id)
|
||||
);
|
||||
assert!(
|
||||
expected_target_state
|
||||
.iter()
|
||||
.any(|entry| entry.delete_marker && entry.version_id == delete_marker_version_id)
|
||||
);
|
||||
|
||||
for version_id in [&object_version_id, &delete_marker_version_id] {
|
||||
source_client
|
||||
.delete_object()
|
||||
.bucket(source_bucket)
|
||||
.key(object_key)
|
||||
.version_id(version_id)
|
||||
.send()
|
||||
.await?;
|
||||
}
|
||||
assert!(list_replication_state(&source_client, source_bucket).await?.is_empty());
|
||||
|
||||
let observation_deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let target_state = list_replication_state(&target_client, target_bucket).await?;
|
||||
assert_eq!(
|
||||
target_state, expected_target_state,
|
||||
"disabled permanent-delete replication changed target versions"
|
||||
);
|
||||
if tokio::time::Instant::now() >= observation_deadline {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -33,14 +33,98 @@ 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-checksums/hotpath",
|
||||
"rustfs-common/hotpath",
|
||||
"rustfs-concurrency/hotpath",
|
||||
"rustfs-config/hotpath",
|
||||
"rustfs-credentials/hotpath",
|
||||
"rustfs-data-usage/hotpath",
|
||||
"rustfs-filemeta/hotpath",
|
||||
"rustfs-io-metrics/hotpath",
|
||||
"rustfs-lifecycle/hotpath",
|
||||
"rustfs-lock/hotpath",
|
||||
"rustfs-madmin/hotpath",
|
||||
"rustfs-object-capacity/hotpath",
|
||||
"rustfs-policy/hotpath",
|
||||
"rustfs-protos/hotpath",
|
||||
"rustfs-replication/hotpath",
|
||||
"rustfs-rio/hotpath",
|
||||
"rustfs-rio-v2?/hotpath",
|
||||
"rustfs-s3-types/hotpath",
|
||||
"rustfs-signer/hotpath",
|
||||
"rustfs-storage-api/hotpath",
|
||||
"rustfs-tls-runtime/hotpath",
|
||||
"rustfs-utils/hotpath",
|
||||
"rustfs-crypto/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-checksums/hotpath-alloc",
|
||||
"rustfs-common/hotpath-alloc",
|
||||
"rustfs-concurrency/hotpath-alloc",
|
||||
"rustfs-config/hotpath-alloc",
|
||||
"rustfs-credentials/hotpath-alloc",
|
||||
"rustfs-data-usage/hotpath-alloc",
|
||||
"rustfs-filemeta/hotpath-alloc",
|
||||
"rustfs-io-metrics/hotpath-alloc",
|
||||
"rustfs-lifecycle/hotpath-alloc",
|
||||
"rustfs-lock/hotpath-alloc",
|
||||
"rustfs-madmin/hotpath-alloc",
|
||||
"rustfs-object-capacity/hotpath-alloc",
|
||||
"rustfs-policy/hotpath-alloc",
|
||||
"rustfs-protos/hotpath-alloc",
|
||||
"rustfs-replication/hotpath-alloc",
|
||||
"rustfs-rio/hotpath-alloc",
|
||||
"rustfs-rio-v2?/hotpath-alloc",
|
||||
"rustfs-s3-types/hotpath-alloc",
|
||||
"rustfs-signer/hotpath-alloc",
|
||||
"rustfs-storage-api/hotpath-alloc",
|
||||
"rustfs-tls-runtime/hotpath-alloc",
|
||||
"rustfs-utils/hotpath-alloc",
|
||||
"rustfs-crypto/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-cpu",
|
||||
"rustfs-checksums/hotpath-cpu",
|
||||
"rustfs-common/hotpath-cpu",
|
||||
"rustfs-concurrency/hotpath-cpu",
|
||||
"rustfs-config/hotpath-cpu",
|
||||
"rustfs-credentials/hotpath-cpu",
|
||||
"rustfs-data-usage/hotpath-cpu",
|
||||
"rustfs-filemeta/hotpath-cpu",
|
||||
"rustfs-io-metrics/hotpath-cpu",
|
||||
"rustfs-lifecycle/hotpath-cpu",
|
||||
"rustfs-lock/hotpath-cpu",
|
||||
"rustfs-madmin/hotpath-cpu",
|
||||
"rustfs-object-capacity/hotpath-cpu",
|
||||
"rustfs-policy/hotpath-cpu",
|
||||
"rustfs-protos/hotpath-cpu",
|
||||
"rustfs-replication/hotpath-cpu",
|
||||
"rustfs-rio/hotpath-cpu",
|
||||
"rustfs-rio-v2?/hotpath-cpu",
|
||||
"rustfs-s3-types/hotpath-cpu",
|
||||
"rustfs-signer/hotpath-cpu",
|
||||
"rustfs-storage-api/hotpath-cpu",
|
||||
"rustfs-tls-runtime/hotpath-cpu",
|
||||
"rustfs-utils/hotpath-cpu",
|
||||
"rustfs-crypto/hotpath-cpu",
|
||||
]
|
||||
# Exposes shared lifecycle/tier test utilities (MockWarmBackend, fault
|
||||
# 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 +141,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
|
||||
@@ -125,8 +208,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"] }
|
||||
|
||||
@@ -267,12 +267,13 @@ pub mod config {
|
||||
pub mod com {
|
||||
pub use crate::config::com::{
|
||||
COMMA_SEPARATED_LISTS, CONFIG_PREFIX, ENV_CONFIG_RECOVER_ON_CORRUPTION, STORAGE_CLASS_SUB_SYS,
|
||||
ServerConfigCorruptError, ServerConfigSnapshot, delete_config, is_server_config_corrupt_error, lookup_configs,
|
||||
read_config, read_config_no_lock, read_config_with_metadata, read_config_without_migrate,
|
||||
read_config_without_migrate_no_lock, read_existing_server_config_no_lock, read_server_config_snapshot, save_config,
|
||||
save_config_no_lock, save_config_with_opts, save_server_config, save_server_config_no_lock,
|
||||
save_server_config_snapshot, server_config_path, try_migrate_server_config, with_config_object_read_lock,
|
||||
with_config_object_write_lock, with_server_config_read_lock, with_server_config_write_lock,
|
||||
ServerConfigCorruptError, ServerConfigSaveResult, ServerConfigSnapshot, delete_config,
|
||||
is_server_config_corrupt_error, lookup_configs, read_config, read_config_no_lock, read_config_with_metadata,
|
||||
read_config_without_migrate, read_config_without_migrate_no_lock, read_existing_server_config_no_lock,
|
||||
read_server_config_snapshot, save_config, save_config_no_lock, save_config_with_opts, save_server_config,
|
||||
save_server_config_no_lock, save_server_config_snapshot, save_server_config_snapshot_with_generation,
|
||||
server_config_path, try_migrate_server_config, with_config_object_read_lock, with_config_object_write_lock,
|
||||
with_server_config_read_lock, with_server_config_write_lock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -391,10 +392,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;
|
||||
}
|
||||
|
||||
@@ -1278,12 +1278,6 @@ fn build_remove_object_headers(version_id: Option<&str>, opts: &RemoveObjectOpti
|
||||
fn resolve_delete_api_version_id(version_id: Option<String>, opts: &RemoveObjectOptions) -> Option<String> {
|
||||
if opts.replication_request && opts.replication_delete_marker {
|
||||
None
|
||||
} else if opts.replication_request
|
||||
&& version_id
|
||||
.as_deref()
|
||||
.is_some_and(|version_id| Uuid::parse_str(version_id).is_ok_and(|version_id| version_id.is_nil()))
|
||||
{
|
||||
Some("null".to_string())
|
||||
} else {
|
||||
version_id
|
||||
}
|
||||
@@ -2265,12 +2259,6 @@ mod tests {
|
||||
assert_eq!(got.as_deref(), Some(vid.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_version_purge_sends_s3_null_version_id() {
|
||||
let got = resolve_delete_api_version_id(Some(Uuid::nil().to_string()), &remove_opts(true, false));
|
||||
assert_eq!(got.as_deref(), Some("null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_marker_propagation_omits_versionid_query_param() {
|
||||
// Propagating a delete-marker CREATION (delete_marker=true): the target
|
||||
|
||||
@@ -579,8 +579,26 @@ pub struct BucketMetadataSys {
|
||||
/// Serializes metadata-map commits and their derived cache updates for one
|
||||
/// bucket. Namespace locks, when present, are acquired before this lock.
|
||||
metadata_publish_locks: Arc<MetadataPublishLockRegistry>,
|
||||
/// Deduplicates concurrent lazy loads of one bucket's metadata, so N
|
||||
/// simultaneous cache misses issue a single disk read instead of N.
|
||||
///
|
||||
/// This is the `singleflight` that upstream applies to its own lazy
|
||||
/// `GetConfig`. Without it the namespace *read* lock the load holds is no
|
||||
/// help: read locks are shared, so it excludes concurrent config writers
|
||||
/// but not concurrent readers, and every caller still pays a full
|
||||
/// erasure-set metadata fanout. A separate registry from
|
||||
/// `metadata_publish_locks`, reusing the same per-bucket lock machinery.
|
||||
///
|
||||
/// Lock order: this lock, then the namespace lock, then the publish lock,
|
||||
/// then the metadata map. It is only ever taken as the first of those, so
|
||||
/// it cannot invert against a path that already holds one of the others.
|
||||
lazy_load_locks: Arc<MetadataPublishLockRegistry>,
|
||||
#[cfg(test)]
|
||||
lazy_load_lock_probe: std::sync::atomic::AtomicBool,
|
||||
/// Counts disk loads taken by the lazy `get_config` path, so a test can
|
||||
/// prove concurrent misses collapse into one.
|
||||
#[cfg(test)]
|
||||
lazy_disk_loads: std::sync::atomic::AtomicUsize,
|
||||
/// Buckets recently observed to have no persisted metadata. Serving the
|
||||
/// fabricated default from here (instead of re-reading disk) keeps the
|
||||
/// per-request cost of repeated lookups for such names bounded — without
|
||||
@@ -599,8 +617,13 @@ impl BucketMetadataSys {
|
||||
metadata_publish_locks: Arc::new(MetadataPublishLockRegistry {
|
||||
locks: StdMutex::new(HashMap::new()),
|
||||
}),
|
||||
lazy_load_locks: Arc::new(MetadataPublishLockRegistry {
|
||||
locks: StdMutex::new(HashMap::new()),
|
||||
}),
|
||||
#[cfg(test)]
|
||||
lazy_load_lock_probe: std::sync::atomic::AtomicBool::new(false),
|
||||
#[cfg(test)]
|
||||
lazy_disk_loads: std::sync::atomic::AtomicUsize::new(0),
|
||||
absent_metadata: moka::future::Cache::builder()
|
||||
.max_capacity(ABSENT_BUCKET_METADATA_MAX_ENTRIES)
|
||||
.time_to_live(ABSENT_BUCKET_METADATA_TTL)
|
||||
@@ -615,16 +638,22 @@ impl BucketMetadataSys {
|
||||
}
|
||||
|
||||
fn metadata_publish_lock(&self, bucket: &str) -> Arc<Mutex<MetadataPublishLockState>> {
|
||||
let mut locks = self
|
||||
.metadata_publish_locks
|
||||
.locks
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
Self::bucket_lock_in(&self.metadata_publish_locks, bucket)
|
||||
}
|
||||
|
||||
/// Per-bucket gate for the lazy `get_config` disk load. See
|
||||
/// [`Self::lazy_load_locks`].
|
||||
fn lazy_load_lock(&self, bucket: &str) -> Arc<Mutex<MetadataPublishLockState>> {
|
||||
Self::bucket_lock_in(&self.lazy_load_locks, bucket)
|
||||
}
|
||||
|
||||
fn bucket_lock_in(registry: &Arc<MetadataPublishLockRegistry>, bucket: &str) -> Arc<Mutex<MetadataPublishLockState>> {
|
||||
let mut locks = registry.locks.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
locks.get(bucket).and_then(Weak::upgrade).unwrap_or_else(|| {
|
||||
let lock = Arc::new_cyclic(|lock| {
|
||||
Mutex::new(MetadataPublishLockState {
|
||||
bucket: bucket.to_string(),
|
||||
registry: Arc::downgrade(&self.metadata_publish_locks),
|
||||
registry: Arc::downgrade(registry),
|
||||
lock: lock.clone(),
|
||||
})
|
||||
});
|
||||
@@ -1079,6 +1108,27 @@ impl BucketMetadataSys {
|
||||
return Ok((Arc::new(bm), true));
|
||||
}
|
||||
|
||||
// Collapse concurrent misses for this bucket into one disk load.
|
||||
// Taken before the namespace lock — see `lazy_load_locks` for the
|
||||
// ordering rule.
|
||||
let load_lock = self.lazy_load_lock(bucket);
|
||||
let _load_guard = load_lock.lock_owned().await;
|
||||
|
||||
// Re-check both caches: whoever held the gate before us may have
|
||||
// already answered this exact question, and repeating the fanout
|
||||
// is the whole cost this gate exists to avoid.
|
||||
if let Some(bm) = self.metadata_map.read().await.get(bucket).cloned() {
|
||||
return Ok((bm, true));
|
||||
}
|
||||
if self.absent_metadata.get(bucket).await.is_some() {
|
||||
let mut bm = BucketMetadata::new(bucket);
|
||||
bm.default_timestamps();
|
||||
return Ok((Arc::new(bm), true));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
self.lazy_disk_loads.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let lock = self.api.new_ns_lock(bucket, bucket).await?;
|
||||
let guard = lock.get_read_lock(crate::set_disk::get_lock_acquire_timeout()).await?;
|
||||
#[cfg(test)]
|
||||
@@ -1431,6 +1481,43 @@ mod tests {
|
||||
use serial_test::serial;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Concurrent cache misses for one bucket must collapse into a single disk
|
||||
/// load.
|
||||
///
|
||||
/// The namespace read lock the lazy path already holds does not provide
|
||||
/// this: read locks are shared, so it excludes concurrent config writers
|
||||
/// but not concurrent readers. Without the dedup gate every caller pays its
|
||||
/// own namespace-lock acquisition plus a full erasure-set metadata fanout —
|
||||
/// and the paths that reach `get_config` are per-request, so the multiplier
|
||||
/// is request concurrency.
|
||||
#[tokio::test]
|
||||
async fn concurrent_lazy_loads_of_one_bucket_issue_a_single_disk_read() {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let (_dirs, ecstore) = isolated_store_over_temp_disks().await;
|
||||
let sys = Arc::new(BucketMetadataSys::new(ecstore));
|
||||
|
||||
// A name with no persisted metadata: every caller misses the map, and
|
||||
// the absent-cache entry does not exist until the first load records it.
|
||||
let bucket = "singleflight-bucket";
|
||||
let waiters = 8;
|
||||
|
||||
let results = futures::future::join_all((0..waiters).map(|_| {
|
||||
let sys = Arc::clone(&sys);
|
||||
async move { sys.get_config(bucket).await.map(|(bm, _)| bm.name.clone()) }
|
||||
}))
|
||||
.await;
|
||||
|
||||
for result in results {
|
||||
assert_eq!(result.expect("every caller must get an answer"), bucket);
|
||||
}
|
||||
assert_eq!(
|
||||
sys.lazy_disk_loads.load(Ordering::Relaxed),
|
||||
1,
|
||||
"concurrent misses for one bucket must share a single disk load"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pins the fail-closed caching contract of the lazy `get_config` path
|
||||
/// and the refresh no-replace rule: fabricated defaults are returned but
|
||||
/// never served by the map-only `get()`, persisted metadata is cached on
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
// limitations under the License.
|
||||
|
||||
pub use rustfs_replication::{
|
||||
ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, delete_replication_target_arns,
|
||||
replication_target_arns, should_remove_replication_target, validate_replication_config_target_arns,
|
||||
ObjectOpts, ReplicationConfigurationExt, ReplicationTargetValidationError, replication_target_arns,
|
||||
should_remove_replication_target, validate_replication_config_target_arns,
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ use super::replication_logging::{EVENT_RESYNC_CONFIG_LOOKUP_SKIPPED, LOG_COMPONE
|
||||
use super::replication_metadata_boundary::ReplicationMetadataStore;
|
||||
use super::replication_object_decision_boundary::{
|
||||
MustReplicateOptions, ReplicationDeleteSource, ReplicationResyncTargetObject, delete_replication_missing_source_decision,
|
||||
delete_replication_object_opts, delete_replication_version_id, resync_target_for_object,
|
||||
delete_replication_object_opts, resync_target_for_object,
|
||||
};
|
||||
use super::replication_storage_boundary::{ObjectInfo, ObjectOptions, ObjectToDelete, object_to_delete_for_replication};
|
||||
use super::replication_target_boundary::{BucketTargets, ReplicationTargetStore};
|
||||
@@ -73,7 +73,7 @@ impl ReplicationConfig {
|
||||
if oi.delete_marker {
|
||||
let opts = ObjectOpts {
|
||||
name: oi.name.clone(),
|
||||
version_id: delete_replication_version_id(oi.delete_marker, oi.version_id, !oi.version_purge_status.is_empty()),
|
||||
version_id: oi.version_id,
|
||||
delete_marker: true,
|
||||
op_type: ReplicationType::Delete,
|
||||
existing_object: true,
|
||||
@@ -299,14 +299,8 @@ pub(crate) async fn must_replicate(bucket: &str, object: &str, mopts: MustReplic
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use s3s::dto::{
|
||||
DeleteMarkerReplication, DeleteMarkerReplicationStatus, DeleteReplication, DeleteReplicationStatus, Destination,
|
||||
ReplicationRule, ReplicationRuleStatus,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
use s3s::dto::{Destination, ReplicationRule, ReplicationRuleStatus};
|
||||
|
||||
use super::super::replication_filemeta_boundary::VersionPurgeStatusType;
|
||||
use super::super::replication_target_boundary::BucketTarget;
|
||||
use super::*;
|
||||
|
||||
fn replication_rule() -> ReplicationRule {
|
||||
@@ -366,56 +360,4 @@ mod tests {
|
||||
assert!(options.is_replication_request());
|
||||
assert_eq!(options.user_tags(), "env=prod");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resync_distinguishes_delete_marker_creation_from_version_purge() {
|
||||
let arn = "arn:rustfs:replication:us-east-1:target:bucket";
|
||||
let mut rule = replication_rule();
|
||||
rule.destination.bucket = arn.to_string();
|
||||
rule.delete_marker_replication = Some(DeleteMarkerReplication {
|
||||
status: Some(DeleteMarkerReplicationStatus::from_static(DeleteMarkerReplicationStatus::ENABLED)),
|
||||
});
|
||||
rule.delete_replication = Some(DeleteReplication {
|
||||
status: DeleteReplicationStatus::from_static(DeleteReplicationStatus::DISABLED),
|
||||
});
|
||||
let config = ReplicationConfig::new(
|
||||
Some(ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![rule],
|
||||
}),
|
||||
Some(BucketTargets {
|
||||
targets: vec![BucketTarget {
|
||||
arn: arn.to_string(),
|
||||
endpoint: "target.example".to_string(),
|
||||
..Default::default()
|
||||
}],
|
||||
}),
|
||||
);
|
||||
let marker_version_id = Uuid::new_v4();
|
||||
let marker = ObjectInfo {
|
||||
bucket: "source".to_string(),
|
||||
name: "object".to_string(),
|
||||
delete_marker: true,
|
||||
version_id: Some(marker_version_id),
|
||||
replication_status: ReplicationStatusType::Pending,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let creation = config
|
||||
.resync(marker.clone(), ReplicateDecision::default(), &HashMap::new())
|
||||
.await;
|
||||
assert!(creation.targets.contains_key(arn));
|
||||
|
||||
let purge = config
|
||||
.resync(
|
||||
ObjectInfo {
|
||||
version_purge_status: VersionPurgeStatusType::Pending,
|
||||
..marker
|
||||
},
|
||||
ReplicateDecision::default(),
|
||||
&HashMap::new(),
|
||||
)
|
||||
.await;
|
||||
assert!(purge.targets.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
// limitations under the License.
|
||||
|
||||
pub use rustfs_replication::{
|
||||
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_parts,
|
||||
delete_replication_state_from_config, delete_replication_version_id, should_schedule_delete_replication,
|
||||
should_use_existing_delete_replication_info, should_use_existing_delete_replication_source,
|
||||
MustReplicateOptions, ReplicationDeleteScheduleInput, ReplicationDeleteStateSource, delete_replication_state_from_config,
|
||||
delete_replication_version_id, should_schedule_delete_replication, should_use_existing_delete_replication_info,
|
||||
should_use_existing_delete_replication_source,
|
||||
};
|
||||
pub(crate) use rustfs_replication::{
|
||||
ReplicationDeleteSource, ReplicationMultipartPartInput, ReplicationResyncTargetObject,
|
||||
delete_replication_missing_source_decision, delete_replication_object_opts, heal_uses_delete_replication_path,
|
||||
is_retryable_delete_replication_head_error, is_version_delete_replication, replication_etags_match,
|
||||
replication_multipart_complete_actual_size, replication_multipart_part_plan, resync_target_for_object,
|
||||
should_retry_delete_marker_purge, version_purge_target_missing,
|
||||
should_retry_delete_marker_purge,
|
||||
};
|
||||
|
||||
@@ -67,7 +67,6 @@ const EVENT_REPLICATION_BACKPRESSURE: &str = "replication_backpressure";
|
||||
const EVENT_REPLICATION_RESYNC_LOAD_SKIPPED: &str = "replication_resync_load_skipped";
|
||||
const EVENT_REPLICATION_RESYNC_RECOVERED: &str = "replication_resync_recovered";
|
||||
const EVENT_REPLICATION_MRF_QUEUE_UNAVAILABLE: &str = "replication_mrf_queue_unavailable";
|
||||
const EVENT_REPLICATION_MRF_ENTRY_SKIPPED: &str = "replication_mrf_entry_skipped";
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DurableMrfBacklog {
|
||||
@@ -661,21 +660,6 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
for entry in entries.iter() {
|
||||
match entry.op {
|
||||
MrfOpKind::Delete => {
|
||||
let Some(delete_parts) = entry.delete_parts_for_replay() else {
|
||||
debug!(
|
||||
event = EVENT_REPLICATION_MRF_ENTRY_SKIPPED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION,
|
||||
bucket = %entry.bucket,
|
||||
object = %entry.object,
|
||||
reason = "invalid_delete_version_ids",
|
||||
"Skipped invalid persisted replication delete"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let version_purge_id = delete_parts
|
||||
.version_id
|
||||
.or_else(|| delete_parts.delete_marker_version_id.filter(|_| !delete_parts.delete_marker));
|
||||
// Reconstruct a heal delete and re-queue it. We do NOT call
|
||||
// get_object_info here because the delete-marker or version may
|
||||
// already be absent from the local store — that is expected.
|
||||
@@ -690,20 +674,15 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let oi = ObjectInfo {
|
||||
bucket: entry.bucket.clone(),
|
||||
name: entry.object.clone(),
|
||||
version_id: version_purge_id,
|
||||
delete_marker: delete_parts.delete_marker,
|
||||
replication_status: if entry.replica {
|
||||
ReplicationStatusType::Replica
|
||||
} else {
|
||||
ReplicationStatusType::Empty
|
||||
},
|
||||
version_id: entry.version_id,
|
||||
delete_marker: entry.delete_marker,
|
||||
..Default::default()
|
||||
};
|
||||
let dsc = check_replicate_delete(
|
||||
&entry.bucket,
|
||||
&ObjectToDelete {
|
||||
object_name: entry.object.clone(),
|
||||
version_id: version_purge_id,
|
||||
version_id: entry.version_id,
|
||||
..Default::default()
|
||||
},
|
||||
&oi,
|
||||
@@ -729,9 +708,9 @@ impl<S: ReplicationStorage> ReplicationPool<S> {
|
||||
let dv = DeletedObjectReplicationInfo {
|
||||
delete_object: ReplicationDeletedObject {
|
||||
object_name: entry.object.clone(),
|
||||
version_id: delete_parts.version_id,
|
||||
delete_marker_version_id: delete_parts.delete_marker_version_id,
|
||||
delete_marker: delete_parts.delete_marker,
|
||||
version_id: entry.version_id,
|
||||
delete_marker_version_id: entry.delete_marker_version_id,
|
||||
delete_marker: entry.delete_marker,
|
||||
delete_marker_mtime,
|
||||
replication_state: Some(rstate),
|
||||
..Default::default()
|
||||
@@ -2354,7 +2333,6 @@ mod tests {
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
};
|
||||
let second = MrfReplicateEntry {
|
||||
@@ -2468,7 +2446,6 @@ mod tests {
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
};
|
||||
|
||||
@@ -2502,7 +2479,6 @@ mod tests {
|
||||
op: MrfOpKind::Delete,
|
||||
delete_marker_version_id: Some(dm_vid),
|
||||
delete_marker: true,
|
||||
replica: false,
|
||||
delete_marker_mtime: Some(mtime_nanos),
|
||||
};
|
||||
|
||||
@@ -2536,7 +2512,6 @@ mod tests {
|
||||
op: MrfOpKind::Delete,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
};
|
||||
|
||||
@@ -2565,7 +2540,6 @@ mod tests {
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
},
|
||||
MrfReplicateEntry {
|
||||
@@ -2577,7 +2551,6 @@ mod tests {
|
||||
op: MrfOpKind::Delete,
|
||||
delete_marker_version_id: Some(del_dm_vid),
|
||||
delete_marker: true,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
},
|
||||
];
|
||||
@@ -2607,7 +2580,6 @@ mod tests {
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
};
|
||||
assert_eq!(obj_entry.op, MrfOpKind::Object);
|
||||
@@ -2622,7 +2594,6 @@ mod tests {
|
||||
op: MrfOpKind::Delete,
|
||||
delete_marker_version_id: Some(Uuid::new_v4()),
|
||||
delete_marker: true,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
};
|
||||
assert_eq!(del_entry.op, MrfOpKind::Delete);
|
||||
@@ -2638,7 +2609,6 @@ mod tests {
|
||||
op: MrfOpKind::default(),
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
};
|
||||
assert_eq!(legacy_entry.op, MrfOpKind::Object, "legacy default must be Object");
|
||||
@@ -2702,7 +2672,6 @@ mod tests {
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
}];
|
||||
let encoded = encode_mrf_file(&entries).expect("durable MRF backlog should encode");
|
||||
@@ -2733,7 +2702,6 @@ mod tests {
|
||||
op: MrfOpKind::Object,
|
||||
delete_marker_version_id: None,
|
||||
delete_marker: false,
|
||||
replica: false,
|
||||
delete_marker_mtime: None,
|
||||
}])
|
||||
.expect("invalid persisted entry should still encode for boundary testing");
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use super::replication_bandwidth_boundary;
|
||||
use super::replication_config_boundary::{ObjectOpts, ReplicationConfigurationExt as _, delete_replication_target_arns};
|
||||
use super::replication_config_boundary::{ObjectOpts, ReplicationConfigurationExt as _};
|
||||
use super::replication_config_store::ReplicationConfigStore;
|
||||
use super::replication_error_boundary::{Result, is_err_object_not_found, is_err_version_not_found};
|
||||
use super::replication_event_sink::{EventArgs, send_event, send_local_event};
|
||||
@@ -29,10 +29,9 @@ use super::replication_metadata_boundary::ReplicationMetadataStore;
|
||||
use super::replication_msgp_boundary::ReplicationMsgpCodec;
|
||||
use super::replication_object_config::{ReplicationConfig, check_replicate_delete, get_replication_config, must_replicate};
|
||||
use super::replication_object_decision_boundary::{
|
||||
MustReplicateOptions, ReplicationMultipartPartInput, delete_replication_parts, heal_uses_delete_replication_path,
|
||||
MustReplicateOptions, ReplicationMultipartPartInput, heal_uses_delete_replication_path,
|
||||
is_retryable_delete_replication_head_error, is_version_delete_replication, replication_etags_match,
|
||||
replication_multipart_complete_actual_size, replication_multipart_part_plan, should_retry_delete_marker_purge,
|
||||
version_purge_target_missing,
|
||||
};
|
||||
use super::replication_queue_boundary::DeletedObjectReplicationInfo;
|
||||
use super::replication_resync_boundary::ResyncStatusType;
|
||||
@@ -50,7 +49,7 @@ use super::replication_target_boundary::{
|
||||
PutObjectOptions, PutObjectPartOptions, ReplicationTargetStore, TargetClient, replication_action_for_target_head,
|
||||
replication_complete_multipart_options, replication_delete_marker_purge_remove_options, replication_delete_remove_options,
|
||||
replication_force_delete_remove_options, replication_object_is_ssec_encrypted, replication_put_object_header_size,
|
||||
replication_put_object_options, replication_target_head_is_newer_null_version, replication_target_version_id,
|
||||
replication_put_object_options, replication_target_head_is_newer_null_version,
|
||||
};
|
||||
use super::replication_versioning_boundary::ReplicationVersioningStore;
|
||||
use super::runtime_boundary as runtime_sources;
|
||||
@@ -71,8 +70,9 @@ use rustfs_utils::http::{
|
||||
AMZ_TAGGING_DIRECTIVE, SUFFIX_REPLICATION_RESET, SUFFIX_REPLICATION_STATUS, has_internal_suffix, insert_str,
|
||||
};
|
||||
use rustfs_utils::{DEFAULT_SIP_HASH_KEY, sip_hash};
|
||||
#[cfg(test)]
|
||||
use s3s::dto::ReplicationConfiguration;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
@@ -97,8 +97,6 @@ const EVENT_RESYNC_TASK_FAILED: &str = "replication_resync_task_failed";
|
||||
const EVENT_RESYNC_TARGET_OPERATION_FAILED: &str = "replication_resync_target_operation_failed";
|
||||
const EVENT_RESYNC_RUNTIME_CHANNEL_FAILED: &str = "replication_resync_runtime_channel_failed";
|
||||
const ERR_REPLICATION_METADATA_COPY_UNSUPPORTED: &str = "metadata-only replication is not implemented";
|
||||
const ERR_VERSION_PURGE_TARGET_STILL_EXISTS: &str = "target version still exists after replication purge";
|
||||
const ERR_VERSION_PURGE_MISSING_VERSION_ID: &str = "version purge record is missing version ID";
|
||||
const REPLICATION_TARGET_OFFLINE_ERROR_MARKERS: &[&str] = &[
|
||||
"dispatch failure",
|
||||
"timeouterror",
|
||||
@@ -788,46 +786,19 @@ impl ReplicationResyncer {
|
||||
}
|
||||
|
||||
if roi.delete_marker || !roi.version_purge_status.is_empty() {
|
||||
let Some(parts) =
|
||||
delete_replication_parts(roi.delete_marker, roi.version_id, !roi.version_purge_status.is_empty())
|
||||
else {
|
||||
debug!(
|
||||
event = EVENT_RESYNC_OBJECT_PROCESSED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||
bucket = %bucket_name,
|
||||
object = %roi.name,
|
||||
reason = "version_purge_missing_version_id",
|
||||
"Failed to process resync version purge"
|
||||
);
|
||||
let status = TargetReplicationResyncStatus {
|
||||
bucket: roi.bucket.clone(),
|
||||
object: roi.name.clone(),
|
||||
failed_count: 1,
|
||||
error: Some(ERR_VERSION_PURGE_MISSING_VERSION_ID.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(err) = results_tx.send(status).await {
|
||||
error!(
|
||||
event = EVENT_RESYNC_RUNTIME_CHANNEL_FAILED,
|
||||
component = LOG_COMPONENT_ECSTORE,
|
||||
subsystem = LOG_SUBSYSTEM_REPLICATION_RESYNC,
|
||||
bucket = %bucket_name,
|
||||
reason = "status_channel_send_failed",
|
||||
error = %err,
|
||||
"Failed to send resync status"
|
||||
);
|
||||
}
|
||||
continue;
|
||||
let (version_id, dm_version_id) = if roi.version_purge_status.is_empty() {
|
||||
(None, roi.version_id)
|
||||
} else {
|
||||
(roi.version_id, None)
|
||||
};
|
||||
|
||||
let doi = DeletedObjectReplicationInfo {
|
||||
delete_object: ReplicationDeletedObject {
|
||||
object_name: roi.name.clone(),
|
||||
delete_marker_version_id: parts.delete_marker_version_id,
|
||||
version_id: parts.version_id,
|
||||
delete_marker_version_id: dm_version_id,
|
||||
version_id,
|
||||
replication_state: roi.replication_state.clone(),
|
||||
delete_marker: parts.delete_marker,
|
||||
delete_marker: roi.delete_marker,
|
||||
delete_marker_mtime: roi.mod_time,
|
||||
..Default::default()
|
||||
},
|
||||
@@ -850,41 +821,21 @@ impl ReplicationResyncer {
|
||||
};
|
||||
|
||||
let reset_id = target_client.reset_id.clone();
|
||||
let is_version_purge = !roi.version_purge_status.is_empty();
|
||||
|
||||
let head_result = head_object_with_proxy_stats(
|
||||
&bucket_name,
|
||||
target_client.as_ref(),
|
||||
&target_client.bucket,
|
||||
&roi.name,
|
||||
replication_target_version_id(roi.version_id, is_version_purge),
|
||||
roi.version_id.map(|v| v.to_string()),
|
||||
)
|
||||
.await;
|
||||
let (size, err) = match head_result {
|
||||
Ok(_) if is_version_purge => {
|
||||
st.failed_count += 1;
|
||||
(0, Some(ERR_VERSION_PURGE_TARGET_STILL_EXISTS.to_string()))
|
||||
}
|
||||
Ok(_) => {
|
||||
st.replicated_count += 1;
|
||||
st.replicated_size += roi.size;
|
||||
(roi.size, None)
|
||||
}
|
||||
Err(err) if is_version_purge => {
|
||||
let (is_not_found, code) = err
|
||||
.as_service_error()
|
||||
.map(|service_err| (service_err.is_not_found(), service_err.code()))
|
||||
.unwrap_or((false, None));
|
||||
let raw_status = err.raw_response().map(|response| response.status().as_u16());
|
||||
let missing = version_purge_target_missing(is_not_found, code, raw_status);
|
||||
if missing {
|
||||
st.replicated_count += 1;
|
||||
(0, None)
|
||||
} else {
|
||||
st.failed_count += 1;
|
||||
(0, resync_target_error_detail(&err))
|
||||
}
|
||||
}
|
||||
Err(err) if roi.delete_marker => {
|
||||
// Verifying a replicated delete marker: only a
|
||||
// definitive 404/NoSuchKey or 405/MethodNotAllowed
|
||||
@@ -901,7 +852,7 @@ impl ReplicationResyncer {
|
||||
};
|
||||
if retryable {
|
||||
st.failed_count += 1;
|
||||
(0, resync_target_error_detail(&err))
|
||||
(0, Some(err))
|
||||
} else {
|
||||
st.replicated_count += 1;
|
||||
(0, None)
|
||||
@@ -920,17 +871,17 @@ impl ReplicationResyncer {
|
||||
}
|
||||
Ok(None) => {
|
||||
st.failed_count += 1;
|
||||
(0, resync_target_error_detail(&err))
|
||||
(0, Some(err))
|
||||
}
|
||||
Err(e2) => {
|
||||
st.failed_count += 1;
|
||||
(0, resync_target_error_detail(&e2))
|
||||
(0, Some(e2))
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
st.failed_count += 1;
|
||||
(0, resync_target_error_detail(&err))
|
||||
(0, Some(err))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -960,7 +911,7 @@ impl ReplicationResyncer {
|
||||
"Processed resync object"
|
||||
);
|
||||
}
|
||||
st.error = err;
|
||||
st.error = err.as_ref().and_then(resync_target_error_detail);
|
||||
|
||||
if cancel_token.is_cancelled() {
|
||||
return;
|
||||
@@ -1114,27 +1065,22 @@ pub async fn get_heal_replicate_object_info(oi: &ObjectInfo, rcfg: &ReplicationC
|
||||
}
|
||||
|
||||
let dsc = if heal_uses_delete_replication_path(oi.delete_marker, &oi.version_purge_status) {
|
||||
match delete_replication_parts(oi.delete_marker, oi.version_id, !oi.version_purge_status.is_empty()) {
|
||||
Some(parts) => {
|
||||
check_replicate_delete(
|
||||
oi.bucket.as_str(),
|
||||
&ObjectToDelete {
|
||||
object_name: oi.name.clone(),
|
||||
version_id: parts.version_id,
|
||||
..Default::default()
|
||||
},
|
||||
&oi,
|
||||
&ObjectOptions {
|
||||
versioned: ReplicationVersioningStore::prefix_enabled(&oi.bucket, &oi.name).await,
|
||||
version_suspended: ReplicationVersioningStore::prefix_suspended(&oi.bucket, &oi.name).await,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => ReplicateDecision::default(),
|
||||
}
|
||||
check_replicate_delete(
|
||||
oi.bucket.as_str(),
|
||||
&ObjectToDelete {
|
||||
object_name: oi.name.clone(),
|
||||
version_id: oi.version_id,
|
||||
..Default::default()
|
||||
},
|
||||
&oi,
|
||||
&ObjectOptions {
|
||||
versioned: ReplicationVersioningStore::prefix_enabled(&oi.bucket, &oi.name).await,
|
||||
version_suspended: ReplicationVersioningStore::prefix_suspended(&oi.bucket, &oi.name).await,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
must_replicate(
|
||||
oi.bucket.as_str(),
|
||||
@@ -1204,6 +1150,7 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
} else {
|
||||
dobj.delete_object.version_id
|
||||
};
|
||||
|
||||
let _rcfg = match get_replication_config(&bucket).await {
|
||||
Ok(Some(config)) => config,
|
||||
Ok(None) => {
|
||||
@@ -1522,8 +1469,8 @@ pub async fn replicate_delete<S: ReplicationStorage>(dobj: DeletedObjectReplicat
|
||||
delete_marker_version_id,
|
||||
)
|
||||
.await
|
||||
&& replicate_delete_marker_purge_to_targets(&bucket_clone, &dobj_clone, &dsc_clone).await
|
||||
{
|
||||
replicate_delete_marker_purge_to_targets(&bucket_clone, &dobj_clone, &dsc_clone).await;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(TokioDuration::from_secs(1)).await;
|
||||
@@ -1653,50 +1600,9 @@ async fn source_delete_marker_missing<S: EcstoreObjectOperations>(
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_marker_purge_target_arns(config: &ReplicationConfiguration, dobj: &DeletedObjectReplicationInfo) -> HashSet<String> {
|
||||
let replica = dobj
|
||||
.delete_object
|
||||
.replication_state
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.replica_status == ReplicationStatusType::Replica);
|
||||
|
||||
delete_replication_target_arns(config, &dobj.delete_object.object_name, replica)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DeleteMarkerPurgeConfig {
|
||||
Apply(HashSet<String>),
|
||||
Stop,
|
||||
Retry,
|
||||
}
|
||||
|
||||
fn delete_marker_purge_config<E>(
|
||||
result: std::result::Result<Option<ReplicationConfiguration>, E>,
|
||||
dobj: &DeletedObjectReplicationInfo,
|
||||
) -> DeleteMarkerPurgeConfig {
|
||||
match result {
|
||||
Ok(Some(config)) => DeleteMarkerPurgeConfig::Apply(delete_marker_purge_target_arns(&config, dobj)),
|
||||
Ok(None) => DeleteMarkerPurgeConfig::Stop,
|
||||
Err(_) => DeleteMarkerPurgeConfig::Retry,
|
||||
}
|
||||
}
|
||||
|
||||
async fn replicate_delete_marker_purge_to_targets(
|
||||
bucket: &str,
|
||||
dobj: &DeletedObjectReplicationInfo,
|
||||
dsc: &ReplicateDecision,
|
||||
) -> bool {
|
||||
async fn replicate_delete_marker_purge_to_targets(bucket: &str, dobj: &DeletedObjectReplicationInfo, dsc: &ReplicateDecision) {
|
||||
let Some(delete_marker_version_id) = dobj.delete_object.delete_marker_version_id else {
|
||||
return true;
|
||||
};
|
||||
let marker_creation_purge_targets = if dobj.delete_object.delete_marker {
|
||||
match delete_marker_purge_config(get_replication_config(bucket).await, dobj) {
|
||||
DeleteMarkerPurgeConfig::Apply(targets) => Some(targets),
|
||||
DeleteMarkerPurgeConfig::Stop => return true,
|
||||
DeleteMarkerPurgeConfig::Retry => return false,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
return;
|
||||
};
|
||||
|
||||
for tgt_entry in dsc.targets_map.values() {
|
||||
@@ -1706,12 +1612,6 @@ async fn replicate_delete_marker_purge_to_targets(
|
||||
if !dobj.target_arn.is_empty() && dobj.target_arn != tgt_entry.arn {
|
||||
continue;
|
||||
}
|
||||
if marker_creation_purge_targets
|
||||
.as_ref()
|
||||
.is_some_and(|targets| !targets.contains(&tgt_entry.arn))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(tgt_client) = ReplicationTargetStore::remote_target_client(bucket, &tgt_entry.arn).await else {
|
||||
continue;
|
||||
};
|
||||
@@ -1725,7 +1625,6 @@ async fn replicate_delete_marker_purge_to_targets(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &DeletedObjectReplicationInfo, storage: Arc<S>) {
|
||||
@@ -1952,10 +1851,10 @@ async fn replicate_force_delete_to_targets<S: ReplicationStorage>(dobj: &Deleted
|
||||
}
|
||||
|
||||
async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_client: Arc<TargetClient>) -> ReplicatedTargetInfo {
|
||||
let version_id = if let Some(version_id) = dobj.delete_object.delete_marker_version_id {
|
||||
Some(version_id)
|
||||
let version_id = if let Some(version_id) = &dobj.delete_object.delete_marker_version_id {
|
||||
version_id.to_owned()
|
||||
} else {
|
||||
dobj.delete_object.version_id
|
||||
dobj.delete_object.version_id.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut rinfo = dobj
|
||||
@@ -1990,7 +1889,11 @@ async fn replicate_delete_to_target(dobj: &DeletedObjectReplicationInfo, tgt_cli
|
||||
return rinfo;
|
||||
}
|
||||
|
||||
let version_id = replication_target_version_id(version_id, is_version_purge);
|
||||
let version_id = if version_id.is_nil() {
|
||||
None
|
||||
} else {
|
||||
Some(version_id.to_string())
|
||||
};
|
||||
|
||||
if dobj.delete_object.delete_marker && dobj.delete_object.delete_marker_version_id.is_some() {
|
||||
match head_object_with_proxy_stats(
|
||||
@@ -3240,10 +3143,6 @@ async fn replicate_object_with_multipart<S: ReplicationObjectIO>(ctx: MultipartR
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use s3s::dto::{
|
||||
DeleteReplication, DeleteReplicationStatus, Destination, ReplicaModifications, ReplicaModificationsStatus,
|
||||
ReplicationRule, ReplicationRuleAndOperator, ReplicationRuleFilter, ReplicationRuleStatus, SourceSelectionCriteria, Tag,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use time::OffsetDateTime;
|
||||
use uuid::Uuid;
|
||||
@@ -3553,110 +3452,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_marker_purge_targets_follow_delete_and_replica_modification_rules() {
|
||||
fn rule(arn: &str, delete_status: &'static str) -> ReplicationRule {
|
||||
ReplicationRule {
|
||||
delete_marker_replication: None,
|
||||
delete_replication: Some(DeleteReplication {
|
||||
status: DeleteReplicationStatus::from_static(delete_status),
|
||||
}),
|
||||
destination: Destination {
|
||||
bucket: arn.to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
existing_object_replication: None,
|
||||
filter: None,
|
||||
id: Some(arn.to_string()),
|
||||
prefix: Some("logs/".to_string()),
|
||||
priority: Some(1),
|
||||
source_selection_criteria: None,
|
||||
status: ReplicationRuleStatus::from_static(ReplicationRuleStatus::ENABLED),
|
||||
}
|
||||
}
|
||||
|
||||
let enabled_arn = "arn:rustfs:replication:us-east-1:target:enabled";
|
||||
let disabled_arn = "arn:rustfs:replication:us-east-1:target:disabled";
|
||||
let delete_marker_version_id = Uuid::new_v4();
|
||||
let mut config = ReplicationConfiguration {
|
||||
role: String::new(),
|
||||
rules: vec![
|
||||
rule(enabled_arn, DeleteReplicationStatus::ENABLED),
|
||||
rule(disabled_arn, DeleteReplicationStatus::DISABLED),
|
||||
],
|
||||
};
|
||||
let mut dobj = DeletedObjectReplicationInfo {
|
||||
delete_object: ReplicationDeletedObject {
|
||||
object_name: "logs/object.txt".to_string(),
|
||||
delete_marker: true,
|
||||
delete_marker_version_id: Some(delete_marker_version_id),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(delete_marker_purge_target_arns(&config, &dobj), HashSet::from([enabled_arn.to_string()]));
|
||||
assert_eq!(
|
||||
delete_marker_purge_config::<()>(Ok(Some(config.clone())), &dobj),
|
||||
DeleteMarkerPurgeConfig::Apply(HashSet::from([enabled_arn.to_string()]))
|
||||
);
|
||||
|
||||
dobj.delete_object.replication_state = Some(Default::default());
|
||||
dobj.delete_object
|
||||
.replication_state
|
||||
.as_mut()
|
||||
.expect("test replication state")
|
||||
.replica_status = ReplicationStatusType::Replica;
|
||||
assert!(delete_marker_purge_target_arns(&config, &dobj).is_empty());
|
||||
|
||||
config.rules[0].source_selection_criteria = Some(SourceSelectionCriteria {
|
||||
replica_modifications: Some(ReplicaModifications {
|
||||
status: ReplicaModificationsStatus::from_static(ReplicaModificationsStatus::ENABLED),
|
||||
}),
|
||||
sse_kms_encrypted_objects: None,
|
||||
});
|
||||
assert_eq!(delete_marker_purge_target_arns(&config, &dobj), HashSet::from([enabled_arn.to_string()]));
|
||||
|
||||
config.rules[0].prefix = None;
|
||||
config.rules[0].filter = Some(ReplicationRuleFilter {
|
||||
tag: Some(Tag {
|
||||
key: Some("env".to_string()),
|
||||
value: Some("prod".to_string()),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(delete_marker_purge_target_arns(&config, &dobj).is_empty());
|
||||
|
||||
config.rules[0].filter = Some(ReplicationRuleFilter {
|
||||
and: Some(ReplicationRuleAndOperator {
|
||||
prefix: Some("logs/".to_string()),
|
||||
tags: Some(vec![Tag {
|
||||
key: Some("env".to_string()),
|
||||
value: Some("prod".to_string()),
|
||||
}]),
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(delete_marker_purge_target_arns(&config, &dobj).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_marker_purge_config_errors_are_retryable() {
|
||||
let delete_marker_version_id = Uuid::new_v4();
|
||||
let dobj = DeletedObjectReplicationInfo {
|
||||
delete_object: ReplicationDeletedObject {
|
||||
object_name: "object.txt".to_string(),
|
||||
delete_marker: true,
|
||||
delete_marker_version_id: Some(delete_marker_version_id),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(delete_marker_purge_config::<()>(Ok(None), &dobj), DeleteMarkerPurgeConfig::Stop);
|
||||
assert_eq!(delete_marker_purge_config::<()>(Err(()), &dobj), DeleteMarkerPurgeConfig::Retry);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_retryable_delete_replication_head_error_allows_delete_marker_head_responses() {
|
||||
assert!(
|
||||
|
||||
@@ -31,13 +31,10 @@ use rustfs_utils::http::{
|
||||
};
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) use crate::bucket::bucket_target_sys::{
|
||||
AdvancedPutOptions, PutObjectOptions, PutObjectPartOptions, RemoveObjectOptions, TargetClient,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use crate::bucket::target::BucketTarget;
|
||||
pub(crate) use crate::bucket::target::BucketTargets;
|
||||
|
||||
use super::replication_config_store::ReplicationConfigStore;
|
||||
@@ -308,14 +305,6 @@ pub(crate) fn replication_target_head_is_newer_null_version(object_info: &Object
|
||||
target_is_newer_than_source_null_version(&replication_source_object(object_info), &replication_target_object(target))
|
||||
}
|
||||
|
||||
pub(crate) fn replication_target_version_id(version_id: Option<Uuid>, version_purge: bool) -> Option<String> {
|
||||
match version_id {
|
||||
Some(version_id) if version_id.is_nil() && version_purge => Some("null".to_string()),
|
||||
Some(version_id) if !version_id.is_nil() => Some(version_id.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn replication_delete_remove_options(
|
||||
delete_marker: bool,
|
||||
replication_mtime: Option<OffsetDateTime>,
|
||||
@@ -530,18 +519,6 @@ mod tests {
|
||||
assert!(force.replication_request);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_target_version_id_preserves_null_purges() {
|
||||
assert_eq!(replication_target_version_id(Some(Uuid::nil()), true).as_deref(), Some("null"));
|
||||
assert_eq!(replication_target_version_id(Some(Uuid::nil()), false), None);
|
||||
|
||||
let version_id = Uuid::new_v4();
|
||||
assert_eq!(
|
||||
replication_target_version_id(Some(version_id), true).as_deref(),
|
||||
Some(version_id.to_string().as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replication_complete_multipart_options_sets_actual_size() {
|
||||
let options = replication_complete_multipart_options("1024".to_string());
|
||||
|
||||
@@ -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-")
|
||||
}
|
||||
|
||||
|
||||
@@ -53,12 +53,14 @@ use std::sync::LazyLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::sync::{OwnedRwLockWriteGuard, RwLock as AsyncRwLock};
|
||||
use tracing::{debug, error, info, instrument, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const CONFIG_PREFIX: &str = "config";
|
||||
const SERVER_CONFIG_OBJECT: &str = "config/config.json";
|
||||
const CONFIG_TRANSACTION_LOCK_SUFFIX: &str = ".transaction.lock";
|
||||
|
||||
// Server-config lock order: SERVER_CONFIG_LOCK -> distributed namespace lock
|
||||
// for SERVER_CONFIG_OBJECT. Readers and writers must never reverse this order.
|
||||
// Server-config lock order: SERVER_CONFIG_LOCK -> transaction lock ->
|
||||
// SERVER_CONFIG_OBJECT. Readers and writers must never reverse this order.
|
||||
static SERVER_CONFIG_LOCK: LazyLock<Arc<AsyncRwLock<()>>> = LazyLock::new(|| Arc::new(AsyncRwLock::new(())));
|
||||
|
||||
fn config_task_join_error(operation: &'static str, error: tokio::task::JoinError) -> Error {
|
||||
@@ -76,8 +78,11 @@ where
|
||||
T: Send + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
// Lock order: SERVER_CONFIG_LOCK -> namespace write lock.
|
||||
// Lock order: SERVER_CONFIG_LOCK -> transaction lock -> object lock.
|
||||
let _local_guard = SERVER_CONFIG_LOCK.write().await;
|
||||
let transaction_lock = server_config_transaction_lock_path();
|
||||
let transaction_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &transaction_lock).await?;
|
||||
let _transaction_guard = transaction_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||
let namespace_lock = store.new_ns_lock(RUSTFS_META_BUCKET, SERVER_CONFIG_OBJECT).await?;
|
||||
let _write_guard = namespace_lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||
Ok(operation().await)
|
||||
@@ -96,8 +101,11 @@ where
|
||||
T: Send + 'static,
|
||||
{
|
||||
tokio::spawn(async move {
|
||||
// Lock order: SERVER_CONFIG_LOCK -> namespace read lock.
|
||||
// Lock order: SERVER_CONFIG_LOCK -> transaction lock -> object lock.
|
||||
let _local_guard = SERVER_CONFIG_LOCK.read().await;
|
||||
let transaction_lock = server_config_transaction_lock_path();
|
||||
let transaction_lock = store.new_ns_lock(RUSTFS_META_BUCKET, &transaction_lock).await?;
|
||||
let _transaction_guard = transaction_lock.get_read_lock(get_lock_acquire_timeout()).await?;
|
||||
let namespace_lock = store.new_ns_lock(RUSTFS_META_BUCKET, SERVER_CONFIG_OBJECT).await?;
|
||||
let _read_guard = namespace_lock.get_read_lock(get_lock_acquire_timeout()).await?;
|
||||
Ok(operation().await)
|
||||
@@ -567,6 +575,21 @@ where
|
||||
}
|
||||
|
||||
pub async fn save_config_with_opts<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<()>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
>,
|
||||
{
|
||||
save_config_with_opts_and_metadata(api, file, data, opts).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn save_config_with_opts_and_metadata<S>(api: Arc<S>, file: &str, data: Vec<u8>, opts: &ObjectOptions) -> Result<ObjectInfo>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
@@ -579,11 +602,13 @@ where
|
||||
>,
|
||||
{
|
||||
let mut put_data = PutObjReader::from_vec(data);
|
||||
if let Err(err) = api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts).await {
|
||||
error!("save_config_with_opts: err: {:?}, file: {}", err, file);
|
||||
return Err(err);
|
||||
match api.put_object(RUSTFS_META_BUCKET, file, &mut put_data, opts).await {
|
||||
Ok(object_info) => Ok(object_info),
|
||||
Err(err) => {
|
||||
error!("save_config_with_opts: err: {:?}, file: {}", err, file);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn new_server_config() -> Config {
|
||||
@@ -594,8 +619,12 @@ async fn new_and_save_server_config<S>(api: Arc<S>) -> Result<Config>
|
||||
where
|
||||
S: EcstoreObjectIO + StorageAdminApi + NamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||
{
|
||||
let snapshot = read_server_config_snapshot(api.clone()).await?;
|
||||
if snapshot.object_exists() {
|
||||
return Ok(snapshot.config.clone());
|
||||
}
|
||||
let cfg = new_server_config();
|
||||
save_server_config(api, &cfg).await?;
|
||||
save_server_config_snapshot(api, &cfg, &snapshot).await?;
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
@@ -617,6 +646,10 @@ pub fn server_config_path() -> String {
|
||||
SERVER_CONFIG_OBJECT.to_string()
|
||||
}
|
||||
|
||||
fn server_config_transaction_lock_path() -> String {
|
||||
format!("{}{CONFIG_TRANSACTION_LOCK_SUFFIX}", server_config_path())
|
||||
}
|
||||
|
||||
fn storage_class_kvs_mut(cfg: &mut Config) -> &mut KVS {
|
||||
let sub_cfg = cfg.0.entry(STORAGE_CLASS_SUB_SYS.to_string()).or_insert_with(|| {
|
||||
let mut section = HashMap::new();
|
||||
@@ -819,6 +852,9 @@ fn apply_external_scalar_config_map(
|
||||
let Some(config_value) = root.get(descriptor.subsystem_key) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if descriptor.subsystem_key == HEAL_SUB_SYS && config_value.is_null() {
|
||||
return Ok(false);
|
||||
}
|
||||
let overrides = decode_scalar_config_value(config_value, descriptor)?;
|
||||
|
||||
if overrides.is_empty() {
|
||||
@@ -1463,21 +1499,142 @@ fn build_audit_object(cfg: &Config) -> Map<String, Value> {
|
||||
build_target_object(cfg, &audit_target_descriptors())
|
||||
}
|
||||
|
||||
fn sync_rendered_target_instance(existing: Value, rendered: Option<&Value>, valid_keys: &[&str]) -> Option<Value> {
|
||||
match existing {
|
||||
Value::Object(mut instance) => {
|
||||
for key in valid_keys {
|
||||
instance.remove(*key);
|
||||
}
|
||||
if let Some(Value::Object(rendered)) = rendered {
|
||||
instance.extend(rendered.clone());
|
||||
}
|
||||
(!instance.is_empty()).then_some(Value::Object(instance))
|
||||
}
|
||||
Value::Array(entries) => {
|
||||
let mut pending = rendered
|
||||
.and_then(Value::as_object)
|
||||
.map(|rendered| {
|
||||
rendered
|
||||
.iter()
|
||||
.filter_map(|(key, value)| parse_target_scalar_value(key, value).map(|value| (key.clone(), value)))
|
||||
.collect::<HashMap<_, _>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut updated = Vec::with_capacity(entries.len().saturating_add(pending.len()));
|
||||
for entry in entries {
|
||||
let Some(entry_obj) = entry.as_object() else {
|
||||
updated.push(entry);
|
||||
continue;
|
||||
};
|
||||
let Some(key) = entry_obj.get("key").and_then(Value::as_str) else {
|
||||
updated.push(entry);
|
||||
continue;
|
||||
};
|
||||
if !valid_keys.contains(&key) {
|
||||
updated.push(entry);
|
||||
continue;
|
||||
}
|
||||
let Some(value) = pending.remove(key) else {
|
||||
continue;
|
||||
};
|
||||
let mut entry_obj = entry_obj.clone();
|
||||
entry_obj.insert("value".to_string(), Value::String(value));
|
||||
updated.push(Value::Object(entry_obj));
|
||||
}
|
||||
updated.extend(rendered_scalar_config_kvs_entries(&pending));
|
||||
(!updated.is_empty()).then_some(Value::Array(updated))
|
||||
}
|
||||
value if rendered.is_none() => Some(value),
|
||||
_ => rendered.cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_rendered_target_object(
|
||||
target_obj: &mut Map<String, Value>,
|
||||
rendered_target: &Map<String, Value>,
|
||||
descriptors: &[TargetConfigDescriptor],
|
||||
) {
|
||||
for descriptor in descriptors {
|
||||
match rendered_target.get(descriptor.external_key) {
|
||||
Some(Value::Object(v)) => {
|
||||
target_obj.insert(descriptor.external_key.to_string(), Value::Object(v.clone()));
|
||||
target_obj.remove(descriptor.subsystem_key);
|
||||
let existing = target_obj.remove(descriptor.external_key);
|
||||
let alias = target_obj.remove(descriptor.subsystem_key);
|
||||
let mut section = existing
|
||||
.or(alias)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
let rendered = rendered_target.get(descriptor.external_key).and_then(Value::as_object);
|
||||
|
||||
if is_target_instance_shorthand(§ion, descriptor.valid_keys) {
|
||||
let has_named_instances = rendered.is_some_and(|instances| instances.keys().any(|name| name != "default"));
|
||||
if !has_named_instances {
|
||||
if let Some(section) = sync_rendered_target_instance(
|
||||
Value::Object(section),
|
||||
rendered.and_then(|instances| instances.get("default")),
|
||||
descriptor.valid_keys,
|
||||
) {
|
||||
target_obj.insert(descriptor.external_key.to_string(), section);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
target_obj.remove(descriptor.external_key);
|
||||
target_obj.remove(descriptor.subsystem_key);
|
||||
|
||||
let mut nested = Map::new();
|
||||
if let Some(default) = sync_rendered_target_instance(
|
||||
Value::Object(section),
|
||||
rendered.and_then(|instances| instances.get("default")),
|
||||
descriptor.valid_keys,
|
||||
) {
|
||||
nested.insert("default".to_string(), default);
|
||||
}
|
||||
if let Some(rendered) = rendered {
|
||||
for (instance_name, instance) in rendered {
|
||||
if instance_name != "default" {
|
||||
nested.insert(instance_name.clone(), instance.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !nested.is_empty() {
|
||||
target_obj.insert(descriptor.external_key.to_string(), Value::Object(nested));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(default_alias) = section.remove(DEFAULT_DELIMITER) {
|
||||
if let Some(default) = section.get_mut("default") {
|
||||
if let Some(alias) = sync_rendered_target_instance(default_alias, None, descriptor.valid_keys) {
|
||||
match (default, alias) {
|
||||
(Value::Object(default), Value::Object(alias)) => {
|
||||
for (key, value) in alias {
|
||||
default.entry(key).or_insert(value);
|
||||
}
|
||||
}
|
||||
(Value::Array(default), Value::Array(alias)) => default.extend(alias),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
section.insert("default".to_string(), default_alias);
|
||||
}
|
||||
}
|
||||
|
||||
let mut merged = Map::new();
|
||||
for (instance_name, instance) in section {
|
||||
if let Some(instance) = sync_rendered_target_instance(
|
||||
instance,
|
||||
rendered.and_then(|instances| instances.get(&instance_name)),
|
||||
descriptor.valid_keys,
|
||||
) {
|
||||
merged.insert(instance_name, instance);
|
||||
}
|
||||
}
|
||||
if let Some(rendered) = rendered {
|
||||
for (instance_name, instance) in rendered {
|
||||
if !merged.contains_key(instance_name) {
|
||||
merged.insert(instance_name.clone(), instance.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !merged.is_empty() {
|
||||
target_obj.insert(descriptor.external_key.to_string(), Value::Object(merged));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1496,6 +1653,14 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8
|
||||
Some(Value::Object(v)) => v,
|
||||
_ => Map::new(),
|
||||
};
|
||||
for key in [
|
||||
storageclass::CLASS_STANDARD,
|
||||
storageclass::CLASS_RRS,
|
||||
storageclass::OPTIMIZE,
|
||||
storageclass::INLINE_BLOCK,
|
||||
] {
|
||||
sc_obj.remove(key);
|
||||
}
|
||||
for (k, v) in build_storageclass_object(cfg) {
|
||||
sc_obj.insert(k, v);
|
||||
}
|
||||
@@ -1503,7 +1668,10 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8
|
||||
root.remove("storage_class");
|
||||
|
||||
for descriptor in [scanner_config_descriptor(), heal_config_descriptor()] {
|
||||
let existing = root.remove(descriptor.subsystem_key);
|
||||
let mut existing = root.remove(descriptor.subsystem_key);
|
||||
if descriptor.subsystem_key == HEAL_SUB_SYS && existing.as_ref().is_some_and(Value::is_null) {
|
||||
existing = None;
|
||||
}
|
||||
let rendered = build_scalar_config_object(cfg, descriptor);
|
||||
if let Some(config_value) = sync_rendered_scalar_config_value(existing, &rendered, descriptor)? {
|
||||
root.insert(descriptor.subsystem_key.to_string(), config_value);
|
||||
@@ -1560,6 +1728,7 @@ fn is_standard_object_server_config(data: &[u8]) -> bool {
|
||||
matches!(root.get("version"), Some(Value::String(v)) if !v.trim().is_empty())
|
||||
&& matches!(root.get("storageclass"), Some(Value::Object(_)))
|
||||
&& !root.contains_key("storage_class")
|
||||
&& !matches!(root.get(HEAL_SUB_SYS), Some(Value::Null))
|
||||
}
|
||||
|
||||
fn configs_semantically_equal(lhs: &Config, rhs: &Config) -> bool {
|
||||
@@ -1593,7 +1762,7 @@ where
|
||||
FileInfo = FileInfo,
|
||||
ObjectToDelete = ObjectToDelete,
|
||||
DeletedObject = DeletedObject,
|
||||
>,
|
||||
> + NamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||
{
|
||||
if let Some(decrypt) = &decrypt_fn {
|
||||
register_server_config_decrypt_fn(decrypt.clone());
|
||||
@@ -1601,14 +1770,7 @@ where
|
||||
|
||||
let config_file = server_config_path();
|
||||
match api
|
||||
.get_object_info(
|
||||
RUSTFS_META_BUCKET,
|
||||
&config_file,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.get_object_info(RUSTFS_META_BUCKET, &config_file, &ObjectOptions::default())
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
@@ -1624,7 +1786,6 @@ where
|
||||
|
||||
let opts = ObjectOptions {
|
||||
max_parity: true,
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -1677,7 +1838,33 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
match save_config(api, &config_file, normalized).await {
|
||||
let snapshot = match read_server_config_snapshot(api.clone()).await {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(err) => {
|
||||
warn!("recheck target server config failed, skip migration: {:?}", err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if snapshot.object_exists() {
|
||||
debug!("server config was created while legacy migration was preparing, skip migration");
|
||||
return;
|
||||
}
|
||||
|
||||
match save_config_with_opts(
|
||||
api,
|
||||
&config_file,
|
||||
normalized,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
info!("Migrated compatible server config from legacy metadata bucket");
|
||||
}
|
||||
@@ -1769,8 +1956,13 @@ where
|
||||
{
|
||||
let config_file = server_config_path();
|
||||
|
||||
// Try to read the configuration file
|
||||
match read_config_no_lock(api.clone(), &config_file).await {
|
||||
// Try to read the configuration file.
|
||||
let data = if namespace_lock_held {
|
||||
read_config_no_lock(api.clone(), &config_file).await
|
||||
} else {
|
||||
read_config(api.clone(), &config_file).await
|
||||
};
|
||||
match data {
|
||||
Ok(data) => read_server_config(api, &data, namespace_lock_held).await,
|
||||
Err(Error::ConfigNotFound) => handle_missing_config(api, "Read the main configuration", namespace_lock_held).await,
|
||||
Err(err) => handle_config_read_error(err, &config_file),
|
||||
@@ -1787,7 +1979,12 @@ where
|
||||
warn!("Received empty configuration data, try to reread from '{}'", config_file);
|
||||
|
||||
// Try to read the configuration again
|
||||
match read_config_no_lock(api.clone(), &config_file).await {
|
||||
let data = if namespace_lock_held {
|
||||
read_config_no_lock(api.clone(), &config_file).await
|
||||
} else {
|
||||
read_config(api.clone(), &config_file).await
|
||||
};
|
||||
match data {
|
||||
Ok(cfg_data) => {
|
||||
let cfg = decode_persisted_server_config(&cfg_data)?;
|
||||
return Ok(cfg.merge());
|
||||
@@ -2036,11 +2233,16 @@ pub struct ServerConfigSnapshot {
|
||||
raw: Option<Vec<u8>>,
|
||||
seed: Option<Vec<u8>>,
|
||||
etag: Option<String>,
|
||||
generation: Option<Uuid>,
|
||||
_local_guard: OwnedRwLockWriteGuard<()>,
|
||||
_guard: rustfs_lock::NamespaceLockGuard,
|
||||
}
|
||||
|
||||
impl ServerConfigSnapshot {
|
||||
pub fn object_exists(&self) -> bool {
|
||||
self.raw.is_some()
|
||||
}
|
||||
|
||||
pub fn ensure_lock_held(&self) -> Result<()> {
|
||||
if self._guard.is_lock_lost() {
|
||||
return Err(Error::other("server config transaction lock was lost"));
|
||||
@@ -2051,12 +2253,34 @@ impl ServerConfigSnapshot {
|
||||
pub fn is_lock_lost(&self) -> bool {
|
||||
self._guard.is_lock_lost()
|
||||
}
|
||||
|
||||
pub fn generation(&self) -> Option<Uuid> {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a server config transaction snapshot while holding the same local and
|
||||
/// distributed write locks used by every other server-config writer. Internal
|
||||
/// reads and the later conditional write use no-lock object I/O; the guards
|
||||
/// remain live until the snapshot is dropped.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ServerConfigSaveResult {
|
||||
persisted: bool,
|
||||
generation: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl ServerConfigSaveResult {
|
||||
pub fn persisted(&self) -> bool {
|
||||
self.persisted
|
||||
}
|
||||
|
||||
pub fn generation(&self) -> Option<Uuid> {
|
||||
self.generation
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a server config transaction snapshot while holding a dedicated
|
||||
/// transaction lock. The config object's normal namespace lock remains
|
||||
/// available to fence reads and the conditional write at commit time.
|
||||
/// The transaction guard remains live until the snapshot is dropped,
|
||||
/// serializing persistence and history ordering across admin nodes. Runtime
|
||||
/// state is reloaded from the durable object after this guard is released.
|
||||
pub async fn read_server_config_snapshot<S>(api: Arc<S>) -> Result<ServerConfigSnapshot>
|
||||
where
|
||||
S: ObjectIO<
|
||||
@@ -2071,12 +2295,10 @@ where
|
||||
{
|
||||
let config_file = server_config_path();
|
||||
let local_guard = SERVER_CONFIG_LOCK.clone().write_owned().await;
|
||||
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, &config_file).await?;
|
||||
let transaction_lock = server_config_transaction_lock_path();
|
||||
let lock = api.new_ns_lock(RUSTFS_META_BUCKET, &transaction_lock).await?;
|
||||
let guard = lock.get_write_lock(get_lock_acquire_timeout()).await?;
|
||||
let read_options = ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
};
|
||||
let read_options = ObjectOptions::default();
|
||||
match read_config_with_metadata_inner(api, &config_file, &read_options, true).await {
|
||||
Ok((raw, object_info)) => {
|
||||
let (config, seed) = decode_persisted_server_config_with_seed(&raw)?;
|
||||
@@ -2085,6 +2307,7 @@ where
|
||||
raw: Some(raw),
|
||||
seed: Some(seed),
|
||||
etag: object_info.etag,
|
||||
generation: object_info.data_dir.filter(|generation| !generation.is_nil()),
|
||||
_local_guard: local_guard,
|
||||
_guard: guard,
|
||||
})
|
||||
@@ -2094,6 +2317,7 @@ where
|
||||
raw: None,
|
||||
seed: None,
|
||||
etag: None,
|
||||
generation: None,
|
||||
_local_guard: local_guard,
|
||||
_guard: guard,
|
||||
}),
|
||||
@@ -2108,6 +2332,27 @@ where
|
||||
/// lock, so a concurrent update or transaction lease loss cannot commit an
|
||||
/// unfenced overwrite.
|
||||
pub async fn save_server_config_snapshot<S>(api: Arc<S>, cfg: &Config, snapshot: &ServerConfigSnapshot) -> Result<bool>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
RangeSpec = HTTPRangeSpec,
|
||||
HeaderMap = HeaderMap,
|
||||
ObjectOptions = ObjectOptions,
|
||||
ObjectInfo = ObjectInfo,
|
||||
GetObjectReader = GetObjectReader,
|
||||
PutObjectReader = PutObjReader,
|
||||
> + NamespaceLocking<Error = Error, NamespaceLock = rustfs_lock::NamespaceLockWrapper>,
|
||||
{
|
||||
save_server_config_snapshot_with_generation(api, cfg, snapshot)
|
||||
.await
|
||||
.map(|result| result.persisted())
|
||||
}
|
||||
|
||||
pub async fn save_server_config_snapshot_with_generation<S>(
|
||||
api: Arc<S>,
|
||||
cfg: &Config,
|
||||
snapshot: &ServerConfigSnapshot,
|
||||
) -> Result<ServerConfigSaveResult>
|
||||
where
|
||||
S: ObjectIO<
|
||||
Error = Error,
|
||||
@@ -2126,13 +2371,19 @@ where
|
||||
&& configs_semantically_equal(&snapshot.config, cfg)
|
||||
{
|
||||
debug!("server config unchanged and already in standard object shape, skip write");
|
||||
return Ok(false);
|
||||
return Ok(ServerConfigSaveResult {
|
||||
persisted: false,
|
||||
generation: snapshot.generation(),
|
||||
});
|
||||
}
|
||||
|
||||
let data = encode_server_config_blob(cfg, snapshot.seed.as_deref())?;
|
||||
if snapshot.raw.as_deref().is_some_and(|current| current == data.as_slice()) {
|
||||
debug!("server config bytes unchanged after encode, skip write");
|
||||
return Ok(false);
|
||||
return Ok(ServerConfigSaveResult {
|
||||
persisted: false,
|
||||
generation: snapshot.generation(),
|
||||
});
|
||||
}
|
||||
|
||||
let http_preconditions = if snapshot.raw.is_some() {
|
||||
@@ -2152,19 +2403,22 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
save_config_with_opts(
|
||||
snapshot.ensure_lock_held()?;
|
||||
let object_info = save_config_with_opts_and_metadata(
|
||||
api,
|
||||
&config_file,
|
||||
data,
|
||||
&ObjectOptions {
|
||||
max_parity: true,
|
||||
no_lock: true,
|
||||
http_preconditions: Some(http_preconditions),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(true)
|
||||
Ok(ServerConfigSaveResult {
|
||||
persisted: true,
|
||||
generation: object_info.data_dir.filter(|generation| !generation.is_nil()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Saves the server config while an upper layer holds the namespace write
|
||||
@@ -2301,8 +2555,9 @@ mod tests {
|
||||
use super::{
|
||||
SERVER_CONFIG_LOCK, ServerConfigSnapshot, apply_dynamic_config_for_sub_sys_with, config_task_join_error,
|
||||
configs_semantically_equal, decode_server_config_blob, encode_server_config_blob, is_standard_object_server_config,
|
||||
lookup_configs, read_config, read_config_preserve_empty, read_config_with_metadata, read_config_without_migrate,
|
||||
read_server_config_snapshot, save_server_config, save_server_config_snapshot, server_config_path, storage_class_kvs_mut,
|
||||
lookup_configs, new_and_save_server_config, read_config, read_config_preserve_empty, read_config_with_metadata,
|
||||
read_config_without_migrate, read_server_config_snapshot, save_server_config, save_server_config_snapshot,
|
||||
save_server_config_snapshot_with_generation, server_config_transaction_lock_path, storage_class_kvs_mut,
|
||||
};
|
||||
use crate::config::{audit, heal, notify, oidc, scanner};
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
@@ -2311,7 +2566,9 @@ mod tests {
|
||||
use crate::object_api::{GetObjectReader, ObjectInfo, ObjectOptions, PutObjReader};
|
||||
use crate::runtime::sources as runtime_sources;
|
||||
use crate::set_disk::SetDisks;
|
||||
use crate::storage_api_contracts::{admin::StorageAdminApi, namespace::NamespaceLocking as _, range::HTTPRangeSpec};
|
||||
use crate::storage_api_contracts::{
|
||||
admin::StorageAdminApi, namespace::NamespaceLocking as _, object::HTTPPreconditions, range::HTTPRangeSpec,
|
||||
};
|
||||
use http::HeaderMap;
|
||||
use rustfs_config::audit::{AUDIT_AMQP_SUB_SYS, AUDIT_KAFKA_SUB_SYS, AUDIT_MQTT_SUB_SYS, AUDIT_WEBHOOK_SUB_SYS};
|
||||
use rustfs_config::notify::{
|
||||
@@ -3104,6 +3361,85 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_heal_null_decodes_as_no_override_and_is_canonicalized_on_save() {
|
||||
let seed = br#"{
|
||||
"version":"33",
|
||||
"storageclass":{"standard":"","rrs":""},
|
||||
"heal":null,
|
||||
"future_root":{"mode":"keep"},
|
||||
"openid":{"default":{
|
||||
"config_url":"https://issuer.example/.well-known/openid-configuration",
|
||||
"client_id":"console",
|
||||
"client_secret":"oidc-secret",
|
||||
"future_provider_control":"keep"
|
||||
}},
|
||||
"notify":{"webhook":{"primary":{
|
||||
"enable":true,
|
||||
"endpoint":"https://notify.example/hook",
|
||||
"auth_token":"notify-secret",
|
||||
"future_notify_control":"keep"
|
||||
}}},
|
||||
"logger":{"webhook":{"primary":{
|
||||
"enable":true,
|
||||
"endpoint":"https://audit.example/hook",
|
||||
"auth_token":"audit-secret",
|
||||
"future_audit_control":"keep"
|
||||
}}}
|
||||
}"#;
|
||||
|
||||
let cfg = decode_server_config_blob(seed).expect("root heal null should mean no persisted override");
|
||||
assert!(cfg.get_value(HEAL_SUB_SYS, DEFAULT_DELIMITER).is_none());
|
||||
assert!(!is_standard_object_server_config(seed));
|
||||
|
||||
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("legacy seed should canonicalize on an authorized save");
|
||||
let value: Value = serde_json::from_slice(&encoded).expect("canonical config should be valid JSON");
|
||||
assert!(value.get(HEAL_SUB_SYS).is_none());
|
||||
assert_eq!(value["future_root"]["mode"].as_str(), Some("keep"));
|
||||
assert_eq!(value["openid"]["default"]["client_secret"].as_str(), Some("oidc-secret"));
|
||||
assert_eq!(value["openid"]["default"]["future_provider_control"].as_str(), Some("keep"));
|
||||
assert_eq!(value["notify"]["webhook"]["primary"]["auth_token"].as_str(), Some("notify-secret"));
|
||||
assert_eq!(value["notify"]["webhook"]["primary"]["future_notify_control"].as_str(), Some("keep"));
|
||||
assert_eq!(value["logger"]["webhook"]["primary"]["auth_token"].as_str(), Some("audit-secret"));
|
||||
assert_eq!(value["logger"]["webhook"]["primary"]["future_audit_control"].as_str(), Some("keep"));
|
||||
assert!(is_standard_object_server_config(&encoded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_scalar_and_nested_null_config_shapes_remain_rejected() {
|
||||
let invalid_sections = [
|
||||
r#""scanner":null"#,
|
||||
r#""heal":"""#,
|
||||
r#""heal":false"#,
|
||||
r#""heal":0"#,
|
||||
r#""heal":{"default":null}"#,
|
||||
r#""heal":{"_":null}"#,
|
||||
r#""heal":{"bitrot_cycle":null}"#,
|
||||
r#""heal":[{"key":"bitrot_cycle","value":null}]"#,
|
||||
];
|
||||
|
||||
for section in invalid_sections {
|
||||
let input = format!(r#"{{"version":"33","storageclass":{{"standard":"","rrs":""}},{section}}}"#);
|
||||
let err = decode_server_config_blob(input.as_bytes()).expect_err("invalid scalar shape must remain rejected");
|
||||
assert!(
|
||||
err.to_string().contains("expected"),
|
||||
"invalid section {section} returned an unrelated error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_heal_object_and_kvs_array_shapes_remain_accepted() {
|
||||
let empty_object = br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":{}}"#;
|
||||
let cfg = decode_server_config_blob(empty_object).expect("empty heal object should decode as no override");
|
||||
assert!(cfg.get_value(HEAL_SUB_SYS, DEFAULT_DELIMITER).is_none());
|
||||
|
||||
let kvs_array =
|
||||
br#"{"version":"33","storageclass":{"standard":"","rrs":""},"heal":[{"key":"bitrot_cycle","value":"off"}]}"#;
|
||||
let cfg = decode_server_config_blob(kvs_array).expect("heal KVS array should decode");
|
||||
assert!(cfg.get_value(HEAL_SUB_SYS, DEFAULT_DELIMITER).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanner_update_preserves_unknown_root_and_oidc_provider_fields() {
|
||||
let seed = br#"{
|
||||
@@ -3131,6 +3467,171 @@ mod tests {
|
||||
assert_eq!(value["openid"]["default"]["client_id"].as_str(), Some("console"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storageclass_reset_removes_stale_inline_block_from_seed() {
|
||||
let seed = br#"{
|
||||
"version":"33",
|
||||
"storageclass":{
|
||||
"standard":"EC:2",
|
||||
"rrs":"EC:1",
|
||||
"optimize":"availability",
|
||||
"inline_block":"64KiB",
|
||||
"future_storage_control":"keep"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let encoded = encode_server_config_blob(&Config::new(), Some(seed)).expect("storageclass reset should encode");
|
||||
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||
let storageclass = value["storageclass"].as_object().expect("storageclass object");
|
||||
|
||||
assert!(storageclass.get(crate::config::storageclass::INLINE_BLOCK).is_none());
|
||||
assert_eq!(storageclass["future_storage_control"].as_str(), Some("keep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_update_preserves_unknown_fields_without_restoring_removed_instances() {
|
||||
let seed = br#"{
|
||||
"version":"33",
|
||||
"storageclass":{"standard":"","rrs":""},
|
||||
"notify":{"webhook":{
|
||||
"primary":{
|
||||
"enable":true,
|
||||
"endpoint":"https://notify.example/old",
|
||||
"auth_token":"notify-secret",
|
||||
"future_control":"keep"
|
||||
},
|
||||
"removed":{"enable":true,"endpoint":"https://notify.example/removed"},
|
||||
"retained":{"enable":true,"endpoint":"https://notify.example/retained","future_control":"keep"},
|
||||
"enable":{"enable":true,"endpoint":"https://notify.example/named-enable","future_control":"keep"}
|
||||
}}
|
||||
}"#;
|
||||
let mut cfg = decode_server_config_blob(seed).expect("target seed should decode");
|
||||
let webhook = cfg
|
||||
.0
|
||||
.get_mut(NOTIFY_WEBHOOK_SUB_SYS)
|
||||
.expect("notify webhook subsystem should exist");
|
||||
webhook
|
||||
.get_mut("primary")
|
||||
.expect("primary target should exist")
|
||||
.insert(rustfs_config::WEBHOOK_ENDPOINT.to_string(), "https://notify.example/new".to_string());
|
||||
webhook.remove("removed");
|
||||
webhook.remove("retained");
|
||||
|
||||
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("target update should encode");
|
||||
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||
let webhook = value["notify"]["webhook"].as_object().expect("webhook section");
|
||||
|
||||
assert_eq!(webhook["primary"]["endpoint"].as_str(), Some("https://notify.example/new"));
|
||||
assert_eq!(webhook["primary"]["auth_token"].as_str(), Some("notify-secret"));
|
||||
assert_eq!(webhook["primary"]["future_control"].as_str(), Some("keep"));
|
||||
assert!(webhook.get("removed").is_none());
|
||||
assert_eq!(webhook["retained"]["future_control"].as_str(), Some("keep"));
|
||||
assert!(webhook["retained"].get("enable").is_none());
|
||||
assert!(webhook["retained"].get("endpoint").is_none());
|
||||
assert_eq!(webhook["enable"]["endpoint"].as_str(), Some("https://notify.example/named-enable"));
|
||||
assert_eq!(webhook["enable"]["future_control"].as_str(), Some("keep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorthand_target_update_preserves_shape_and_unknown_nested_fields() {
|
||||
let seed = br#"{
|
||||
"version":"33",
|
||||
"storageclass":{"standard":"","rrs":""},
|
||||
"notify":{"webhook":{
|
||||
"enable":true,
|
||||
"endpoint":"https://notify.example/old",
|
||||
"future_control":{"endpoint":"leave-untouched","mode":"keep"}
|
||||
}}
|
||||
}"#;
|
||||
let mut cfg = decode_server_config_blob(seed).expect("shorthand target should decode");
|
||||
cfg.0
|
||||
.get_mut(NOTIFY_WEBHOOK_SUB_SYS)
|
||||
.and_then(|targets| targets.get_mut(DEFAULT_DELIMITER))
|
||||
.expect("default webhook target should exist")
|
||||
.insert(rustfs_config::WEBHOOK_ENDPOINT.to_string(), "https://notify.example/new".to_string());
|
||||
|
||||
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("shorthand target update should encode");
|
||||
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||
let webhook = value["notify"]["webhook"].as_object().expect("webhook shorthand object");
|
||||
|
||||
assert_eq!(webhook["endpoint"].as_str(), Some("https://notify.example/new"));
|
||||
assert!(webhook.get("default").is_none());
|
||||
assert_eq!(webhook["future_control"]["endpoint"].as_str(), Some("leave-untouched"));
|
||||
assert_eq!(webhook["future_control"]["mode"].as_str(), Some("keep"));
|
||||
let decoded = decode_server_config_blob(&encoded).expect("updated shorthand target should remain decodable");
|
||||
assert_eq!(
|
||||
decoded
|
||||
.get_value(NOTIFY_WEBHOOK_SUB_SYS, DEFAULT_DELIMITER)
|
||||
.expect("updated default webhook target")
|
||||
.get(rustfs_config::WEBHOOK_ENDPOINT),
|
||||
"https://notify.example/new"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_kvs_update_preserves_unknown_entries_and_attributes() {
|
||||
let seed = br#"{
|
||||
"version":"33",
|
||||
"storageclass":{"standard":"","rrs":""},
|
||||
"notify":{"webhook":{"primary":[
|
||||
{"key":"enable","value":"on","hidden_if_empty":false},
|
||||
{"key":"endpoint","value":"https://notify.example/old","future_attribute":"keep-endpoint"},
|
||||
{"key":"future_control","value":"keep","future_attribute":"keep-control"}
|
||||
]}}
|
||||
}"#;
|
||||
let mut cfg = decode_server_config_blob(seed).expect("target KVS seed should decode");
|
||||
cfg.0
|
||||
.get_mut(NOTIFY_WEBHOOK_SUB_SYS)
|
||||
.and_then(|targets| targets.get_mut("primary"))
|
||||
.expect("primary webhook target should exist")
|
||||
.insert(rustfs_config::WEBHOOK_ENDPOINT.to_string(), "https://notify.example/new".to_string());
|
||||
|
||||
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("target KVS update should encode");
|
||||
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||
let entries = value["notify"]["webhook"]["primary"]
|
||||
.as_array()
|
||||
.expect("target KVS shape should be preserved");
|
||||
let endpoint = entries
|
||||
.iter()
|
||||
.find(|entry| entry["key"].as_str() == Some(rustfs_config::WEBHOOK_ENDPOINT))
|
||||
.expect("endpoint entry should remain");
|
||||
let future = entries
|
||||
.iter()
|
||||
.find(|entry| entry["key"].as_str() == Some("future_control"))
|
||||
.expect("unknown target entry should remain");
|
||||
|
||||
assert_eq!(endpoint["value"].as_str(), Some("https://notify.example/new"));
|
||||
assert_eq!(endpoint["future_attribute"].as_str(), Some("keep-endpoint"));
|
||||
assert_eq!(future["value"].as_str(), Some("keep"));
|
||||
assert_eq!(future["future_attribute"].as_str(), Some("keep-control"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_default_alias_is_canonicalized_without_losing_unknown_fields() {
|
||||
let seed = br#"{
|
||||
"version":"33",
|
||||
"storageclass":{"standard":"","rrs":""},
|
||||
"notify":{"webhook":{
|
||||
"_":{"enable":false,"endpoint":"https://notify.example/alias","future_alias":"keep"},
|
||||
"default":{"enable":true,"endpoint":"https://notify.example/default","future_default":"keep"}
|
||||
}}
|
||||
}"#;
|
||||
let cfg = decode_server_config_blob(seed).expect("dual default aliases should decode");
|
||||
let expected_endpoint = cfg
|
||||
.get_value(NOTIFY_WEBHOOK_SUB_SYS, DEFAULT_DELIMITER)
|
||||
.expect("default webhook target should exist")
|
||||
.get(rustfs_config::WEBHOOK_ENDPOINT);
|
||||
|
||||
let encoded = encode_server_config_blob(&cfg, Some(seed)).expect("default alias should canonicalize");
|
||||
let value: Value = serde_json::from_slice(&encoded).expect("encoded config should be valid json");
|
||||
let webhook = value["notify"]["webhook"].as_object().expect("webhook section");
|
||||
|
||||
assert!(webhook.get(DEFAULT_DELIMITER).is_none());
|
||||
assert_eq!(webhook["default"]["endpoint"].as_str(), Some(expected_endpoint.as_str()));
|
||||
assert_eq!(webhook["default"]["future_alias"].as_str(), Some("keep"));
|
||||
assert_eq!(webhook["default"]["future_default"].as_str(), Some("keep"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scanner_config_changes_are_semantically_significant() {
|
||||
let baseline = Config::new();
|
||||
@@ -4124,6 +4625,7 @@ mod tests {
|
||||
|
||||
/// What reads of the config object currently return.
|
||||
enum RecoveryReadState {
|
||||
Missing,
|
||||
Blob(Vec<u8>),
|
||||
QuorumError,
|
||||
}
|
||||
@@ -4136,6 +4638,7 @@ mod tests {
|
||||
heal_calls: AtomicUsize,
|
||||
write_calls: AtomicUsize,
|
||||
last_put_no_lock: AtomicBool,
|
||||
last_put_preconditions: Mutex<Option<HTTPPreconditions>>,
|
||||
revision: AtomicUsize,
|
||||
drive_counts: Vec<usize>,
|
||||
lock_manager: Arc<rustfs_lock::GlobalLockManager>,
|
||||
@@ -4150,6 +4653,7 @@ mod tests {
|
||||
heal_calls: AtomicUsize::new(0),
|
||||
write_calls: AtomicUsize::new(0),
|
||||
last_put_no_lock: AtomicBool::new(false),
|
||||
last_put_preconditions: Mutex::new(None),
|
||||
revision: AtomicUsize::new(1),
|
||||
drive_counts: vec![2],
|
||||
lock_manager: Arc::new(rustfs_lock::GlobalLockManager::new()),
|
||||
@@ -4206,6 +4710,7 @@ mod tests {
|
||||
_opts: &ObjectOptions,
|
||||
) -> Result<GetObjectReader> {
|
||||
let data = match &*self.state.lock().expect("state lock poisoned") {
|
||||
RecoveryReadState::Missing => return Err(Error::ConfigNotFound),
|
||||
RecoveryReadState::Blob(data) => data.clone(),
|
||||
RecoveryReadState::QuorumError => return Err(Error::ErasureReadQuorum),
|
||||
};
|
||||
@@ -4213,6 +4718,9 @@ mod tests {
|
||||
size: data.len() as i64,
|
||||
actual_size: data.len() as i64,
|
||||
etag: Some(format!("config-{}", self.revision.load(Ordering::SeqCst))),
|
||||
data_dir: Some(uuid::Uuid::from_u128(
|
||||
u128::try_from(self.revision.load(Ordering::SeqCst)).expect("test revision should fit in u128"),
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
Ok(GetObjectReader {
|
||||
@@ -4231,15 +4739,19 @@ mod tests {
|
||||
opts: &ObjectOptions,
|
||||
) -> Result<ObjectInfo> {
|
||||
let current_etag = format!("config-{}", self.revision.load(Ordering::SeqCst));
|
||||
let object_exists = matches!(&*self.state.lock().expect("state lock poisoned"), RecoveryReadState::Blob(_));
|
||||
if let Some(preconditions) = &opts.http_preconditions
|
||||
&& (preconditions.if_match_value().is_some_and(|etag| etag != current_etag)
|
||||
|| preconditions.if_none_match_value() == Some("*"))
|
||||
&& (preconditions
|
||||
.if_match_value()
|
||||
.is_some_and(|etag| !object_exists || etag != current_etag)
|
||||
|| (object_exists && preconditions.if_none_match_value() == Some("*")))
|
||||
{
|
||||
return Err(Error::PreconditionFailed);
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
data.stream.read_to_end(&mut body).await?;
|
||||
self.last_put_no_lock.store(opts.no_lock, Ordering::SeqCst);
|
||||
*self.last_put_preconditions.lock().expect("preconditions lock poisoned") = opts.http_preconditions.clone();
|
||||
self.write_calls.fetch_add(1, Ordering::SeqCst);
|
||||
*self.state.lock().expect("state lock poisoned") = RecoveryReadState::Blob(body.clone());
|
||||
let revision = self.revision.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
@@ -4247,6 +4759,7 @@ mod tests {
|
||||
size: i64::try_from(body.len()).expect("test config should fit in i64"),
|
||||
actual_size: i64::try_from(body.len()).expect("test config should fit in i64"),
|
||||
etag: Some(format!("config-{revision}")),
|
||||
data_dir: Some(uuid::Uuid::from_u128(u128::try_from(revision).expect("test revision should fit in u128"))),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -4284,10 +4797,18 @@ mod tests {
|
||||
.expect("scanner-only config change should be persisted");
|
||||
|
||||
assert_eq!(store.write_calls.load(Ordering::SeqCst), 1);
|
||||
assert!(store.last_put_no_lock.load(Ordering::SeqCst));
|
||||
assert!(!store.last_put_no_lock.load(Ordering::SeqCst));
|
||||
let preconditions = store
|
||||
.last_put_preconditions
|
||||
.lock()
|
||||
.expect("preconditions lock poisoned")
|
||||
.clone()
|
||||
.expect("existing config update must be conditional");
|
||||
assert_eq!(preconditions.if_match_value(), Some("config-1"));
|
||||
assert_eq!(preconditions.if_none_match_value(), None);
|
||||
assert_eq!(
|
||||
store.lock_resources.lock().expect("lock resources mutex poisoned").as_slice(),
|
||||
&[server_config_path()]
|
||||
&[server_config_transaction_lock_path()]
|
||||
);
|
||||
let decoded = read_config_without_migrate(store)
|
||||
.await
|
||||
@@ -4301,6 +4822,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_config_snapshot_save_returns_committed_generation() {
|
||||
let baseline = encode_server_config_blob(&Config::new(), None).expect("baseline config should encode");
|
||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline), None));
|
||||
let snapshot = read_server_config_snapshot(store.clone())
|
||||
.await
|
||||
.expect("server config snapshot");
|
||||
|
||||
let result = save_server_config_snapshot_with_generation(store, &config_with_scanner_cycle("61"), &snapshot)
|
||||
.await
|
||||
.expect("conditional config save");
|
||||
|
||||
assert!(result.persisted());
|
||||
assert_eq!(result.generation(), Some(uuid::Uuid::from_u128(2)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_server_config_is_created_with_if_none_match() {
|
||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Missing, None));
|
||||
let cfg = config_with_scanner_cycle("61");
|
||||
|
||||
save_server_config(store.clone(), &cfg)
|
||||
.await
|
||||
.expect("missing config should be created conditionally");
|
||||
|
||||
assert_eq!(store.write_calls.load(Ordering::SeqCst), 1);
|
||||
assert!(!store.last_put_no_lock.load(Ordering::SeqCst));
|
||||
let preconditions = store
|
||||
.last_put_preconditions
|
||||
.lock()
|
||||
.expect("preconditions lock poisoned")
|
||||
.clone()
|
||||
.expect("missing config create must be conditional");
|
||||
assert_eq!(preconditions.if_match_value(), None);
|
||||
assert_eq!(preconditions.if_none_match_value(), Some("*"));
|
||||
let persisted = read_config_without_migrate(store)
|
||||
.await
|
||||
.expect("created config should reload");
|
||||
assert_eq!(
|
||||
persisted
|
||||
.get_value(SCANNER_SUB_SYS, DEFAULT_DELIMITER)
|
||||
.expect("persisted scanner config")
|
||||
.get(SCANNER_CYCLE),
|
||||
"61"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_config_initialization_recheck_preserves_concurrent_config() {
|
||||
let existing = config_with_scanner_cycle("73");
|
||||
let baseline = encode_server_config_blob(&existing, None).expect("existing config should encode");
|
||||
let store = Arc::new(RecoveryMockStore::new(RecoveryReadState::Blob(baseline), None));
|
||||
|
||||
let observed = new_and_save_server_config(store.clone())
|
||||
.await
|
||||
.expect("initialization recheck should return the config created by another writer");
|
||||
|
||||
assert_eq!(store.write_calls.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(
|
||||
observed
|
||||
.get_value(SCANNER_SUB_SYS, DEFAULT_DELIMITER)
|
||||
.expect("concurrent scanner config")
|
||||
.get(SCANNER_CYCLE),
|
||||
"73"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_server_config_snapshot_cannot_overwrite_newer_update() {
|
||||
let baseline = encode_server_config_blob(&Config::new(), None).expect("baseline config should encode");
|
||||
@@ -4357,7 +4945,7 @@ mod tests {
|
||||
let lock = rustfs_lock::NamespaceLock::new("server-config-lease-loss".to_string(), client.clone());
|
||||
let guard = lock
|
||||
.lock_guard(
|
||||
rustfs_lock::ObjectKey::new(crate::disk::RUSTFS_META_BUCKET, server_config_path()),
|
||||
rustfs_lock::ObjectKey::new(crate::disk::RUSTFS_META_BUCKET, server_config_transaction_lock_path()),
|
||||
"server-config-lease-loss",
|
||||
std::time::Duration::from_secs(1),
|
||||
std::time::Duration::from_millis(120),
|
||||
@@ -4372,6 +4960,7 @@ mod tests {
|
||||
raw: Some(baseline.clone()),
|
||||
seed: None,
|
||||
etag: Some("config-0".to_string()),
|
||||
generation: Some(uuid::Uuid::from_u128(1)),
|
||||
_local_guard: local_guard,
|
||||
_guard: guard,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
#![recursion_limit = "256"]
|
||||
|
||||
/// 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,10 +412,6 @@ 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;
|
||||
@@ -436,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;
|
||||
@@ -563,8 +601,8 @@ pub(crate) async fn init_tier_config_mgr(store: Arc<ECStore>) -> Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
LockRegistry, clear_local_disk_id_map_for_test, local_disk_path_by_id, local_node_name, replace_local_disk_id,
|
||||
set_local_node_name,
|
||||
LockRegistry, clear_local_disk_id_map_for_test, local_disk_path_by_id, local_node_name, reconcile_local_disk_ids,
|
||||
replace_local_disk_id, set_local_node_name,
|
||||
};
|
||||
use crate::disk::endpoint::Endpoint;
|
||||
use rustfs_lock::{LocalClient, LockClient};
|
||||
@@ -628,4 +666,65 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -147,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,
|
||||
@@ -670,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.
|
||||
@@ -4715,6 +4714,7 @@ mod tests {
|
||||
use crate::layout::endpoints::SetupType;
|
||||
use crate::object_api::BLOCK_SIZE_V2;
|
||||
use crate::object_api::ObjectInfo;
|
||||
use crate::set_disk::core::io_primitives::rename_fanout_barrier;
|
||||
use crate::storage_api_contracts::{
|
||||
heal::HealOperations as _, lifecycle::TransitionedObject, list::ListOperations as _, multipart::CompletePart,
|
||||
namespace::NamespaceLocking as _, object::ObjectIO as _, object::ObjectOperations as _,
|
||||
@@ -9566,6 +9566,15 @@ mod tests {
|
||||
make_local_bucket_test_set_disks_with_drive_count(2).await
|
||||
}
|
||||
|
||||
fn assert_exclusive_object_lock_held(set_disks: &SetDisks, bucket: &str, object: &str) {
|
||||
let lock = set_disks
|
||||
.local_lock_manager_for_test()
|
||||
.get_lock_info(&ObjectKey::new(bucket, object))
|
||||
.expect("object lock should be visible while rename is paused");
|
||||
assert!(matches!(lock.mode, rustfs_lock::LockMode::Exclusive));
|
||||
assert_eq!(lock.owner.as_ref(), set_disks.locker_owner.as_str());
|
||||
}
|
||||
|
||||
async fn make_local_bucket_test_set_disks_with_drive_count(drive_count: usize) -> Arc<SetDisks> {
|
||||
let format = FormatV3::new(1, drive_count);
|
||||
let mut endpoints = Vec::new();
|
||||
@@ -9600,7 +9609,9 @@ mod tests {
|
||||
disks.push(Some(disk));
|
||||
}
|
||||
|
||||
let set_disks = SetDisks::new(
|
||||
let instance_ctx = Arc::new(InstanceContext::new());
|
||||
instance_ctx.update_erasure_type(SetupType::Erasure).await;
|
||||
let set_disks = SetDisks::new_with_instance_ctx(
|
||||
"test-owner".to_string(),
|
||||
Arc::new(RwLock::new(disks)),
|
||||
drive_count,
|
||||
@@ -9610,6 +9621,7 @@ mod tests {
|
||||
endpoints,
|
||||
format,
|
||||
Vec::new(),
|
||||
instance_ctx,
|
||||
)
|
||||
.await;
|
||||
set_disks.set_test_storage_class_config(
|
||||
@@ -10263,6 +10275,216 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conditional_replace_holds_object_lock_through_rename() {
|
||||
let set_disks = make_local_bucket_test_set_disks().await;
|
||||
let bucket = "bucket-conditional-replace-fence";
|
||||
let object = "config/conditional-replace.json";
|
||||
set_disks
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created");
|
||||
|
||||
let mut initial_reader = PutObjReader::from_vec(b"initial config".to_vec());
|
||||
let initial = set_disks
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut initial_reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("initial config should be written");
|
||||
let initial_etag = initial.etag.expect("initial config should have an ETag");
|
||||
|
||||
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||
let writer_store = set_disks.clone();
|
||||
let expected_etag = initial_etag.clone();
|
||||
let writer = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(b"replacement config".to_vec());
|
||||
writer_store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
preserve_etag: Some("replacement-etag".to_string()),
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(expected_etag),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("conditional replace should reach the rename barrier");
|
||||
assert_exclusive_object_lock_held(&set_disks, bucket, object);
|
||||
barrier.release();
|
||||
writer
|
||||
.await
|
||||
.expect("conditional writer task should finish")
|
||||
.expect("matching conditional replace should commit");
|
||||
assert!(
|
||||
set_disks
|
||||
.local_lock_manager_for_test()
|
||||
.get_lock_info(&ObjectKey::new(bucket, object))
|
||||
.is_none(),
|
||||
"conditional replace should release the object lock after commit"
|
||||
);
|
||||
let contender = set_disks
|
||||
.new_ns_lock(bucket, object)
|
||||
.await
|
||||
.expect("contender namespace lock should be created");
|
||||
let contender_guard = contender
|
||||
.get_write_lock(std::time::Duration::from_secs(30))
|
||||
.await
|
||||
.expect("contender should acquire after conditional replace commits");
|
||||
drop(contender_guard);
|
||||
|
||||
let mut stale_reader = PutObjReader::from_vec(b"stale config".to_vec());
|
||||
let err = set_disks
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut stale_reader,
|
||||
&ObjectOptions {
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_match: Some(initial_etag),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("the old ETag must fail after the fenced replacement commits");
|
||||
assert_eq!(err, StorageError::PreconditionFailed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repeated_body_write_keeps_etag_but_changes_data_dir_generation() {
|
||||
let set_disks = make_local_bucket_test_set_disks().await;
|
||||
let bucket = "bucket-write-generation";
|
||||
let object = "config/write-generation.json";
|
||||
let body = b"identical config body".to_vec();
|
||||
set_disks
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created");
|
||||
|
||||
let mut first_reader = PutObjReader::from_vec(body.clone());
|
||||
let first = set_disks
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut first_reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("first config body should be written");
|
||||
let mut second_reader = PutObjReader::from_vec(body);
|
||||
let second = set_disks
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut second_reader,
|
||||
&ObjectOptions {
|
||||
no_lock: true,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("identical config body should be rewritten");
|
||||
|
||||
assert_eq!(first.etag, second.etag, "content ETag should expose the ABA collision");
|
||||
assert_ne!(first.data_dir, second.data_dir, "each committed body write needs a unique generation");
|
||||
assert!(first.data_dir.is_some() && second.data_dir.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn conditional_create_holds_object_lock_through_rename() {
|
||||
let set_disks = make_local_bucket_test_set_disks().await;
|
||||
let bucket = "bucket-conditional-create-fence";
|
||||
let object = "config/conditional-create.json";
|
||||
set_disks
|
||||
.make_bucket(bucket, &MakeBucketOptions::default())
|
||||
.await
|
||||
.expect("bucket should be created");
|
||||
|
||||
let barrier = rename_fanout_barrier::arm(object, 0, rename_fanout_barrier::PHASE_RENAME);
|
||||
let writer_store = set_disks.clone();
|
||||
let writer = tokio::spawn(async move {
|
||||
let mut reader = PutObjReader::from_vec(b"created config".to_vec());
|
||||
writer_store
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut reader,
|
||||
&ObjectOptions {
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), barrier.wait_until_paused())
|
||||
.await
|
||||
.expect("conditional create should reach the rename barrier");
|
||||
assert_exclusive_object_lock_held(&set_disks, bucket, object);
|
||||
barrier.release();
|
||||
writer
|
||||
.await
|
||||
.expect("conditional writer task should finish")
|
||||
.expect("first conditional create should commit");
|
||||
assert!(
|
||||
set_disks
|
||||
.local_lock_manager_for_test()
|
||||
.get_lock_info(&ObjectKey::new(bucket, object))
|
||||
.is_none(),
|
||||
"conditional create should release the object lock after commit"
|
||||
);
|
||||
let contender = set_disks
|
||||
.new_ns_lock(bucket, object)
|
||||
.await
|
||||
.expect("contender namespace lock should be created");
|
||||
let contender_guard = contender
|
||||
.get_write_lock(std::time::Duration::from_secs(30))
|
||||
.await
|
||||
.expect("contender should acquire after conditional create commits");
|
||||
drop(contender_guard);
|
||||
|
||||
let mut duplicate_reader = PutObjReader::from_vec(b"duplicate config".to_vec());
|
||||
let err = set_disks
|
||||
.put_object(
|
||||
bucket,
|
||||
object,
|
||||
&mut duplicate_reader,
|
||||
&ObjectOptions {
|
||||
http_preconditions: Some(HTTPPreconditions {
|
||||
if_none_match: Some("*".to_string()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("a second create-only write must not replace the committed config");
|
||||
assert_eq!(err, StorageError::PreconditionFailed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_level_if_none_match_fails_closed_without_read_quorum() {
|
||||
let set_disks = make_local_bucket_test_set_disks_with_drive_count(4).await;
|
||||
|
||||
@@ -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>],
|
||||
|
||||
@@ -313,7 +313,8 @@ 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,
|
||||
&mut disks,
|
||||
pool_eps.set_count,
|
||||
@@ -1259,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(),
|
||||
@@ -1273,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]
|
||||
|
||||
@@ -16,6 +16,8 @@ use crate::config::storageclass;
|
||||
use crate::disk::error_reduce::{count_errs, reduce_write_quorum_errs};
|
||||
use crate::disk::{self, DiskAPI};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::runtime::instance::InstanceContext;
|
||||
use crate::runtime::sources::{quarantine_local_disks, reconcile_local_disk_ids};
|
||||
use crate::{
|
||||
disk::{
|
||||
DiskInfoOptions, DiskOption, DiskStore, FORMAT_CONFIG_FILE, MIGRATING_META_BUCKET, RUSTFS_META_BUCKET,
|
||||
@@ -26,7 +28,10 @@ use crate::{
|
||||
layout::endpoints::Endpoints,
|
||||
};
|
||||
use futures::{future::join_all, stream, stream::StreamExt};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tracing::{debug, error, info, warn};
|
||||
use uuid::Uuid;
|
||||
@@ -62,12 +67,25 @@ pub async fn init_disks(eps: &Endpoints, opt: &DiskOption) -> (Vec<Option<DiskSt
|
||||
(res, errors)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn connect_load_init_formats(
|
||||
first_disk: bool,
|
||||
disks: &mut [Option<DiskStore>],
|
||||
set_count: usize,
|
||||
set_drive_count: usize,
|
||||
deployment_id: Option<Uuid>,
|
||||
) -> Result<FormatV3> {
|
||||
let instance_ctx = crate::runtime::global::current_ctx();
|
||||
connect_load_init_formats_with_instance_ctx(&instance_ctx, first_disk, disks, set_count, set_drive_count, deployment_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn connect_load_init_formats_with_instance_ctx(
|
||||
instance_ctx: &Arc<InstanceContext>,
|
||||
first_disk: bool,
|
||||
disks: &mut [Option<DiskStore>],
|
||||
set_count: usize,
|
||||
set_drive_count: usize,
|
||||
deployment_id: Option<Uuid>,
|
||||
) -> Result<FormatV3> {
|
||||
let (formats, errs) = load_format_erasure_all(disks, false).await;
|
||||
|
||||
@@ -98,7 +116,7 @@ pub async fn connect_load_init_formats(
|
||||
match try_migrate_format(disks, &formats, set_count, set_drive_count).await {
|
||||
Ok(LegacyFormatOutcome::Migrated { format, quorum_members }) => {
|
||||
info!("Migrated format from MinIO config");
|
||||
retain_format_quorum_members(disks, &format, &quorum_members, set_drive_count).await?;
|
||||
retain_format_quorum_members(instance_ctx, disks, &format, &quorum_members, set_drive_count).await?;
|
||||
return Ok(*format);
|
||||
}
|
||||
Ok(LegacyFormatOutcome::Incompatible) => {
|
||||
@@ -115,7 +133,7 @@ pub async fn connect_load_init_formats(
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
if all_unformatted {
|
||||
let fm = init_format_erasure(disks, set_count, set_drive_count, deployment_id).await?;
|
||||
let fm = init_format_erasure(instance_ctx, disks, set_count, set_drive_count, deployment_id).await?;
|
||||
return Ok(fm);
|
||||
}
|
||||
}
|
||||
@@ -140,12 +158,13 @@ pub async fn connect_load_init_formats(
|
||||
None => select_format_erasure_in_quorum(&formats, 0)?,
|
||||
};
|
||||
check_format_erasure_value_for_topology(&fm, formats.len(), set_drive_count)?;
|
||||
retain_format_quorum_members(disks, &fm, &quorum_members, set_drive_count).await?;
|
||||
retain_format_quorum_members(instance_ctx, disks, &fm, &quorum_members, set_drive_count).await?;
|
||||
|
||||
Ok(fm)
|
||||
}
|
||||
|
||||
async fn retain_format_quorum_members(
|
||||
instance_ctx: &Arc<InstanceContext>,
|
||||
disks: &mut [Option<DiskStore>],
|
||||
format: &FormatV3,
|
||||
quorum_members: &[bool],
|
||||
@@ -154,26 +173,73 @@ async fn retain_format_quorum_members(
|
||||
if set_drive_count == 0 || quorum_members.len() != disks.len() {
|
||||
return Err(Error::CorruptedFormat);
|
||||
}
|
||||
for (disk, belongs_to_quorum) in disks.iter().zip(quorum_members) {
|
||||
if !belongs_to_quorum && let Some(disk) = disk {
|
||||
disk.set_disk_id(None).await?;
|
||||
|
||||
let pool_idx = disks
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|disk| disk.endpoint().pool_idx)
|
||||
.find_map(|pool_idx| usize::try_from(pool_idx).ok());
|
||||
let registered_endpoints = if let Some(pool_idx) = pool_idx {
|
||||
instance_ctx
|
||||
.local_disk_set_drives()
|
||||
.read()
|
||||
.await
|
||||
.get(pool_idx)
|
||||
.map(|sets| {
|
||||
sets.iter()
|
||||
.flat_map(|set| set.iter())
|
||||
.map(|disk| disk.as_ref().map(|disk| disk.endpoint()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|endpoints| endpoints.len() == disks.len())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let endpoints = disks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, disk)| {
|
||||
disk.as_ref()
|
||||
.map(|disk| disk.endpoint())
|
||||
.or_else(|| registered_endpoints.as_ref()?.get(index)?.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut member_disk_ids = vec![None; disks.len()];
|
||||
let mut local_pool_endpoints = Vec::new();
|
||||
let mut selected_local_disk_ids = Vec::new();
|
||||
let mut quarantined_endpoints = Vec::new();
|
||||
for (index, ((disk, endpoint), belongs_to_quorum)) in disks.iter().zip(&endpoints).zip(quorum_members).enumerate() {
|
||||
if let Some(endpoint) = endpoint.as_ref().filter(|endpoint| endpoint.is_local) {
|
||||
local_pool_endpoints.push(endpoint.to_string());
|
||||
if !belongs_to_quorum {
|
||||
quarantined_endpoints.push(endpoint.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (index, (disk, belongs_to_quorum)) in disks.iter().zip(quorum_members).enumerate() {
|
||||
if !belongs_to_quorum {
|
||||
continue;
|
||||
}
|
||||
let disk = disk.as_ref().ok_or(Error::CorruptedFormat)?;
|
||||
let disk_id = format
|
||||
.erasure
|
||||
.sets
|
||||
.get(index / set_drive_count)
|
||||
.and_then(|set| set.get(index % set_drive_count))
|
||||
.copied()
|
||||
.ok_or(Error::CorruptedFormat)?;
|
||||
disk.as_ref()
|
||||
.ok_or(Error::CorruptedFormat)?
|
||||
.set_disk_id(Some(*disk_id))
|
||||
.await?;
|
||||
member_disk_ids[index] = Some(disk_id);
|
||||
if disk.is_local() {
|
||||
selected_local_disk_ids.push((disk_id, disk.endpoint().to_string()));
|
||||
}
|
||||
}
|
||||
quarantine_local_disks(instance_ctx, &quarantined_endpoints).await?;
|
||||
|
||||
for (disk, disk_id) in disks.iter().zip(member_disk_ids) {
|
||||
if let Some(disk) = disk {
|
||||
disk.set_disk_id_state(disk_id).await?;
|
||||
}
|
||||
}
|
||||
reconcile_local_disk_ids(instance_ctx, &local_pool_endpoints, &selected_local_disk_ids).await;
|
||||
for (disk, belongs_to_quorum) in disks.iter_mut().zip(quorum_members) {
|
||||
if !belongs_to_quorum {
|
||||
*disk = None;
|
||||
@@ -207,7 +273,8 @@ pub fn check_disk_fatal_errs(errs: &[Option<DiskError>]) -> disk::error::Result<
|
||||
}
|
||||
|
||||
async fn init_format_erasure(
|
||||
disks: &[Option<DiskStore>],
|
||||
instance_ctx: &Arc<InstanceContext>,
|
||||
disks: &mut [Option<DiskStore>],
|
||||
set_count: usize,
|
||||
set_drive_count: usize,
|
||||
deployment_id: Option<Uuid>,
|
||||
@@ -227,9 +294,11 @@ async fn init_format_erasure(
|
||||
}
|
||||
}
|
||||
|
||||
save_format_file_all(disks, &fms).await?;
|
||||
|
||||
get_format_erasure_in_quorum(&fms, 0)
|
||||
write_format_file_all(disks, &fms).await?;
|
||||
let format = get_format_erasure_in_quorum(&fms, 0)?;
|
||||
let quorum_members = vec![true; disks.len()];
|
||||
retain_format_quorum_members(instance_ctx, disks, &format, &quorum_members, set_drive_count).await?;
|
||||
Ok(format)
|
||||
}
|
||||
|
||||
/// Outcome of attempting to migrate an on-disk MinIO `format.json`.
|
||||
@@ -348,44 +417,59 @@ async fn try_migrate_format(
|
||||
.iter()
|
||||
.zip(&formats_to_write)
|
||||
.zip(rustfs_formats)
|
||||
.filter(|&((disk, _), existing)| disk.is_some() && existing.is_none())
|
||||
.map(|((disk, format), _)| save_format_file(disk, format));
|
||||
.filter_map(|((disk, format), existing)| {
|
||||
if existing.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(write_format_file(disk.as_ref()?, format.as_ref()?))
|
||||
});
|
||||
let mut write_error = None;
|
||||
for result in join_all(writes).await {
|
||||
if let Err(error) = result {
|
||||
write_error.get_or_insert(error);
|
||||
}
|
||||
}
|
||||
for disk in disks.iter().flatten() {
|
||||
disk.set_disk_id(None).await?;
|
||||
}
|
||||
|
||||
let (persisted_formats, persisted_errors) = load_format_erasure_all(disks, false).await;
|
||||
let Some((persisted_format, quorum_members)) =
|
||||
select_persisted_migration_format(&persisted_formats, persisted_errors, &format, set_drive_count, write_error)?
|
||||
else {
|
||||
return Ok(LegacyFormatOutcome::Incompatible);
|
||||
};
|
||||
|
||||
Ok(LegacyFormatOutcome::Migrated {
|
||||
format: Box::new(persisted_format),
|
||||
quorum_members,
|
||||
})
|
||||
}
|
||||
|
||||
fn select_persisted_migration_format(
|
||||
persisted_formats: &[Option<FormatV3>],
|
||||
persisted_errors: Vec<Option<DiskError>>,
|
||||
reference: &FormatV3,
|
||||
set_drive_count: usize,
|
||||
write_error: Option<DiskError>,
|
||||
) -> Result<Option<(FormatV3, Vec<bool>)>> {
|
||||
if !formats_match_reference_slots(persisted_formats, reference, 0) {
|
||||
return Ok(None);
|
||||
}
|
||||
let quorum_error = match select_format_erasure_in_quorum(persisted_formats, 0) {
|
||||
Ok((format, quorum_members)) => {
|
||||
check_format_erasure_value_for_topology(&format, persisted_formats.len(), set_drive_count)?;
|
||||
return Ok(Some((format, quorum_members)));
|
||||
}
|
||||
Err(error) => error,
|
||||
};
|
||||
if let Some(error) = persisted_errors
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|error| !matches!(error, DiskError::UnformattedDisk | DiskError::DiskNotFound))
|
||||
{
|
||||
return match error {
|
||||
DiskError::CorruptedFormat | DiskError::CorruptedBackend | DiskError::InconsistentDisk => {
|
||||
Ok(LegacyFormatOutcome::Incompatible)
|
||||
}
|
||||
DiskError::CorruptedFormat | DiskError::CorruptedBackend | DiskError::InconsistentDisk => Ok(None),
|
||||
error => Err(error.into()),
|
||||
};
|
||||
}
|
||||
if !formats_match_reference_slots(&persisted_formats, &format, 0) {
|
||||
return Ok(LegacyFormatOutcome::Incompatible);
|
||||
}
|
||||
let (persisted_format, quorum_members) = match select_format_erasure_in_quorum(&persisted_formats, 0) {
|
||||
Ok(selected) => selected,
|
||||
Err(error) => return Err(write_error.map_or(error, Into::into)),
|
||||
};
|
||||
check_format_erasure_value_for_topology(&persisted_format, disks.len(), set_drive_count)?;
|
||||
|
||||
Ok(LegacyFormatOutcome::Migrated {
|
||||
format: Box::new(persisted_format),
|
||||
quorum_members,
|
||||
})
|
||||
Err(write_error.map_or(quorum_error, Into::into))
|
||||
}
|
||||
|
||||
fn legacy_format_max_bytes(disk_count: usize) -> Result<usize> {
|
||||
@@ -676,13 +760,12 @@ pub async fn load_format_erasure(disk: &DiskStore, heal: bool) -> disk::error::R
|
||||
Ok(fm)
|
||||
}
|
||||
|
||||
async fn save_format_file_all(disks: &[Option<DiskStore>], formats: &[Option<FormatV3>]) -> disk::error::Result<()> {
|
||||
let mut futures = Vec::with_capacity(disks.len());
|
||||
|
||||
for (i, disk) in disks.iter().enumerate() {
|
||||
futures.push(save_format_file(disk, &formats[i]));
|
||||
}
|
||||
|
||||
async fn write_format_file_all(disks: &[Option<DiskStore>], formats: &[Option<FormatV3>]) -> disk::error::Result<()> {
|
||||
let futures = disks.iter().zip(formats).map(|(disk, format)| async move {
|
||||
let disk = disk.as_ref().ok_or(DiskError::DiskNotFound)?;
|
||||
let format = format.as_ref().ok_or_else(|| DiskError::other("format is none"))?;
|
||||
write_format_file(disk, format).await
|
||||
});
|
||||
let mut errors = Vec::with_capacity(disks.len());
|
||||
|
||||
let results = join_all(futures).await;
|
||||
@@ -713,6 +796,13 @@ pub async fn save_format_file(disk: &Option<DiskStore>, format: &Option<FormatV3
|
||||
return Err(DiskError::other("format is none"));
|
||||
};
|
||||
|
||||
write_format_file(disk, format).await?;
|
||||
disk.set_disk_id(Some(format.erasure.this)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn write_format_file(disk: &DiskStore, format: &FormatV3) -> disk::error::Result<()> {
|
||||
let json_data = format.to_json()?;
|
||||
|
||||
let tmpfile = Uuid::new_v4().to_string();
|
||||
@@ -723,8 +813,6 @@ pub async fn save_format_file(disk: &Option<DiskStore>, format: &Option<FormatV3
|
||||
disk.rename_file(RUSTFS_META_BUCKET, tmpfile.as_str(), RUSTFS_META_BUCKET, FORMAT_CONFIG_FILE)
|
||||
.await?;
|
||||
|
||||
disk.set_disk_id(Some(format.erasure.this)).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -738,7 +826,9 @@ pub fn ec_drives_no_config(set_drive_count: usize) -> Result<usize> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layout::endpoint::Endpoint;
|
||||
use crate::runtime::sources::{clear_local_disk_id_map_for_test, local_disk_path_by_id};
|
||||
use crate::runtime::global::{current_ctx, reset_local_disk_test_state};
|
||||
use crate::runtime::sources::{local_disk_path_by_id, record_local_disks};
|
||||
use crate::store::peer::{find_local_disk_by_ref, prewarm_local_disk_id_map_with_instance_ctx};
|
||||
use serial_test::serial;
|
||||
|
||||
async fn local_disks(count: usize) -> (tempfile::TempDir, Vec<Option<DiskStore>>) {
|
||||
@@ -1045,7 +1135,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn existing_format_load_publishes_only_validated_quorum_disk_ids() {
|
||||
clear_local_disk_id_map_for_test().await;
|
||||
reset_local_disk_test_state().await;
|
||||
let (_temp_dir, mut disks) = local_disks(5).await;
|
||||
let canonical = FormatV3::new(1, 5);
|
||||
for (index, disk) in disks.iter().enumerate().take(3) {
|
||||
@@ -1055,19 +1145,56 @@ mod tests {
|
||||
.await
|
||||
.expect("canonical format should be written");
|
||||
}
|
||||
let mut wrong_slot = canonical.clone();
|
||||
wrong_slot.erasure.this = wrong_slot.erasure.sets[0][0];
|
||||
save_format_file(&disks[3], &Some(wrong_slot))
|
||||
.await
|
||||
.expect("wrong-slot format should be written");
|
||||
let canonical_id = canonical.erasure.sets[0][0];
|
||||
let mut foreign = FormatV3::new(1, 5);
|
||||
foreign.erasure.this = foreign.erasure.sets[0][4];
|
||||
let foreign_id = foreign.erasure.this;
|
||||
save_format_file(&disks[4], &Some(foreign))
|
||||
.await
|
||||
.expect("foreign format should be written");
|
||||
let canonical_id = canonical.erasure.sets[0][0];
|
||||
let mut collision = FormatV3::new(1, 5);
|
||||
collision.erasure.sets[0][3] = canonical_id;
|
||||
collision.erasure.this = canonical_id;
|
||||
save_format_file(&disks[3], &Some(collision))
|
||||
.await
|
||||
.expect("foreign collision format should be written");
|
||||
let canonical_endpoint = disks[0].as_ref().expect("canonical disk should exist").endpoint().to_string();
|
||||
let collision_endpoint = disks[3].as_ref().expect("collision disk should exist").endpoint().to_string();
|
||||
let foreign_endpoint = disks[4].as_ref().expect("foreign disk should exist").endpoint().to_string();
|
||||
let prewarm_endpoints = Endpoints::from(
|
||||
disks
|
||||
.iter()
|
||||
.map(|disk| disk.as_ref().expect("test disk should exist").endpoint())
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
for disk in disks.iter().flatten() {
|
||||
disk.set_disk_id(None).await.expect("test disk ID should be reset");
|
||||
}
|
||||
let (prewarm_disks, prewarm_errors) = init_disks(
|
||||
&prewarm_endpoints,
|
||||
&DiskOption {
|
||||
cleanup: false,
|
||||
health_check: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(prewarm_errors.iter().all(Option::is_none));
|
||||
let prewarm_disks = prewarm_disks
|
||||
.into_iter()
|
||||
.map(|disk| disk.expect("prewarm disk should exist"))
|
||||
.collect::<Vec<_>>();
|
||||
let instance_ctx = current_ctx();
|
||||
record_local_disks(&instance_ctx, prewarm_disks.clone()).await;
|
||||
*instance_ctx.local_disk_set_drives().write().await = vec![vec![prewarm_disks.into_iter().map(Some).collect()]];
|
||||
prewarm_local_disk_id_map_with_instance_ctx(&instance_ctx).await;
|
||||
instance_ctx
|
||||
.local_disk_id_map()
|
||||
.write()
|
||||
.await
|
||||
.insert(canonical_id, collision_endpoint.clone());
|
||||
assert!(local_disk_path_by_id(&foreign_id).await.is_some(), "foreign ID should be prewarmed");
|
||||
assert_eq!(local_disk_path_by_id(&canonical_id).await, Some(collision_endpoint));
|
||||
disks[4] = None;
|
||||
|
||||
connect_load_init_formats(true, &mut disks, 1, 5, None)
|
||||
.await
|
||||
@@ -1075,9 +1202,51 @@ mod tests {
|
||||
|
||||
assert!(disks[..3].iter().all(Option::is_some));
|
||||
assert!(disks[3..].iter().all(Option::is_none));
|
||||
assert_eq!(local_disk_path_by_id(&canonical_id).await, Some(canonical_endpoint));
|
||||
assert_eq!(local_disk_path_by_id(&canonical_id).await, Some(canonical_endpoint.clone()));
|
||||
assert_eq!(local_disk_path_by_id(&foreign_id).await, None);
|
||||
clear_local_disk_id_map_for_test().await;
|
||||
assert!(
|
||||
instance_ctx
|
||||
.local_disk_map()
|
||||
.read()
|
||||
.await
|
||||
.get(&foreign_endpoint)
|
||||
.is_some_and(Option::is_none)
|
||||
);
|
||||
assert!(instance_ctx.local_disk_set_drives().read().await[0][0][4].is_none());
|
||||
assert!(find_local_disk_by_ref(&foreign_id.to_string()).await.is_none());
|
||||
assert_eq!(
|
||||
find_local_disk_by_ref(&canonical_id.to_string())
|
||||
.await
|
||||
.map(|disk| disk.endpoint().to_string()),
|
||||
Some(canonical_endpoint)
|
||||
);
|
||||
reset_local_disk_test_state().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_migration_quorum_ignores_nonmember_read_errors() {
|
||||
for drive_count in [3, 4] {
|
||||
let reference = FormatV3::new(1, drive_count);
|
||||
let required = drive_count / 2 + 1;
|
||||
let mut formats = vec![None; drive_count];
|
||||
let mut errors = (0..drive_count).map(|_| None).collect::<Vec<Option<DiskError>>>();
|
||||
for (index, format) in formats.iter_mut().enumerate().take(required) {
|
||||
let mut disk_format = reference.clone();
|
||||
disk_format.erasure.this = reference.erasure.sets[0][index];
|
||||
*format = Some(disk_format);
|
||||
}
|
||||
errors[drive_count - 1] = Some(DiskError::FaultyDisk);
|
||||
|
||||
let (selected, members) =
|
||||
select_persisted_migration_format(&formats, errors, &reference, drive_count, Some(DiskError::FaultyDisk))
|
||||
.expect("a persisted strict majority must outrank a nonmember read error")
|
||||
.expect("the persisted formats should be compatible");
|
||||
|
||||
assert_eq!(selected.shared_identity(), reference.shared_identity());
|
||||
assert_eq!(members.iter().filter(|member| **member).count(), required);
|
||||
assert!(members[..required].iter().all(|member| *member));
|
||||
assert!(members[required..].iter().all(|member| !*member));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -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,7 +27,14 @@ categories = ["web-programming", "development-tools"]
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = ["hotpath/hotpath"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu"]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror.workspace = true
|
||||
|
||||
|
||||
@@ -27,10 +27,12 @@ documentation = "https://docs.rs/rustfs-filemeta/latest/rustfs_filemeta/"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = ["dep:hotpath", "hotpath/hotpath"]
|
||||
hotpath = ["hotpath/hotpath", "hotpath/tokio", "rustfs-utils/hotpath"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "rustfs-utils/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-utils/hotpath-cpu"]
|
||||
|
||||
[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();
|
||||
|
||||
|
||||
@@ -29,7 +29,48 @@ categories = ["web-programming", "development-tools", "filesystem"]
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
"hotpath/futures",
|
||||
"rustfs-common/hotpath",
|
||||
"rustfs-concurrency/hotpath",
|
||||
"rustfs-config/hotpath",
|
||||
"rustfs-ecstore/hotpath",
|
||||
"rustfs-madmin/hotpath",
|
||||
"rustfs-storage-api/hotpath",
|
||||
"rustfs-utils/hotpath",
|
||||
"rustfs-test-utils/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-common/hotpath-alloc",
|
||||
"rustfs-concurrency/hotpath-alloc",
|
||||
"rustfs-config/hotpath-alloc",
|
||||
"rustfs-ecstore/hotpath-alloc",
|
||||
"rustfs-madmin/hotpath-alloc",
|
||||
"rustfs-storage-api/hotpath-alloc",
|
||||
"rustfs-utils/hotpath-alloc",
|
||||
"rustfs-test-utils/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-cpu",
|
||||
"rustfs-common/hotpath-cpu",
|
||||
"rustfs-concurrency/hotpath-cpu",
|
||||
"rustfs-config/hotpath-cpu",
|
||||
"rustfs-ecstore/hotpath-cpu",
|
||||
"rustfs-madmin/hotpath-cpu",
|
||||
"rustfs-storage-api/hotpath-cpu",
|
||||
"rustfs-utils/hotpath-cpu",
|
||||
"rustfs-test-utils/hotpath-cpu",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
rustfs-config = { workspace = true }
|
||||
rustfs-concurrency = { workspace = true }
|
||||
rustfs-ecstore = { workspace = true }
|
||||
|
||||
@@ -159,6 +159,7 @@ impl ErasureSetHealer {
|
||||
|
||||
/// execute erasure set heal with resume
|
||||
#[tracing::instrument(skip(self, buckets), fields(set_disk_id = %set_disk_id, bucket_count = buckets.len()))]
|
||||
#[hotpath::measure]
|
||||
pub async fn heal_erasure_set(&self, buckets: &[String], set_disk_id: &str) -> Result<()> {
|
||||
debug!(
|
||||
target: "rustfs::heal::erasure_healer",
|
||||
|
||||
@@ -584,6 +584,7 @@ impl HealTask {
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self), fields(task_id = %self.id, heal_type = ?self.heal_type))]
|
||||
#[hotpath::measure]
|
||||
pub async fn execute(&self) -> Result<()> {
|
||||
// update status and timestamps atomically to avoid race conditions
|
||||
let now = SystemTime::now();
|
||||
@@ -759,6 +760,7 @@ impl HealTask {
|
||||
|
||||
// specific heal implementation method
|
||||
#[tracing::instrument(skip(self), fields(bucket = %bucket, object = %object, version_id = ?version_id))]
|
||||
#[hotpath::measure]
|
||||
async fn heal_object(&self, bucket: &str, object: &str, version_id: Option<&str>) -> Result<()> {
|
||||
debug!(
|
||||
target: "rustfs::heal::task",
|
||||
@@ -1404,6 +1406,7 @@ impl HealTask {
|
||||
self.heal_bucket_objects(bucket, prefix).await
|
||||
}
|
||||
|
||||
#[hotpath::measure]
|
||||
async fn heal_bucket_objects(&self, bucket: &str, prefix: &str) -> Result<()> {
|
||||
let mut continuation_token: Option<String> = None;
|
||||
let mut scanned = 0u64;
|
||||
|
||||
@@ -28,7 +28,55 @@ documentation = "https://docs.rs/rustfs-iam/latest/rustfs_iam/"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
"hotpath/futures",
|
||||
"hotpath/reqwest-0-13",
|
||||
"rustfs-config/hotpath",
|
||||
"rustfs-credentials/hotpath",
|
||||
"rustfs-crypto/hotpath",
|
||||
"rustfs-ecstore/hotpath",
|
||||
"rustfs-io-metrics/hotpath",
|
||||
"rustfs-madmin/hotpath",
|
||||
"rustfs-policy/hotpath",
|
||||
"rustfs-storage-api/hotpath",
|
||||
"rustfs-utils/hotpath",
|
||||
"rustfs-test-utils/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-config/hotpath-alloc",
|
||||
"rustfs-credentials/hotpath-alloc",
|
||||
"rustfs-crypto/hotpath-alloc",
|
||||
"rustfs-ecstore/hotpath-alloc",
|
||||
"rustfs-io-metrics/hotpath-alloc",
|
||||
"rustfs-madmin/hotpath-alloc",
|
||||
"rustfs-policy/hotpath-alloc",
|
||||
"rustfs-storage-api/hotpath-alloc",
|
||||
"rustfs-utils/hotpath-alloc",
|
||||
"rustfs-test-utils/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-cpu",
|
||||
"rustfs-config/hotpath-cpu",
|
||||
"rustfs-credentials/hotpath-cpu",
|
||||
"rustfs-crypto/hotpath-cpu",
|
||||
"rustfs-ecstore/hotpath-cpu",
|
||||
"rustfs-io-metrics/hotpath-cpu",
|
||||
"rustfs-madmin/hotpath-cpu",
|
||||
"rustfs-policy/hotpath-cpu",
|
||||
"rustfs-storage-api/hotpath-cpu",
|
||||
"rustfs-utils/hotpath-cpu",
|
||||
"rustfs-test-utils/hotpath-cpu",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
rustfs-credentials = { workspace = true }
|
||||
rustfs-config = { workspace = true, features = ["server-config-model"] }
|
||||
tokio = { workspace = true, features = ["fs", "rt-multi-thread"] }
|
||||
|
||||
+58
-27
@@ -1139,8 +1139,11 @@ impl OidcSys {
|
||||
let mut policies = Vec::new();
|
||||
let mut groups = Vec::new();
|
||||
|
||||
// Add default role policy if configured
|
||||
if !config.role_policy.is_empty() {
|
||||
// Role-policy and claim-based authorization are separate OIDC modes. When a
|
||||
// role policy is configured, group claims still provide group context but
|
||||
// must not also become policy names.
|
||||
let has_role_policy = !config.role_policy.trim().is_empty();
|
||||
if has_role_policy {
|
||||
for policy in config.role_policy.split(',') {
|
||||
let policy = policy.trim();
|
||||
if !policy.is_empty() {
|
||||
@@ -1149,21 +1152,20 @@ impl OidcSys {
|
||||
}
|
||||
}
|
||||
|
||||
// Map groups claim to policies
|
||||
for group in &claims.groups {
|
||||
groups.push(group.clone());
|
||||
let policy_name = if config.claim_prefix.is_empty() {
|
||||
group.clone()
|
||||
} else {
|
||||
format!("{}{}", config.claim_prefix, group)
|
||||
};
|
||||
policies.push(policy_name);
|
||||
if !has_role_policy {
|
||||
let policy_name = if config.claim_prefix.is_empty() {
|
||||
group.clone()
|
||||
} else {
|
||||
format!("{}{}", config.claim_prefix, group)
|
||||
};
|
||||
policies.push(policy_name);
|
||||
}
|
||||
}
|
||||
|
||||
// Map primary claim (if different from groups)
|
||||
if config.claim_name != config.groups_claim {
|
||||
let claim_values = extract_groups_claim(&claims.raw, &config.claim_name);
|
||||
for val in claim_values {
|
||||
if !has_role_policy && config.claim_name != config.groups_claim {
|
||||
for val in extract_groups_claim(&claims.raw, &config.claim_name) {
|
||||
let policy_name = if config.claim_prefix.is_empty() {
|
||||
val
|
||||
} else {
|
||||
@@ -3201,26 +3203,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_claims_to_policies_with_provider() {
|
||||
let mut config = test_config("okta");
|
||||
config.role_policy = "readwrite".to_string();
|
||||
config.display_name = "Okta".to_string();
|
||||
fn role_policy_does_not_map_groups_as_policies() {
|
||||
let mut config = test_config("authentik");
|
||||
config.role_policy = "consoleAdmin".to_string();
|
||||
config.claim_name = "policy".to_string();
|
||||
|
||||
let sys = make_test_sys(vec![config]);
|
||||
|
||||
let claims = OidcClaims {
|
||||
sub: "user123".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
username: "user".to_string(),
|
||||
groups: vec!["admin".to_string(), "devs".to_string()],
|
||||
raw: HashMap::new(),
|
||||
groups: vec!["authentik Admins".to_string(), "users".to_string()],
|
||||
raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (policies, groups) = sys.map_claims_to_policies("okta", &claims);
|
||||
assert_eq!(groups, vec!["admin", "devs"]);
|
||||
assert!(policies.contains(&"readwrite".to_string()));
|
||||
assert!(policies.contains(&"admin".to_string()));
|
||||
assert!(policies.contains(&"devs".to_string()));
|
||||
let (policies, groups) = sys.map_claims_to_policies("authentik", &claims);
|
||||
assert_eq!(groups, vec!["authentik Admins", "users"]);
|
||||
assert_eq!(policies, vec!["consoleAdmin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3245,6 +3243,39 @@ mod tests {
|
||||
assert_eq!(policies.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_role_policy_uses_claim_mapping() {
|
||||
let mut config = test_config("keycloak");
|
||||
config.role_policy = " ".to_string();
|
||||
|
||||
let sys = make_test_sys(vec![config]);
|
||||
let claims = OidcClaims {
|
||||
groups: vec!["readonly".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims);
|
||||
assert_eq!(groups, vec!["readonly"]);
|
||||
assert_eq!(policies, vec!["readonly"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claim_mapping_keeps_groups_with_distinct_primary_claim() {
|
||||
let mut config = test_config("keycloak");
|
||||
config.claim_name = "policy".to_string();
|
||||
|
||||
let sys = make_test_sys(vec![config]);
|
||||
let claims = OidcClaims {
|
||||
groups: vec!["developers".to_string()],
|
||||
raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims);
|
||||
assert_eq!(groups, vec!["developers"]);
|
||||
assert_eq!(policies, vec!["developers", "readonly"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_providers() {
|
||||
let mut config = test_config("keycloak");
|
||||
|
||||
@@ -469,6 +469,7 @@ impl ObjectStore {
|
||||
});
|
||||
}
|
||||
|
||||
#[hotpath::measure]
|
||||
async fn list_all_iamconfig_items(&self) -> Result<HashMap<String, Vec<String>>> {
|
||||
let (tx, mut rx) = mpsc::channel::<StringOrErr>(100);
|
||||
|
||||
@@ -508,6 +509,7 @@ impl ObjectStore {
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
#[hotpath::measure]
|
||||
async fn load_policy_doc_concurrent(&self, names: &[String], mode: LoadMode) -> Result<Vec<PolicyDoc>> {
|
||||
let mut futures = Vec::with_capacity(names.len());
|
||||
|
||||
|
||||
@@ -27,7 +27,14 @@ categories = ["development-tools", "filesystem"]
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = ["hotpath/hotpath", "hotpath/tokio", "rustfs-io-metrics/hotpath"]
|
||||
hotpath-alloc = ["hotpath", "hotpath/hotpath-alloc", "rustfs-io-metrics/hotpath-alloc"]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-io-metrics/hotpath-cpu"]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
bytes = { workspace = true, features = ["serde"] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["io-util", "fs", "sync", "rt-multi-thread"] }
|
||||
|
||||
@@ -28,9 +28,36 @@ categories = ["development-tools", "filesystem"]
|
||||
name = "metrics_pipeline"
|
||||
harness = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
"rustfs-common/hotpath",
|
||||
"rustfs-s3-ops/hotpath",
|
||||
"rustfs-utils/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-common/hotpath-alloc",
|
||||
"rustfs-s3-ops/hotpath-alloc",
|
||||
"rustfs-utils/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-cpu",
|
||||
"rustfs-common/hotpath-cpu",
|
||||
"rustfs-s3-ops/hotpath-cpu",
|
||||
"rustfs-utils/hotpath-cpu",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
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.
|
||||
|
||||
@@ -28,7 +28,34 @@ authors.workspace = true
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
"hotpath/futures",
|
||||
"hotpath/reqwest-0-13",
|
||||
"rustfs-credentials/hotpath",
|
||||
"rustfs-policy/hotpath",
|
||||
"rustfs-utils/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-credentials/hotpath-alloc",
|
||||
"rustfs-policy/hotpath-alloc",
|
||||
"rustfs-utils/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-cpu",
|
||||
"rustfs-credentials/hotpath-cpu",
|
||||
"rustfs-policy/hotpath-cpu",
|
||||
"rustfs-utils/hotpath-cpu",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "sync"] }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -95,6 +95,7 @@ impl KeystoneClient {
|
||||
}
|
||||
|
||||
/// Validate a Keystone token
|
||||
#[hotpath::measure]
|
||||
pub async fn validate_token(&self, token: &str) -> Result<KeystoneToken> {
|
||||
match self.version {
|
||||
KeystoneVersion::V3 => self.validate_token_v3(token).await,
|
||||
@@ -238,6 +239,7 @@ impl KeystoneClient {
|
||||
}
|
||||
|
||||
/// Get EC2 credentials for a user
|
||||
#[hotpath::measure]
|
||||
pub async fn get_ec2_credentials(&self, user_id: &str, project_id: Option<&str>) -> Result<Vec<EC2Credential>> {
|
||||
let admin_token = self.get_admin_token().await?;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -28,6 +28,7 @@ categories = ["cryptography", "web-programming", "authentication"]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
hotpath.workspace = true
|
||||
# Core dependencies
|
||||
async-trait = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "macros", "rt-multi-thread", "sync", "time"] }
|
||||
@@ -37,6 +38,8 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
# Operation metrics emitted by the retry policy engine (crate::policy).
|
||||
metrics = { workspace = true }
|
||||
|
||||
# Cryptography
|
||||
aes-gcm = { workspace = true, features = ["rand_core"] }
|
||||
@@ -65,11 +68,34 @@ 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 }
|
||||
# Debugging recorder for asserting emitted metrics in tests.
|
||||
metrics-util = { version = "0.20", features = ["debugging"] }
|
||||
insta = { workspace = true, features = ["yaml", "json"] }
|
||||
tempfile = { workspace = true }
|
||||
temp-env = { workspace = true }
|
||||
# "net" backs the scripted loopback Vault used by the policy wiring tests.
|
||||
tokio = { workspace = true, features = ["net", "test-util"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
hotpath = [
|
||||
"hotpath/hotpath",
|
||||
"hotpath/tokio",
|
||||
"hotpath/reqwest-0-13",
|
||||
"rustfs-security-governance/hotpath",
|
||||
"rustfs-utils/hotpath",
|
||||
]
|
||||
hotpath-alloc = [
|
||||
"hotpath",
|
||||
"hotpath/hotpath-alloc",
|
||||
"rustfs-security-governance/hotpath-alloc",
|
||||
"rustfs-utils/hotpath-alloc",
|
||||
]
|
||||
hotpath-cpu = ["hotpath", "hotpath/hotpath-cpu", "rustfs-security-governance/hotpath-cpu", "rustfs-utils/hotpath-cpu"]
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// 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.
|
||||
|
||||
//! Shared key state × operation contract tests for KMS backends.
|
||||
//!
|
||||
//! Every stateful backend must satisfy the same lifecycle matrix (see
|
||||
//! `ensure_key_state_permits`): Enabled permits everything, Disabled permits
|
||||
//! decryption and lifecycle recovery but rejects new cryptographic use, and
|
||||
//! PendingDeletion rejects everything except decryption and cancellation.
|
||||
//! Decryption staying available in Disabled/PendingDeletion is an explicit,
|
||||
//! tested deviation from AWS KMS: disabling a key must not break reads of
|
||||
//! objects already encrypted under it.
|
||||
//!
|
||||
//! The full matrix runs offline against the Local backend. The Vault KV2 and
|
||||
//! Vault Transit runs exercise the same helper but need a live Vault dev
|
||||
//! server, so they are `#[ignore]`d in CI. Static is covered by its own
|
||||
//! stateless contract below.
|
||||
|
||||
use super::KmsBackend;
|
||||
use super::local::LocalKmsBackend;
|
||||
use super::static_kms::StaticKmsBackend;
|
||||
use super::vault::VaultKmsBackend;
|
||||
use super::vault_transit::VaultTransitKmsBackend;
|
||||
use crate::config::KmsConfig;
|
||||
use crate::error::{KmsError, Result};
|
||||
use crate::manager::KmsManager;
|
||||
use crate::service::ObjectEncryptionService;
|
||||
use crate::types::{
|
||||
CancelKeyDeletionRequest, CreateKeyRequest, DecryptRequest, DeleteKeyRequest, DescribeKeyRequest, EncryptRequest,
|
||||
GenerateDataKeyRequest, KeySpec, KeyState, KeyUsage, ObjectEncryptionContext,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use rand::RngExt as _;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn expect_unsupported<T: std::fmt::Debug>(result: Result<T>) {
|
||||
match result {
|
||||
Err(KmsError::UnsupportedCapability { .. }) => {}
|
||||
other => panic!("expected UnsupportedCapability, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotation while not Enabled: backends with rotation support must reject it
|
||||
/// through the state machine; backends without it report the capability gap.
|
||||
async fn expect_rotate_rejected(backend: &dyn KmsBackend, key_id: &str) {
|
||||
let result = backend.rotate_key(key_id).await;
|
||||
if backend.capabilities().rotate {
|
||||
expect_invalid_key_state(result, "");
|
||||
} else {
|
||||
expect_unsupported(result);
|
||||
}
|
||||
}
|
||||
|
||||
fn expect_invalid_key_state<T: std::fmt::Debug>(result: Result<T>, expected_fragment: &str) {
|
||||
match result {
|
||||
Err(KmsError::InvalidOperation { message }) => assert!(
|
||||
message.contains(expected_fragment),
|
||||
"expected invalid-key-state message containing {expected_fragment:?}, got {message:?}"
|
||||
),
|
||||
other => panic!("expected InvalidOperation (invalid key state), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn context() -> HashMap<String, String> {
|
||||
HashMap::from([("bucket".to_string(), "contract".to_string())])
|
||||
}
|
||||
|
||||
fn generate_request(key_id: &str) -> GenerateDataKeyRequest {
|
||||
GenerateDataKeyRequest {
|
||||
key_id: key_id.to_string(),
|
||||
key_spec: KeySpec::Aes256,
|
||||
encryption_context: context(),
|
||||
}
|
||||
}
|
||||
|
||||
fn encrypt_request(key_id: &str) -> EncryptRequest {
|
||||
EncryptRequest {
|
||||
key_id: key_id.to_string(),
|
||||
plaintext: b"contract-plaintext".to_vec(),
|
||||
encryption_context: context(),
|
||||
grant_tokens: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn decrypt_request(ciphertext: Vec<u8>) -> DecryptRequest {
|
||||
DecryptRequest {
|
||||
ciphertext,
|
||||
encryption_context: context(),
|
||||
grant_tokens: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn schedule_request(key_id: &str) -> DeleteKeyRequest {
|
||||
DeleteKeyRequest {
|
||||
key_id: key_id.to_string(),
|
||||
pending_window_in_days: Some(7),
|
||||
force_immediate: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_request(key_id: &str) -> CancelKeyDeletionRequest {
|
||||
CancelKeyDeletionRequest {
|
||||
key_id: key_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_request(key_name: String) -> CreateKeyRequest {
|
||||
CreateKeyRequest {
|
||||
key_name: Some(key_name),
|
||||
key_usage: KeyUsage::EncryptDecrypt,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_key_state(backend: &dyn KmsBackend, key_id: &str, expected: KeyState) {
|
||||
let described = backend
|
||||
.describe_key(DescribeKeyRequest {
|
||||
key_id: key_id.to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("describe_key must succeed for an existing key");
|
||||
assert_eq!(described.key_metadata.key_state, expected, "unexpected state for key {key_id}");
|
||||
}
|
||||
|
||||
/// Drives one freshly created (Enabled) key through the full state matrix,
|
||||
/// entirely through the `KmsBackend` product surface.
|
||||
async fn assert_state_machine_contract(backend: &dyn KmsBackend, key_id: &str) {
|
||||
// Enabled: cryptographic use is allowed. Keep an envelope around to prove
|
||||
// decryption keeps working in later states.
|
||||
let data_key = backend
|
||||
.generate_data_key(generate_request(key_id))
|
||||
.await
|
||||
.expect("Enabled key must generate data keys");
|
||||
backend
|
||||
.encrypt(encrypt_request(key_id))
|
||||
.await
|
||||
.expect("Enabled key must encrypt");
|
||||
|
||||
// Enabled -> Disabled.
|
||||
backend.disable_key(key_id).await.expect("disable from Enabled must succeed");
|
||||
assert_key_state(backend, key_id, KeyState::Disabled).await;
|
||||
|
||||
// Disabled: new cryptographic use and rotation are rejected...
|
||||
expect_invalid_key_state(backend.encrypt(encrypt_request(key_id)).await, "disabled");
|
||||
expect_invalid_key_state(backend.generate_data_key(generate_request(key_id)).await, "disabled");
|
||||
expect_rotate_rejected(backend, key_id).await;
|
||||
// ...but decryption of existing data keeps working (explicit AWS deviation)...
|
||||
let decrypted = backend
|
||||
.decrypt(decrypt_request(data_key.ciphertext_blob.clone()))
|
||||
.await
|
||||
.expect("decrypt with a disabled key must keep working");
|
||||
assert_eq!(decrypted.plaintext, data_key.plaintext_key, "decrypt must recover the original data key");
|
||||
// ...disable stays idempotent, cancel has nothing to cancel, and enable recovers.
|
||||
backend.disable_key(key_id).await.expect("disable must be idempotent");
|
||||
expect_invalid_key_state(backend.cancel_key_deletion(cancel_request(key_id)).await, "not pending deletion");
|
||||
backend.enable_key(key_id).await.expect("enable from Disabled must succeed");
|
||||
assert_key_state(backend, key_id, KeyState::Enabled).await;
|
||||
|
||||
// Disabled keys may still be scheduled for deletion.
|
||||
backend
|
||||
.disable_key(key_id)
|
||||
.await
|
||||
.expect("disable before scheduling must succeed");
|
||||
backend
|
||||
.delete_key(schedule_request(key_id))
|
||||
.await
|
||||
.expect("scheduling deletion of a disabled key must succeed");
|
||||
assert_key_state(backend, key_id, KeyState::PendingDeletion).await;
|
||||
|
||||
// PendingDeletion: everything except decryption and cancellation is rejected.
|
||||
expect_invalid_key_state(backend.encrypt(encrypt_request(key_id)).await, "pending deletion");
|
||||
expect_invalid_key_state(backend.generate_data_key(generate_request(key_id)).await, "pending deletion");
|
||||
expect_invalid_key_state(backend.enable_key(key_id).await, "pending deletion");
|
||||
expect_invalid_key_state(backend.disable_key(key_id).await, "pending deletion");
|
||||
expect_rotate_rejected(backend, key_id).await;
|
||||
expect_invalid_key_state(backend.delete_key(schedule_request(key_id)).await, "pending deletion");
|
||||
let decrypted = backend
|
||||
.decrypt(decrypt_request(data_key.ciphertext_blob.clone()))
|
||||
.await
|
||||
.expect("decrypt with a pending-deletion key must keep working");
|
||||
assert_eq!(decrypted.plaintext, data_key.plaintext_key);
|
||||
|
||||
// PendingDeletion -> Enabled through cancellation.
|
||||
backend
|
||||
.cancel_key_deletion(cancel_request(key_id))
|
||||
.await
|
||||
.expect("cancel from PendingDeletion must succeed");
|
||||
assert_key_state(backend, key_id, KeyState::Enabled).await;
|
||||
backend
|
||||
.generate_data_key(generate_request(key_id))
|
||||
.await
|
||||
.expect("cancelled key must be usable again");
|
||||
|
||||
// Cancel without a pending deletion is an invalid state transition.
|
||||
expect_invalid_key_state(backend.cancel_key_deletion(cancel_request(key_id)).await, "not pending deletion");
|
||||
}
|
||||
|
||||
async fn local_fixture() -> (tempfile::TempDir, KmsConfig, LocalKmsBackend, String) {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let config = KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults();
|
||||
let backend = LocalKmsBackend::new(config.clone())
|
||||
.await
|
||||
.expect("local backend should build");
|
||||
let created = backend
|
||||
.create_key(create_request("contract-key".to_string()))
|
||||
.await
|
||||
.expect("key should be created");
|
||||
(temp_dir, config, backend, created.key_id)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_backend_state_machine_contract() {
|
||||
let (_temp_dir, _config, backend, key_id) = local_fixture().await;
|
||||
assert_state_machine_contract(&backend, &key_id).await;
|
||||
}
|
||||
|
||||
/// SSE-shaped regression: disabling a key must not break decryption of data
|
||||
/// keys created while it was enabled, while new data key creation must fail.
|
||||
#[tokio::test]
|
||||
async fn local_disabled_key_keeps_decrypting_existing_envelopes() {
|
||||
let (_temp_dir, config, backend, key_id) = local_fixture().await;
|
||||
let backend = Arc::new(backend);
|
||||
let service = ObjectEncryptionService::new(KmsManager::new(backend.clone(), config));
|
||||
|
||||
let object_context = ObjectEncryptionContext::new("sse-bucket".to_string(), "dir/object.bin".to_string());
|
||||
let kms_key = Some(key_id.clone());
|
||||
let (_data_key, encrypted_blob) = service
|
||||
.create_data_key(&kms_key, &object_context)
|
||||
.await
|
||||
.expect("data key creation must succeed while the key is enabled");
|
||||
|
||||
backend
|
||||
.lifecycle_client()
|
||||
.disable_key(&key_id, None)
|
||||
.await
|
||||
.expect("disable must succeed");
|
||||
|
||||
service
|
||||
.decrypt_data_key(&encrypted_blob, &object_context)
|
||||
.await
|
||||
.expect("existing objects must stay readable after their KMS key is disabled");
|
||||
expect_invalid_key_state(service.create_data_key(&kms_key, &object_context).await, "disabled");
|
||||
}
|
||||
|
||||
/// Static is a stateless read-only backend: cryptographic operations always
|
||||
/// work against the single configured key and every lifecycle mutation is
|
||||
/// rejected as an invalid operation.
|
||||
#[tokio::test]
|
||||
async fn static_backend_stateless_contract() {
|
||||
let key_id = "static-contract-key";
|
||||
let mut raw_key = [0u8; 32];
|
||||
rand::rng().fill(&mut raw_key[..]);
|
||||
let config = KmsConfig::static_kms(key_id.to_string(), BASE64.encode(raw_key));
|
||||
let static_backend = StaticKmsBackend::new(config).await.expect("static backend should build");
|
||||
let backend: &dyn KmsBackend = &static_backend;
|
||||
|
||||
let data_key = backend
|
||||
.generate_data_key(generate_request(key_id))
|
||||
.await
|
||||
.expect("static backend must generate data keys");
|
||||
let decrypted = backend
|
||||
.decrypt(decrypt_request(data_key.ciphertext_blob.clone()))
|
||||
.await
|
||||
.expect("static backend must decrypt its own envelopes");
|
||||
assert_eq!(decrypted.plaintext, data_key.plaintext_key);
|
||||
assert_key_state(backend, key_id, KeyState::Enabled).await;
|
||||
|
||||
expect_invalid_key_state(backend.create_key(create_request("another-key".to_string())).await, "read-only");
|
||||
expect_invalid_key_state(backend.delete_key(schedule_request(key_id)).await, "read-only");
|
||||
expect_invalid_key_state(backend.cancel_key_deletion(cancel_request(key_id)).await, "read-only");
|
||||
// Enable/disable and rotation are capability gaps at the product
|
||||
// surface, not state-machine rejections.
|
||||
expect_unsupported(backend.enable_key(key_id).await);
|
||||
expect_unsupported(backend.disable_key(key_id).await);
|
||||
expect_unsupported(backend.rotate_key(key_id).await);
|
||||
}
|
||||
|
||||
fn vault_dev_config(constructor: fn(url::Url, String) -> KmsConfig) -> KmsConfig {
|
||||
let address = std::env::var("RUSTFS_KMS_VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string());
|
||||
let token = std::env::var("RUSTFS_KMS_VAULT_TOKEN").unwrap_or_else(|_| "dev-token".to_string());
|
||||
let mut config = constructor(url::Url::parse(&address).expect("vault address should parse"), token);
|
||||
config.allow_insecure_dev_defaults = true;
|
||||
config
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires a running Vault instance (dev mode) with a KV2 mount
|
||||
async fn vault_kv2_backend_state_machine_contract() {
|
||||
let config = vault_dev_config(KmsConfig::vault);
|
||||
let backend = VaultKmsBackend::new(config).await.expect("vault kv2 backend should build");
|
||||
let created = backend
|
||||
.create_key(create_request(format!("contract-{}", uuid::Uuid::new_v4())))
|
||||
.await
|
||||
.expect("key should be created");
|
||||
|
||||
assert_state_machine_contract(&backend, &created.key_id).await;
|
||||
|
||||
// KV2 additionally supports version-retaining rotation, which must only
|
||||
// work while the key is Enabled (the shared matrix covered the
|
||||
// rejections).
|
||||
backend
|
||||
.rotate_key(&created.key_id)
|
||||
.await
|
||||
.expect("rotation of an Enabled KV2 key must succeed");
|
||||
|
||||
// Cleanup: leave the key pending deletion so repeated runs stay tidy.
|
||||
let _ = backend.delete_key(schedule_request(&created.key_id)).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires a running Vault instance (dev mode) with the transit engine enabled
|
||||
async fn vault_transit_backend_state_machine_contract() {
|
||||
let config = vault_dev_config(KmsConfig::vault_transit);
|
||||
let backend = VaultTransitKmsBackend::new(config)
|
||||
.await
|
||||
.expect("vault transit backend should build");
|
||||
let created = backend
|
||||
.create_key(create_request(format!("contract-{}", uuid::Uuid::new_v4())))
|
||||
.await
|
||||
.expect("key should be created");
|
||||
|
||||
assert_state_machine_contract(&backend, &created.key_id).await;
|
||||
|
||||
// Transit additionally supports rotation, which must only work while the
|
||||
// key is Enabled (the shared matrix already covered the rejections).
|
||||
backend
|
||||
.rotate_key(&created.key_id)
|
||||
.await
|
||||
.expect("rotation of an Enabled transit key must succeed");
|
||||
|
||||
let _ = backend.delete_key(schedule_request(&created.key_id)).await;
|
||||
}
|
||||
+1526
-109
File diff suppressed because it is too large
Load Diff
+377
-169
@@ -14,144 +14,88 @@
|
||||
|
||||
//! KMS backend implementations
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::error::{KmsError, Result};
|
||||
use crate::types::*;
|
||||
use async_trait::async_trait;
|
||||
use std::collections::HashMap;
|
||||
use jiff::Zoned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(test)]
|
||||
mod contract_tests;
|
||||
pub mod local;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod scripted_vault;
|
||||
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
|
||||
#[async_trait]
|
||||
pub trait KmsClient: Send + Sync {
|
||||
/// Generate a new data encryption key (DEK)
|
||||
///
|
||||
/// Creates a new data key using the specified master key. The returned DataKey
|
||||
/// contains both the plaintext and encrypted versions of the key.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - The key generation request
|
||||
/// * `context` - Optional operation context for auditing
|
||||
///
|
||||
/// # Returns
|
||||
/// Returns a DataKey containing both plaintext and encrypted key material
|
||||
async fn generate_data_key(&self, request: &GenerateKeyRequest, context: Option<&OperationContext>) -> Result<DataKeyInfo>;
|
||||
/// Operations whose availability depends on the key's lifecycle state.
|
||||
///
|
||||
/// Decryption is deliberately absent: RustFS allows decryption with
|
||||
/// `Disabled` and `PendingDeletion` keys — an explicit deviation from AWS
|
||||
/// KMS — because rejecting it would break reads of every object encrypted
|
||||
/// under a key the moment it is disabled. Deletion cancellation is also
|
||||
/// absent: it is valid exactly when the key is `PendingDeletion`, which call
|
||||
/// sites enforce directly.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum StateGatedOperation {
|
||||
Encrypt,
|
||||
GenerateDataKey,
|
||||
Rotate,
|
||||
Enable,
|
||||
Disable,
|
||||
ScheduleDeletion,
|
||||
}
|
||||
|
||||
/// Encrypt data directly using a master key
|
||||
///
|
||||
/// Encrypts the provided plaintext using the specified master key.
|
||||
/// This is different from generate_data_key as it encrypts user data directly.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - The encryption request containing plaintext and key ID
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn encrypt(&self, request: &EncryptRequest, context: Option<&OperationContext>) -> Result<EncryptResponse>;
|
||||
impl StateGatedOperation {
|
||||
fn describe(self) -> &'static str {
|
||||
match self {
|
||||
Self::Encrypt => "encryption",
|
||||
Self::GenerateDataKey => "data key generation",
|
||||
Self::Rotate => "rotation",
|
||||
Self::Enable => "enabling",
|
||||
Self::Disable => "disabling",
|
||||
Self::ScheduleDeletion => "deletion scheduling",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypt data using a master key
|
||||
///
|
||||
/// Decrypts the provided ciphertext. The KMS automatically determines
|
||||
/// which key was used for encryption based on the ciphertext metadata.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - The decryption request containing ciphertext
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn decrypt(&self, request: &DecryptRequest, context: Option<&OperationContext>) -> Result<Vec<u8>>;
|
||||
/// Enforce the shared key state × operation matrix.
|
||||
///
|
||||
/// - `Enabled`: every operation is allowed.
|
||||
/// - `Disabled`: enabling, disabling (idempotent) and deletion scheduling are
|
||||
/// allowed; encryption, data key generation and rotation are rejected.
|
||||
/// - `PendingDeletion`: every state-gated operation is rejected, including a
|
||||
/// repeated deletion schedule; only cancellation and decryption proceed.
|
||||
/// - `PendingImport`/`Unavailable`: the key is not usable and is reported as
|
||||
/// not found.
|
||||
pub(crate) fn ensure_key_state_permits(key_id: &str, state: &KeyState, operation: StateGatedOperation) -> Result<()> {
|
||||
match state {
|
||||
KeyState::Enabled => Ok(()),
|
||||
KeyState::Disabled => match operation {
|
||||
StateGatedOperation::Enable | StateGatedOperation::Disable | StateGatedOperation::ScheduleDeletion => Ok(()),
|
||||
StateGatedOperation::Encrypt | StateGatedOperation::GenerateDataKey | StateGatedOperation::Rotate => Err(
|
||||
KmsError::invalid_key_state(format!("Key {key_id} is disabled: {} is not allowed", operation.describe())),
|
||||
),
|
||||
},
|
||||
KeyState::PendingDeletion => Err(KmsError::invalid_key_state(format!(
|
||||
"Key {key_id} is pending deletion: {} is not allowed",
|
||||
operation.describe()
|
||||
))),
|
||||
KeyState::PendingImport | KeyState::Unavailable => Err(KmsError::key_not_found(key_id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new master key
|
||||
///
|
||||
/// Creates a new master key in the KMS with the specified ID.
|
||||
/// Returns an error if a key with the same ID already exists.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key_id` - Unique identifier for the new key
|
||||
/// * `algorithm` - Key algorithm (e.g., "AES_256")
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn create_key(&self, key_id: &str, algorithm: &str, context: Option<&OperationContext>) -> Result<MasterKeyInfo>;
|
||||
|
||||
/// Get information about a specific key
|
||||
///
|
||||
/// Returns metadata and information about the specified key.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key_id` - The key identifier
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn describe_key(&self, key_id: &str, context: Option<&OperationContext>) -> Result<KeyInfo>;
|
||||
|
||||
/// List available keys
|
||||
///
|
||||
/// Returns a paginated list of keys available in the KMS.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - List request parameters (pagination, filters)
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn list_keys(&self, request: &ListKeysRequest, context: Option<&OperationContext>) -> Result<ListKeysResponse>;
|
||||
|
||||
/// Enable a key
|
||||
///
|
||||
/// Enables a previously disabled key, allowing it to be used for cryptographic operations.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key_id` - The key identifier
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn enable_key(&self, key_id: &str, context: Option<&OperationContext>) -> Result<()>;
|
||||
|
||||
/// Disable a key
|
||||
///
|
||||
/// Disables a key, preventing it from being used for new cryptographic operations.
|
||||
/// Existing encrypted data can still be decrypted.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key_id` - The key identifier
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn disable_key(&self, key_id: &str, context: Option<&OperationContext>) -> Result<()>;
|
||||
|
||||
/// Schedule key deletion
|
||||
///
|
||||
/// Schedules a key for deletion after a specified number of days.
|
||||
/// This allows for a grace period to recover the key if needed.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key_id` - The key identifier
|
||||
/// * `pending_window_days` - Number of days before actual deletion
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn schedule_key_deletion(
|
||||
&self,
|
||||
key_id: &str,
|
||||
pending_window_days: u32,
|
||||
context: Option<&OperationContext>,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Cancel key deletion
|
||||
///
|
||||
/// Cancels a previously scheduled key deletion.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key_id` - The key identifier
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn cancel_key_deletion(&self, key_id: &str, context: Option<&OperationContext>) -> Result<()>;
|
||||
|
||||
/// Rotate a key
|
||||
///
|
||||
/// Creates a new version of the specified key. Previous versions remain
|
||||
/// available for decryption but new operations will use the new version.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key_id` - The key identifier
|
||||
/// * `context` - Optional operation context for auditing
|
||||
async fn rotate_key(&self, key_id: &str, context: Option<&OperationContext>) -> Result<MasterKeyInfo>;
|
||||
|
||||
/// Health check
|
||||
///
|
||||
/// Performs a health check on the KMS backend to ensure it's operational.
|
||||
async fn health_check(&self) -> Result<()>;
|
||||
|
||||
/// Get backend information
|
||||
///
|
||||
/// Returns information about the KMS backend (type, version, etc.).
|
||||
fn backend_info(&self) -> BackendInfo;
|
||||
/// [`ensure_key_state_permits`] for backends that persist [`KeyStatus`].
|
||||
pub(crate) fn ensure_key_status_permits(key_id: &str, status: &KeyStatus, operation: StateGatedOperation) -> Result<()> {
|
||||
let state = match status {
|
||||
KeyStatus::Active => KeyState::Enabled,
|
||||
KeyStatus::Disabled => KeyState::Disabled,
|
||||
KeyStatus::PendingDeletion => KeyState::PendingDeletion,
|
||||
KeyStatus::Deleted => KeyState::Unavailable,
|
||||
};
|
||||
ensure_key_state_permits(key_id, &state, operation)
|
||||
}
|
||||
|
||||
/// Simplified KMS backend interface for manager
|
||||
@@ -181,58 +125,322 @@ pub trait KmsBackend: Send + Sync {
|
||||
/// Cancel key deletion
|
||||
async fn cancel_key_deletion(&self, request: CancelKeyDeletionRequest) -> Result<CancelKeyDeletionResponse>;
|
||||
|
||||
/// Enable a disabled key so it can be used for cryptographic operations
|
||||
/// again.
|
||||
///
|
||||
/// Backends that advertise [`BackendCapabilities::enable_disable`] must
|
||||
/// override this method; the default rejects the operation.
|
||||
async fn enable_key(&self, _key_id: &str) -> Result<()> {
|
||||
Err(KmsError::unsupported_capability("backend without enable/disable support", "enable_key"))
|
||||
}
|
||||
|
||||
/// Disable a key, rejecting new cryptographic use while existing data
|
||||
/// remains decryptable.
|
||||
///
|
||||
/// Backends that advertise [`BackendCapabilities::enable_disable`] must
|
||||
/// override this method; the default rejects the operation.
|
||||
async fn disable_key(&self, _key_id: &str) -> Result<()> {
|
||||
Err(KmsError::unsupported_capability("backend without enable/disable support", "disable_key"))
|
||||
}
|
||||
|
||||
/// Rotate a key to a new version while prior versions remain available
|
||||
/// for decryption.
|
||||
///
|
||||
/// Only backends that advertise [`BackendCapabilities::rotate`] (that is,
|
||||
/// backends with retained version history) may override this method; the
|
||||
/// default rejects the operation.
|
||||
async fn rotate_key(&self, _key_id: &str) -> Result<()> {
|
||||
Err(KmsError::unsupported_capability("backend without rotation support", "rotate_key"))
|
||||
}
|
||||
|
||||
/// Health check
|
||||
async fn health_check(&self) -> Result<bool>;
|
||||
|
||||
/// Report which operations this backend actually supports.
|
||||
///
|
||||
/// The default is conservative: only the operations every backend is
|
||||
/// required to implement by this trait are advertised. Optional lifecycle
|
||||
/// operations (rotation, enable/disable, deletion scheduling, ...) must be
|
||||
/// opted in by overriding this method.
|
||||
fn capabilities(&self) -> BackendCapabilities {
|
||||
BackendCapabilities::minimal()
|
||||
}
|
||||
|
||||
/// Remove a key whose scheduled deletion deadline has passed.
|
||||
///
|
||||
/// Used by the background deletion worker. Implementations must re-check
|
||||
/// state and deadline under their own write synchronization so that a
|
||||
/// concurrent cancellation observed after the caller's inspection wins
|
||||
/// ([`ExpiredKeyRemoval::StateChanged`]), must write a tombstone (a
|
||||
/// `Deleted`/`Unavailable` record) before destroying material so a crashed
|
||||
/// removal can simply be re-run, and must treat an already-removed key as
|
||||
/// success so the operation stays idempotent across restarts and nodes.
|
||||
///
|
||||
/// The default rejects the operation for backends without deletion
|
||||
/// support.
|
||||
async fn remove_expired_key(&self, _key_id: &str, _now: &Zoned) -> Result<ExpiredKeyRemoval> {
|
||||
Err(KmsError::unsupported_capability("backend without deletion support", "remove_expired_key"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a KMS backend
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackendInfo {
|
||||
/// Backend type name (e.g., "local", "vault")
|
||||
pub backend_type: String,
|
||||
/// Backend version
|
||||
pub version: String,
|
||||
/// Backend endpoint or location
|
||||
pub endpoint: String,
|
||||
/// Whether the backend is currently healthy
|
||||
pub healthy: bool,
|
||||
/// Additional metadata about the backend
|
||||
pub metadata: HashMap<String, String>,
|
||||
/// Outcome of [`KmsBackend::remove_expired_key`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExpiredKeyRemoval {
|
||||
/// The key's record and material were removed, or were already gone.
|
||||
Removed,
|
||||
/// The key is no longer pending deletion (for example the deletion was
|
||||
/// cancelled after the caller inspected it); nothing was removed.
|
||||
StateChanged,
|
||||
/// The key is pending deletion but its deadline has not passed, or it has
|
||||
/// no persisted deadline (legacy record) and is never auto-removed.
|
||||
NotExpired,
|
||||
}
|
||||
|
||||
impl BackendInfo {
|
||||
/// Create a new backend info
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `backend_type` - The type of the backend
|
||||
/// * `version` - The version of the backend
|
||||
/// * `endpoint` - The endpoint or location of the backend
|
||||
/// * `healthy` - Whether the backend is healthy
|
||||
///
|
||||
/// # Returns
|
||||
/// A new BackendInfo instance
|
||||
///
|
||||
pub fn new(backend_type: String, version: String, endpoint: String, healthy: bool) -> Self {
|
||||
/// Set of operations a KMS backend supports.
|
||||
///
|
||||
/// Reported by [`KmsBackend::capabilities`] so callers (manager, admin API)
|
||||
/// can discover what the active backend can do without probing individual
|
||||
/// operations. Marked `#[non_exhaustive]` so new capability flags can be
|
||||
/// added without breaking downstream code; construct values through
|
||||
/// [`BackendCapabilities::minimal`] and the `with_*` builders.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BackendCapabilities {
|
||||
/// Direct encryption of caller-provided plaintext with a master key
|
||||
pub encrypt: bool,
|
||||
/// Decryption of previously produced ciphertext
|
||||
pub decrypt: bool,
|
||||
/// Data encryption key (DEK) generation
|
||||
pub generate_data_key: bool,
|
||||
/// Key rotation that retains prior versions for decryption
|
||||
pub rotate: bool,
|
||||
/// Enabling and disabling keys
|
||||
pub enable_disable: bool,
|
||||
/// Scheduling key deletion with a pending window
|
||||
pub schedule_deletion: bool,
|
||||
/// Multiple key versions addressable after rotation
|
||||
pub versioning: bool,
|
||||
/// Irreversible physical deletion of key material
|
||||
pub physical_delete: bool,
|
||||
}
|
||||
|
||||
impl BackendCapabilities {
|
||||
/// Conservative baseline: only the operations that every [`KmsBackend`]
|
||||
/// implementation is required to provide by the trait. All optional
|
||||
/// lifecycle capabilities default to unsupported.
|
||||
pub const fn minimal() -> Self {
|
||||
Self {
|
||||
backend_type,
|
||||
version,
|
||||
endpoint,
|
||||
healthy,
|
||||
metadata: HashMap::new(),
|
||||
encrypt: true,
|
||||
decrypt: true,
|
||||
generate_data_key: true,
|
||||
rotate: false,
|
||||
enable_disable: false,
|
||||
schedule_deletion: false,
|
||||
versioning: false,
|
||||
physical_delete: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Add metadata to the backend info
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `key` - Metadata key
|
||||
/// * `value` - Metadata value
|
||||
///
|
||||
/// # Returns
|
||||
/// Updated BackendInfo instance
|
||||
///
|
||||
pub fn with_metadata(mut self, key: String, value: String) -> Self {
|
||||
self.metadata.insert(key, value);
|
||||
/// Set whether direct encryption is supported
|
||||
pub const fn with_encrypt(mut self, encrypt: bool) -> Self {
|
||||
self.encrypt = encrypt;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether decryption is supported
|
||||
pub const fn with_decrypt(mut self, decrypt: bool) -> Self {
|
||||
self.decrypt = decrypt;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether data key generation is supported
|
||||
pub const fn with_generate_data_key(mut self, generate_data_key: bool) -> Self {
|
||||
self.generate_data_key = generate_data_key;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether version-retaining key rotation is supported
|
||||
pub const fn with_rotate(mut self, rotate: bool) -> Self {
|
||||
self.rotate = rotate;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether enabling/disabling keys is supported
|
||||
pub const fn with_enable_disable(mut self, enable_disable: bool) -> Self {
|
||||
self.enable_disable = enable_disable;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether scheduled deletion with a pending window is supported
|
||||
pub const fn with_schedule_deletion(mut self, schedule_deletion: bool) -> Self {
|
||||
self.schedule_deletion = schedule_deletion;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether multiple key versions are supported
|
||||
pub const fn with_versioning(mut self, versioning: bool) -> Self {
|
||||
self.versioning = versioning;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set whether physical deletion of key material is supported
|
||||
pub const fn with_physical_delete(mut self, physical_delete: bool) -> Self {
|
||||
self.physical_delete = physical_delete;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BackendCapabilities {
|
||||
fn default() -> Self {
|
||||
Self::minimal()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::KmsConfig;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
/// Backend that implements only the trait-mandated operations and relies
|
||||
/// on the default `capabilities` implementation.
|
||||
struct MinimalBackend;
|
||||
|
||||
#[async_trait]
|
||||
impl KmsBackend for MinimalBackend {
|
||||
async fn create_key(&self, _request: CreateKeyRequest) -> Result<CreateKeyResponse> {
|
||||
unimplemented!("not exercised by capability tests")
|
||||
}
|
||||
|
||||
async fn encrypt(&self, _request: EncryptRequest) -> Result<EncryptResponse> {
|
||||
unimplemented!("not exercised by capability tests")
|
||||
}
|
||||
|
||||
async fn decrypt(&self, _request: DecryptRequest) -> Result<DecryptResponse> {
|
||||
unimplemented!("not exercised by capability tests")
|
||||
}
|
||||
|
||||
async fn generate_data_key(&self, _request: GenerateDataKeyRequest) -> Result<GenerateDataKeyResponse> {
|
||||
unimplemented!("not exercised by capability tests")
|
||||
}
|
||||
|
||||
async fn describe_key(&self, _request: DescribeKeyRequest) -> Result<DescribeKeyResponse> {
|
||||
unimplemented!("not exercised by capability tests")
|
||||
}
|
||||
|
||||
async fn list_keys(&self, _request: ListKeysRequest) -> Result<ListKeysResponse> {
|
||||
unimplemented!("not exercised by capability tests")
|
||||
}
|
||||
|
||||
async fn delete_key(&self, _request: DeleteKeyRequest) -> Result<DeleteKeyResponse> {
|
||||
unimplemented!("not exercised by capability tests")
|
||||
}
|
||||
|
||||
async fn cancel_key_deletion(&self, _request: CancelKeyDeletionRequest) -> Result<CancelKeyDeletionResponse> {
|
||||
unimplemented!("not exercised by capability tests")
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn capabilities_snapshot(capabilities: BackendCapabilities) -> std::collections::BTreeMap<String, bool> {
|
||||
serde_json::from_value(serde_json::to_value(capabilities).expect("capabilities should serialize"))
|
||||
.expect("capabilities should deserialize into a flat bool map")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_capabilities_are_conservative() {
|
||||
let capabilities = MinimalBackend.capabilities();
|
||||
assert_eq!(capabilities, BackendCapabilities::minimal());
|
||||
assert_eq!(capabilities, BackendCapabilities::default());
|
||||
|
||||
// The conservative baseline advertises only trait-mandated operations.
|
||||
assert!(capabilities.encrypt);
|
||||
assert!(capabilities.decrypt);
|
||||
assert!(capabilities.generate_data_key);
|
||||
assert!(!capabilities.rotate);
|
||||
assert!(!capabilities.enable_disable);
|
||||
assert!(!capabilities.schedule_deletion);
|
||||
assert!(!capabilities.versioning);
|
||||
assert!(!capabilities.physical_delete);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_lifecycle_operations_are_unsupported() {
|
||||
for (operation, result) in [
|
||||
("enable_key", MinimalBackend.enable_key("any-key").await),
|
||||
("disable_key", MinimalBackend.disable_key("any-key").await),
|
||||
("rotate_key", MinimalBackend.rotate_key("any-key").await),
|
||||
] {
|
||||
let error = result.expect_err("backends must opt in to lifecycle operations by overriding them");
|
||||
assert!(
|
||||
matches!(error, KmsError::UnsupportedCapability { .. }),
|
||||
"expected UnsupportedCapability for {operation}, got {error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_remove_expired_key_is_unsupported() {
|
||||
let error = MinimalBackend
|
||||
.remove_expired_key("any-key", &jiff::Zoned::now())
|
||||
.await
|
||||
.expect_err("backends without deletion support must reject expired-key removal");
|
||||
assert!(
|
||||
matches!(error, KmsError::UnsupportedCapability { .. }),
|
||||
"expected UnsupportedCapability, got {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_backend_capabilities_golden() {
|
||||
let temp_dir = tempfile::tempdir().expect("temp dir should be created");
|
||||
let config = KmsConfig::local(temp_dir.path().to_path_buf()).with_insecure_development_defaults();
|
||||
let backend = local::LocalKmsBackend::new(config).await.expect("local backend should build");
|
||||
|
||||
insta::assert_json_snapshot!("local_backend_capabilities", capabilities_snapshot(backend.capabilities()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_kv2_backend_capabilities_golden() {
|
||||
let config = KmsConfig::vault(
|
||||
url::Url::parse("http://127.0.0.1:8200").expect("vault URL should parse"),
|
||||
"dev-token".to_string(),
|
||||
)
|
||||
.with_insecure_development_defaults();
|
||||
// Constructing the client performs no network I/O with token auth.
|
||||
let backend = vault::VaultKmsBackend::new(config)
|
||||
.await
|
||||
.expect("vault kv2 backend should build");
|
||||
|
||||
insta::assert_json_snapshot!("vault_kv2_backend_capabilities", capabilities_snapshot(backend.capabilities()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn vault_transit_backend_capabilities_golden() {
|
||||
let config = KmsConfig::vault_transit(
|
||||
url::Url::parse("http://127.0.0.1:8200").expect("vault URL should parse"),
|
||||
"dev-token".to_string(),
|
||||
)
|
||||
.with_insecure_development_defaults();
|
||||
// Constructing the client performs no network I/O with token auth.
|
||||
let backend = vault_transit::VaultTransitKmsBackend::new(config)
|
||||
.await
|
||||
.expect("vault transit backend should build");
|
||||
|
||||
insta::assert_json_snapshot!("vault_transit_backend_capabilities", capabilities_snapshot(backend.capabilities()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn static_backend_capabilities_golden() {
|
||||
let config = KmsConfig::static_kms("static-key".to_string(), BASE64.encode([0u8; 32]));
|
||||
let backend = static_kms::StaticKmsBackend::new(config)
|
||||
.await
|
||||
.expect("static backend should build");
|
||||
|
||||
insta::assert_json_snapshot!("static_backend_capabilities", capabilities_snapshot(backend.capabilities()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// 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.
|
||||
|
||||
//! Minimal scripted HTTP responder standing in for a Vault server.
|
||||
//!
|
||||
//! Wiring tests need to observe how many Vault requests a code path performs
|
||||
//! (retries, read-confirm recovery) without a live Vault. The responder serves
|
||||
//! one canned response per incoming request in order, closes the connection
|
||||
//! after each response, and records the `METHOD /path` sequence for
|
||||
//! assertions. It intentionally implements just enough HTTP/1.1 for the
|
||||
//! `vaultrs` reqwest client: no keep-alive, no chunked bodies.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
/// One canned HTTP response.
|
||||
pub(crate) struct ScriptedResponse {
|
||||
status: u16,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl ScriptedResponse {
|
||||
/// A 200 response carrying `data` inside the standard Vault envelope.
|
||||
pub(crate) fn ok(data: serde_json::Value) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
body: serde_json::json!({
|
||||
"request_id": "scripted",
|
||||
"lease_id": "",
|
||||
"lease_duration": 0,
|
||||
"renewable": false,
|
||||
"data": data,
|
||||
})
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An error response in Vault's `{"errors": [...]}` format.
|
||||
pub(crate) fn error(status: u16, message: &str) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: serde_json::json!({ "errors": [message] }).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A scripted stand-in Vault listening on a loopback port.
|
||||
pub(crate) struct ScriptedVault {
|
||||
/// Base address (`http://127.0.0.1:port`) to point a Vault client at.
|
||||
pub(crate) address: String,
|
||||
requests: Arc<Mutex<Vec<(String, String)>>>,
|
||||
}
|
||||
|
||||
impl ScriptedVault {
|
||||
/// Bind a loopback listener and serve `responses` one per request.
|
||||
///
|
||||
/// Requests beyond the script get a 599 error so a test that under-scripts
|
||||
/// fails loudly instead of hanging.
|
||||
pub(crate) async fn serve(responses: Vec<ScriptedResponse>) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind scripted vault listener");
|
||||
let address = format!("http://{}", listener.local_addr().expect("scripted vault local addr"));
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let recorded = Arc::clone(&requests);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut responses = responses.into_iter();
|
||||
loop {
|
||||
let Ok((mut stream, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
let Some(request) = read_request(&mut stream).await else {
|
||||
continue;
|
||||
};
|
||||
recorded.lock().expect("scripted vault request log poisoned").push(request);
|
||||
let response = responses
|
||||
.next()
|
||||
.unwrap_or_else(|| ScriptedResponse::error(599, "scripted vault: script exhausted"));
|
||||
let payload = format!(
|
||||
"HTTP/1.1 {} Scripted\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
|
||||
response.status,
|
||||
response.body.len(),
|
||||
response.body
|
||||
);
|
||||
let _ = stream.write_all(payload.as_bytes()).await;
|
||||
let _ = stream.shutdown().await;
|
||||
}
|
||||
});
|
||||
|
||||
Self { address, requests }
|
||||
}
|
||||
|
||||
/// The `METHOD /path` lines of every request served so far, in order.
|
||||
pub(crate) fn requests(&self) -> Vec<String> {
|
||||
self.requests
|
||||
.lock()
|
||||
.expect("scripted vault request log poisoned")
|
||||
.iter()
|
||||
.map(|(line, _)| line.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The request bodies, in the same order as [`Self::requests`]; empty for
|
||||
/// bodyless requests. Lets tests assert what a write actually persisted
|
||||
/// (record contents, check-and-set options), not just that a write happened.
|
||||
pub(crate) fn request_bodies(&self) -> Vec<String> {
|
||||
self.requests
|
||||
.lock()
|
||||
.expect("scripted vault request log poisoned")
|
||||
.iter()
|
||||
.map(|(_, body)| body.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one HTTP/1.1 request (head plus content-length body) and return its
|
||||
/// `METHOD /path` line together with the body. Draining the body before
|
||||
/// responding keeps the client from seeing a connection reset while it is
|
||||
/// still writing.
|
||||
async fn read_request(stream: &mut TcpStream) -> Option<(String, String)> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
let head_end = loop {
|
||||
if let Some(position) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
break position + 4;
|
||||
}
|
||||
let read = stream.read(&mut chunk).await.ok()?;
|
||||
if read == 0 {
|
||||
return None;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
};
|
||||
|
||||
let head = String::from_utf8_lossy(&buffer[..head_end]).into_owned();
|
||||
let mut lines = head.lines();
|
||||
let request_line = lines.next()?;
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = parts.next()?;
|
||||
let path = parts.next()?;
|
||||
// rustify appends a lone "?" when an endpoint has no query parameters;
|
||||
// strip it so assertions can use the plain path.
|
||||
let path = path.strip_suffix('?').unwrap_or(path);
|
||||
|
||||
let content_length: usize = lines
|
||||
.filter_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse().ok())?
|
||||
})
|
||||
.next()
|
||||
.unwrap_or(0);
|
||||
let mut body = buffer[head_end..].to_vec();
|
||||
let mut remaining = content_length.saturating_sub(body.len());
|
||||
while remaining > 0 {
|
||||
let read = stream.read(&mut chunk).await.ok()?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&chunk[..read]);
|
||||
remaining = remaining.saturating_sub(read);
|
||||
}
|
||||
body.truncate(content_length);
|
||||
|
||||
Some((format!("{method} {path}"), String::from_utf8_lossy(&body).into_owned()))
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: crates/kms/src/backends/mod.rs
|
||||
expression: capabilities_snapshot(backend.capabilities())
|
||||
---
|
||||
{
|
||||
"decrypt": true,
|
||||
"enable_disable": true,
|
||||
"encrypt": true,
|
||||
"generate_data_key": true,
|
||||
"physical_delete": true,
|
||||
"rotate": false,
|
||||
"schedule_deletion": true,
|
||||
"versioning": false
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: crates/kms/src/backends/mod.rs
|
||||
expression: capabilities_snapshot(backend.capabilities())
|
||||
---
|
||||
{
|
||||
"decrypt": true,
|
||||
"enable_disable": false,
|
||||
"encrypt": true,
|
||||
"generate_data_key": true,
|
||||
"physical_delete": false,
|
||||
"rotate": false,
|
||||
"schedule_deletion": false,
|
||||
"versioning": false
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: crates/kms/src/backends/mod.rs
|
||||
expression: capabilities_snapshot(backend.capabilities())
|
||||
---
|
||||
{
|
||||
"decrypt": true,
|
||||
"enable_disable": true,
|
||||
"encrypt": true,
|
||||
"generate_data_key": true,
|
||||
"physical_delete": true,
|
||||
"rotate": true,
|
||||
"schedule_deletion": true,
|
||||
"versioning": true
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: crates/kms/src/backends/mod.rs
|
||||
expression: capabilities_snapshot(backend.capabilities())
|
||||
---
|
||||
{
|
||||
"decrypt": true,
|
||||
"enable_disable": true,
|
||||
"encrypt": true,
|
||||
"generate_data_key": true,
|
||||
"physical_delete": true,
|
||||
"rotate": true,
|
||||
"schedule_deletion": true,
|
||||
"versioning": true
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
//!
|
||||
//! encrypted_data(plaintext_len+16) || nonce (12 bytes)
|
||||
|
||||
use crate::backends::{BackendInfo, KmsBackend, KmsClient};
|
||||
use crate::backends::{BackendCapabilities, KmsBackend};
|
||||
use crate::config::{BackendConfig, KmsConfig};
|
||||
use crate::encryption::DataKeyEnvelope;
|
||||
use crate::error::{KmsError, Result};
|
||||
@@ -98,9 +98,10 @@ impl StaticKmsBackend {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KmsClient for StaticKmsBackend {
|
||||
async fn generate_data_key(&self, request: &GenerateKeyRequest, _context: Option<&OperationContext>) -> Result<DataKeyInfo> {
|
||||
impl StaticKmsBackend {
|
||||
/// Generate a fresh data key and wrap it in the standard KMS envelope,
|
||||
/// authenticated against the canonical encryption context.
|
||||
pub(crate) fn generate_data_key_envelope(&self, request: &GenerateKeyRequest) -> Result<DataKeyInfo> {
|
||||
if request.master_key_id != self.key_id {
|
||||
return Err(KmsError::key_not_found(&request.master_key_id));
|
||||
}
|
||||
@@ -137,6 +138,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)?;
|
||||
|
||||
@@ -149,7 +152,8 @@ impl KmsClient for StaticKmsBackend {
|
||||
))
|
||||
}
|
||||
|
||||
async fn encrypt(&self, request: &EncryptRequest, _context: Option<&OperationContext>) -> Result<EncryptResponse> {
|
||||
/// Encrypt caller-provided plaintext into the standard KMS envelope.
|
||||
pub(crate) fn encrypt_to_envelope(&self, request: &EncryptRequest) -> Result<EncryptResponse> {
|
||||
if request.key_id != self.key_id {
|
||||
return Err(KmsError::key_not_found(&request.key_id));
|
||||
}
|
||||
@@ -181,6 +185,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)?;
|
||||
|
||||
@@ -192,7 +198,8 @@ impl KmsClient for StaticKmsBackend {
|
||||
})
|
||||
}
|
||||
|
||||
async fn decrypt(&self, request: &DecryptRequest, _context: Option<&OperationContext>) -> Result<Vec<u8>> {
|
||||
/// Open a KMS envelope produced by this backend.
|
||||
pub(crate) fn decrypt_envelope(&self, request: &DecryptRequest) -> Result<Vec<u8>> {
|
||||
let envelope: DataKeyEnvelope = serde_json::from_slice(&request.ciphertext)
|
||||
.map_err(|error| KmsError::cryptographic_error("parse", format!("Failed to parse data key envelope: {error}")))?;
|
||||
if envelope.master_key_id != self.key_id {
|
||||
@@ -231,14 +238,8 @@ impl KmsClient for StaticKmsBackend {
|
||||
Ok(plaintext)
|
||||
}
|
||||
|
||||
async fn create_key(&self, key_id: &str, _algorithm: &str, _context: Option<&OperationContext>) -> Result<MasterKeyInfo> {
|
||||
if key_id == self.key_id {
|
||||
return Err(KmsError::key_already_exists(key_id));
|
||||
}
|
||||
Err(KmsError::invalid_operation("Static KMS is read-only: cannot create new keys"))
|
||||
}
|
||||
|
||||
async fn describe_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<KeyInfo> {
|
||||
/// Describe the single configured key.
|
||||
pub(crate) fn configured_key_info(&self, key_id: &str) -> Result<KeyInfo> {
|
||||
if key_id != self.key_id {
|
||||
return Err(KmsError::key_not_found(key_id));
|
||||
}
|
||||
@@ -259,7 +260,8 @@ impl KmsClient for StaticKmsBackend {
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_keys(&self, request: &ListKeysRequest, _context: Option<&OperationContext>) -> Result<ListKeysResponse> {
|
||||
/// List the single configured key, honouring the pagination marker.
|
||||
pub(crate) fn list_configured_key(&self, request: &ListKeysRequest) -> Result<ListKeysResponse> {
|
||||
let key_info = KeyInfo {
|
||||
key_id: self.key_id.clone(),
|
||||
description: Some("Static single-key KMS backend".to_string()),
|
||||
@@ -291,57 +293,6 @@ impl KmsClient for StaticKmsBackend {
|
||||
truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn enable_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<()> {
|
||||
if key_id != self.key_id {
|
||||
return Err(KmsError::key_not_found(key_id));
|
||||
}
|
||||
// Static KMS key is always enabled
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disable_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<()> {
|
||||
if key_id != self.key_id {
|
||||
return Err(KmsError::key_not_found(key_id));
|
||||
}
|
||||
Err(KmsError::invalid_operation("Static KMS is read-only: cannot disable keys"))
|
||||
}
|
||||
|
||||
async fn schedule_key_deletion(
|
||||
&self,
|
||||
key_id: &str,
|
||||
_pending_window_days: u32,
|
||||
_context: Option<&OperationContext>,
|
||||
) -> Result<()> {
|
||||
if key_id != self.key_id {
|
||||
return Err(KmsError::key_not_found(key_id));
|
||||
}
|
||||
Err(KmsError::invalid_operation("Static KMS is read-only: cannot schedule key deletion"))
|
||||
}
|
||||
|
||||
async fn cancel_key_deletion(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<()> {
|
||||
if key_id != self.key_id {
|
||||
return Err(KmsError::key_not_found(key_id));
|
||||
}
|
||||
Err(KmsError::invalid_operation("Static KMS is read-only: cannot cancel key deletion"))
|
||||
}
|
||||
|
||||
async fn rotate_key(&self, key_id: &str, _context: Option<&OperationContext>) -> Result<MasterKeyInfo> {
|
||||
if key_id != self.key_id {
|
||||
return Err(KmsError::key_not_found(key_id));
|
||||
}
|
||||
Err(KmsError::invalid_operation("Static KMS is read-only: cannot rotate keys"))
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<()> {
|
||||
// Static KMS is always healthy if it was successfully initialized
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backend_info(&self) -> BackendInfo {
|
||||
BackendInfo::new("static".to_string(), env!("CARGO_PKG_VERSION").to_string(), "local".to_string(), true)
|
||||
.with_metadata("key_id".to_string(), self.key_id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -355,12 +306,12 @@ impl KmsBackend for StaticKmsBackend {
|
||||
}
|
||||
|
||||
async fn encrypt(&self, request: EncryptRequest) -> Result<EncryptResponse> {
|
||||
<Self as KmsClient>::encrypt(self, &request, None).await
|
||||
self.encrypt_to_envelope(&request)
|
||||
}
|
||||
|
||||
async fn decrypt(&self, request: DecryptRequest) -> Result<DecryptResponse> {
|
||||
let key_id = self.key_id.clone();
|
||||
let plaintext = <Self as KmsClient>::decrypt(self, &request, None).await?;
|
||||
let plaintext = self.decrypt_envelope(&request)?;
|
||||
Ok(DecryptResponse {
|
||||
plaintext,
|
||||
key_id,
|
||||
@@ -376,7 +327,7 @@ impl KmsBackend for StaticKmsBackend {
|
||||
encryption_context: request.encryption_context,
|
||||
grant_tokens: Vec::new(),
|
||||
};
|
||||
let data_key = <Self as KmsClient>::generate_data_key(self, &gen_req, None).await?;
|
||||
let data_key = self.generate_data_key_envelope(&gen_req)?;
|
||||
|
||||
let plaintext_key = data_key
|
||||
.plaintext
|
||||
@@ -391,7 +342,7 @@ impl KmsBackend for StaticKmsBackend {
|
||||
}
|
||||
|
||||
async fn describe_key(&self, request: DescribeKeyRequest) -> Result<DescribeKeyResponse> {
|
||||
let key_info = <Self as KmsClient>::describe_key(self, &request.key_id, None).await?;
|
||||
let key_info = self.configured_key_info(&request.key_id)?;
|
||||
let key_metadata = KeyMetadata {
|
||||
key_id: key_info.key_id.clone(),
|
||||
key_state: if key_info.status == KeyStatus::Active {
|
||||
@@ -411,7 +362,7 @@ impl KmsBackend for StaticKmsBackend {
|
||||
}
|
||||
|
||||
async fn list_keys(&self, request: ListKeysRequest) -> Result<ListKeysResponse> {
|
||||
<Self as KmsClient>::list_keys(self, &request, None).await
|
||||
self.list_configured_key(&request)
|
||||
}
|
||||
|
||||
async fn delete_key(&self, request: DeleteKeyRequest) -> Result<DeleteKeyResponse> {
|
||||
@@ -431,12 +382,18 @@ impl KmsBackend for StaticKmsBackend {
|
||||
async fn health_check(&self) -> Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> BackendCapabilities {
|
||||
// Static KMS is a read-only single-key backend: it only performs
|
||||
// cryptographic operations and rejects every lifecycle mutation.
|
||||
BackendCapabilities::minimal()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backends::{KmsBackend as KmsBackendTrait, KmsClient};
|
||||
use crate::backends::KmsBackend as KmsBackendTrait;
|
||||
use crate::config::{BackendConfig, KmsBackend, StaticConfig};
|
||||
use crate::encryption::is_data_key_envelope;
|
||||
use base64::Engine as _;
|
||||
@@ -481,8 +438,8 @@ mod tests {
|
||||
// Generate data key
|
||||
let request = GenerateKeyRequest::new(key_id.clone(), "AES_256".to_string())
|
||||
.with_context("bucket".to_string(), "test-bucket".to_string());
|
||||
let data_key = KmsClient::generate_data_key(&backend, &request, None)
|
||||
.await
|
||||
let data_key = backend
|
||||
.generate_data_key_envelope(&request)
|
||||
.expect("Failed to generate data key");
|
||||
|
||||
assert_eq!(data_key.key_id, key_id);
|
||||
@@ -499,9 +456,7 @@ mod tests {
|
||||
// Decrypt the data key
|
||||
let decrypt_request =
|
||||
DecryptRequest::new(data_key.ciphertext.clone()).with_context("bucket".to_string(), "test-bucket".to_string());
|
||||
let decrypted = KmsClient::decrypt(&backend, &decrypt_request, None)
|
||||
.await
|
||||
.expect("Failed to decrypt");
|
||||
let decrypted = backend.decrypt_envelope(&decrypt_request).expect("Failed to decrypt");
|
||||
|
||||
assert_eq!(decrypted.as_slice(), data_key.plaintext.as_deref().expect("plaintext should exist"));
|
||||
}
|
||||
@@ -513,8 +468,8 @@ mod tests {
|
||||
.with_context("bucket".to_string(), "source-bucket".to_string())
|
||||
.with_context("object".to_string(), "source-object".to_string());
|
||||
|
||||
let data_key = KmsClient::generate_data_key(&backend, &request, None)
|
||||
.await
|
||||
let data_key = backend
|
||||
.generate_data_key_envelope(&request)
|
||||
.expect("generate static KMS data key");
|
||||
|
||||
assert!(
|
||||
@@ -558,8 +513,8 @@ mod tests {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
let request = GenerateKeyRequest::new(key_id, "AES_256".to_string())
|
||||
.with_context("bucket".to_string(), "source-bucket".to_string());
|
||||
let generated = KmsClient::generate_data_key(&backend, &request, None)
|
||||
.await
|
||||
let generated = backend
|
||||
.generate_data_key_envelope(&request)
|
||||
.expect("generate context-bound data key");
|
||||
let mut envelope: DataKeyEnvelope = serde_json::from_slice(&generated.ciphertext).expect("parse static KMS envelope");
|
||||
envelope
|
||||
@@ -568,8 +523,8 @@ mod tests {
|
||||
let decrypt_request = DecryptRequest::new(serde_json::to_vec(&envelope).expect("serialize tampered envelope"))
|
||||
.with_context("bucket".to_string(), "different-bucket".to_string());
|
||||
|
||||
let error = KmsClient::decrypt(&backend, &decrypt_request, None)
|
||||
.await
|
||||
let error = backend
|
||||
.decrypt_envelope(&decrypt_request)
|
||||
.expect_err("tampering with authenticated envelope context must fail");
|
||||
|
||||
assert!(matches!(error, KmsError::CryptographicError { .. }));
|
||||
@@ -580,7 +535,7 @@ mod tests {
|
||||
let (backend, _key_id, _key) = create_test_backend().await;
|
||||
|
||||
let request = GenerateKeyRequest::new("wrong-key-id".to_string(), "AES_256".to_string());
|
||||
let result = KmsClient::generate_data_key(&backend, &request, None).await;
|
||||
let result = backend.generate_data_key_envelope(&request);
|
||||
assert!(result.is_err());
|
||||
assert!(result.expect_err("should be Err").to_string().contains("wrong-key-id"));
|
||||
}
|
||||
@@ -592,7 +547,7 @@ mod tests {
|
||||
// Ciphertext too short
|
||||
let short = vec![0u8; 10];
|
||||
let request = DecryptRequest::new(short);
|
||||
let result = KmsClient::decrypt(&backend, &request, None).await;
|
||||
let result = backend.decrypt_envelope(&request);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -602,9 +557,7 @@ mod tests {
|
||||
|
||||
// Generate a valid ciphertext first
|
||||
let gen_request = GenerateKeyRequest::new(key_id, "AES_256".to_string());
|
||||
let data_key = KmsClient::generate_data_key(&backend, &gen_request, None)
|
||||
.await
|
||||
.expect("generate");
|
||||
let data_key = backend.generate_data_key_envelope(&gen_request).expect("generate");
|
||||
|
||||
// Tamper with the ciphertext (flip a bit in the encrypted portion)
|
||||
let mut tampered = data_key.ciphertext.clone();
|
||||
@@ -613,7 +566,7 @@ mod tests {
|
||||
}
|
||||
|
||||
let request = DecryptRequest::new(tampered);
|
||||
let result = KmsClient::decrypt(&backend, &request, None).await;
|
||||
let result = backend.decrypt_envelope(&request);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -622,7 +575,14 @@ mod tests {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
// Creating the pre-configured key should return KeyAlreadyExists
|
||||
let result = KmsClient::create_key(&backend, &key_id, "AES_256", None).await;
|
||||
let result = KmsBackendTrait::create_key(
|
||||
&backend,
|
||||
CreateKeyRequest {
|
||||
key_name: Some(key_id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.expect_err("should be Err").to_string().contains("already exists"));
|
||||
}
|
||||
@@ -632,7 +592,14 @@ mod tests {
|
||||
let (backend, _key_id, _key) = create_test_backend().await;
|
||||
|
||||
// Creating any other key should return invalid operation (read-only)
|
||||
let result = KmsClient::create_key(&backend, "other-key", "AES_256", None).await;
|
||||
let result = KmsBackendTrait::create_key(
|
||||
&backend,
|
||||
CreateKeyRequest {
|
||||
key_name: Some("other-key".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.expect_err("should be Err").to_string();
|
||||
assert!(err_msg.contains("read-only") || err_msg.contains("cannot create"));
|
||||
@@ -642,15 +609,13 @@ mod tests {
|
||||
async fn test_describe_key() {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
let key_info = KmsClient::describe_key(&backend, &key_id, None)
|
||||
.await
|
||||
.expect("describe_key should succeed");
|
||||
let key_info = backend.configured_key_info(&key_id).expect("describe_key should succeed");
|
||||
assert_eq!(key_info.key_id, key_id);
|
||||
assert_eq!(key_info.status, KeyStatus::Active);
|
||||
assert_eq!(key_info.algorithm, "AES_256");
|
||||
|
||||
// Wrong key ID
|
||||
let result = KmsClient::describe_key(&backend, "nonexistent", None).await;
|
||||
let result = backend.configured_key_info("nonexistent");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -658,8 +623,8 @@ mod tests {
|
||||
async fn test_list_keys() {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
let response = KmsClient::list_keys(&backend, &ListKeysRequest::default(), None)
|
||||
.await
|
||||
let response = backend
|
||||
.list_configured_key(&ListKeysRequest::default())
|
||||
.expect("list_keys should succeed");
|
||||
assert_eq!(response.keys.len(), 1);
|
||||
assert_eq!(response.keys[0].key_id, key_id);
|
||||
@@ -667,61 +632,45 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_disable_key_returns_error() {
|
||||
async fn lifecycle_mutations_are_unsupported_at_the_product_surface() {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
let result = KmsClient::disable_key(&backend, &key_id, None).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.expect_err("should be Err").to_string().contains("read-only"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_enable_key_is_noop() {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
// Enable should succeed (no-op for static KMS)
|
||||
KmsClient::enable_key(&backend, &key_id, None)
|
||||
.await
|
||||
.expect("enable_key should be no-op");
|
||||
|
||||
// Wrong key should still fail
|
||||
let result = KmsClient::enable_key(&backend, "wrong", None).await;
|
||||
assert!(result.is_err());
|
||||
// The static backend advertises no enable/disable or rotation
|
||||
// capability, so the shared KmsBackend defaults reject all three.
|
||||
for result in [
|
||||
KmsBackendTrait::enable_key(&backend, &key_id).await,
|
||||
KmsBackendTrait::disable_key(&backend, &key_id).await,
|
||||
KmsBackendTrait::rotate_key(&backend, &key_id).await,
|
||||
] {
|
||||
let error = result.expect_err("static lifecycle mutations must be rejected");
|
||||
assert!(matches!(error, KmsError::UnsupportedCapability { .. }), "got {error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_key_returns_error() {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
let result = KmsClient::schedule_key_deletion(&backend, &key_id, 7, None).await;
|
||||
let result = KmsBackendTrait::delete_key(
|
||||
&backend,
|
||||
DeleteKeyRequest {
|
||||
key_id: key_id.clone(),
|
||||
pending_window_in_days: Some(7),
|
||||
force_immediate: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.expect_err("should be Err").to_string().contains("read-only"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rotate_key_returns_error() {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
let result = KmsClient::rotate_key(&backend, &key_id, None).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_health_check() {
|
||||
let (backend, _key_id, _key) = create_test_backend().await;
|
||||
|
||||
KmsClient::health_check(&backend).await.expect("health_check should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_backend_info() {
|
||||
let (backend, key_id, _key) = create_test_backend().await;
|
||||
|
||||
let info = KmsClient::backend_info(&backend);
|
||||
assert_eq!(info.backend_type, "static");
|
||||
assert_eq!(info.endpoint, "local");
|
||||
assert!(info.healthy);
|
||||
assert_eq!(info.metadata.get("key_id"), Some(&key_id));
|
||||
KmsBackendTrait::health_check(&backend)
|
||||
.await
|
||||
.expect("health_check should succeed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -730,17 +679,13 @@ mod tests {
|
||||
|
||||
let plaintext = b"Hello, static KMS world!";
|
||||
let enc_request = EncryptRequest::new(key_id.clone(), plaintext.to_vec());
|
||||
let enc_response = KmsClient::encrypt(&backend, &enc_request, None)
|
||||
.await
|
||||
.expect("encrypt should succeed");
|
||||
let enc_response = backend.encrypt_to_envelope(&enc_request).expect("encrypt should succeed");
|
||||
|
||||
assert_eq!(enc_response.key_id, key_id);
|
||||
assert!(!enc_response.ciphertext.is_empty());
|
||||
|
||||
let dec_request = DecryptRequest::new(enc_response.ciphertext);
|
||||
let decrypted = KmsClient::decrypt(&backend, &dec_request, None)
|
||||
.await
|
||||
.expect("decrypt should succeed");
|
||||
let decrypted = backend.decrypt_envelope(&dec_request).expect("decrypt should succeed");
|
||||
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
+2246
-270
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user